diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index a07991f..bd528a7 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -81,6 +81,10 @@ jobs: grep -q "createEncryptedClient" crates/fula-js/pkg/fula_js.d.ts grep -q "getDecrypted" crates/fula-js/pkg/fula_js.d.ts grep -q "deriveKey" crates/fula-js/pkg/fula_js.d.ts + # Issue #36 — forest-cache invalidation must stay exposed for + # npm parity with the fula-flutter bindings. + grep -q "invalidateForestCache" crates/fula-js/pkg/fula_js.d.ts + grep -q "invalidateAllForestCaches" crates/fula-js/pkg/fula_js.d.ts echo "All expected exports found!" # Layer 1: ban panic-on-wasm stdlib time APIs at compile time. diff --git a/Cargo.lock b/Cargo.lock index d584710..2aa7788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1736,7 +1736,7 @@ dependencies = [ [[package]] name = "fula-api" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "axum", @@ -1765,7 +1765,7 @@ dependencies = [ [[package]] name = "fula-blockstore" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "async-trait", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "fula-cli" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "async-trait", @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "fula-client" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "async-trait", @@ -1899,7 +1899,7 @@ dependencies = [ [[package]] name = "fula-core" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "async-trait", @@ -1934,7 +1934,7 @@ dependencies = [ [[package]] name = "fula-crypto" -version = "0.6.8" +version = "0.6.9" dependencies = [ "aes-gcm", "anyhow", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "fula-flutter" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "async-lock", @@ -2002,7 +2002,7 @@ dependencies = [ [[package]] name = "fula-js" -version = "0.6.8" +version = "0.6.9" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index b836b7a..4e45609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ name = "encrypted_upload_test" path = "examples/encrypted_upload_test.rs" [workspace.package] -version = "0.6.8" +version = "0.6.9" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/crates/fula-client/src/encryption.rs b/crates/fula-client/src/encryption.rs index 10359a6..522aa0b 100644 --- a/crates/fula-client/src/encryption.rs +++ b/crates/fula-client/src/encryption.rs @@ -8934,13 +8934,17 @@ impl EncryptedClient { /// Forces the next `load_forest` call to reload from storage. /// Dirty (unsaved) entries are NOT evicted — call `flush_forest()` first /// to persist changes before invalidating. + /// + /// The dirty check and the removal are ATOMIC (`DashMap::remove_if` + /// holds the shard lock across both). The previous get-then-remove + /// shape had a TOCTOU window where a concurrent deferred put could + /// dirty the entry between the check and the eviction, silently + /// dropping the pending index entry — reachable now that issue #36 + /// exposes this call to arbitrary app refresh paths running + /// concurrently with uploads. pub fn invalidate_forest_cache(&self, bucket: &str) { - let is_dirty = self.forest_cache.get(bucket) - .map(|entry| entry.is_dirty()) - .unwrap_or(false); - if !is_dirty { - self.forest_cache.remove(bucket); - } + self.forest_cache + .remove_if(bucket, |_, entry| !entry.is_dirty()); } /// Invalidate all cached forests diff --git a/crates/fula-client/tests/issue_36_invalidate_forest_cache.rs b/crates/fula-client/tests/issue_36_invalidate_forest_cache.rs new file mode 100644 index 0000000..8e7ab2c --- /dev/null +++ b/crates/fula-client/tests/issue_36_invalidate_forest_cache.rs @@ -0,0 +1,374 @@ +//! Issue #36 — forest-cache invalidation behaviour the new fula-flutter / +//! fula-js bindings call through to. +//! +//! The encrypted client caches each bucket's forest for the CLIENT +//! LIFETIME: once loaded, `list_files_from_forest` / `get_object_flat` +//! resolve against memory and never observe another device's uploads. +//! `invalidate_forest_cache` / `invalidate_all_forest_caches` are the +//! escape hatch (drop the cached forest; next operation reloads from +//! storage) — issue #36 exposes them through the app-facing bindings. +//! +//! These tests pin the acceptance criteria from the issue at the +//! `EncryptedClient` layer (the layer both bindings delegate to): +//! +//! 1. After another client uploads to a bucket, +//! `invalidate_forest_cache(bucket)` + `list_from_forest` on an +//! EXISTING client returns the new file — no client rebuild. +//! 2. A dirty (unsaved local changes) forest is NOT evicted — the +//! documented contract that makes the call safe to wire into +//! arbitrary app refresh paths. +//! 3. `invalidate_all_forest_caches` drops every clean forest and +//! keeps every dirty one. +//! +//! Same stateful conditional-PUT mock harness as +//! `issue_34_two_client_interop.rs` (self-contained per repo convention). + +#![cfg(not(target_arch = "wasm32"))] + +use bytes::Bytes; +use cid::multihash::Multihash; +use cid::Cid; +use fula_client::{Config, EncryptedClient, EncryptionConfig}; +use fula_crypto::keys::SecretKey; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; +use wiremock::matchers::method; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +// ───────────────────────────── stateful mock ──────────────────────────────── + +fn blake3_raw_cid(data: &[u8]) -> Cid { + let h = blake3::hash(data); + let mh = Multihash::<64>::wrap(0x1e, h.as_bytes()).expect("blake3 multihash wrap"); + Cid::new_v1(0x55, mh) +} + +type Stash = Arc>>>; + +fn header_value(req: &Request, name: &str) -> Option { + req.headers + .iter() + .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name)) + .and_then(|(_, v)| v.to_str().ok()) + .map(|s| s.to_string()) +} + +struct PutResponder { + stash: Stash, +} +impl Respond for PutResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let path = req.url.path().to_string(); + let body = req.body.clone(); + let mut s = self.stash.lock().unwrap(); + + if let Some(inm) = header_value(req, "If-None-Match") { + if inm.trim() == "*" && s.contains_key(&path) { + return ResponseTemplate::new(412); + } + } + if let Some(im) = header_value(req, "If-Match") { + let want = im.trim().trim_matches('"').to_string(); + let current = s.get(&path).map(|b| blake3_raw_cid(b).to_string()); + if current.as_deref() != Some(want.as_str()) { + return ResponseTemplate::new(412); + } + } + + let cid = blake3_raw_cid(&body); + s.insert(path, body); + ResponseTemplate::new(200).insert_header("ETag", cid.to_string()) + } +} + +struct GetResponder { + stash: Stash, +} +impl Respond for GetResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let path = req.url.path().to_string(); + match self.stash.lock().unwrap().get(&path) { + Some(bytes) => ResponseTemplate::new(200) + .insert_header("ETag", blake3_raw_cid(bytes).to_string()) + .set_body_bytes(bytes.clone()), + None => ResponseTemplate::new(404), + } + } +} + +struct HeadResponder { + stash: Stash, +} +impl Respond for HeadResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let path = req.url.path().to_string(); + match self.stash.lock().unwrap().get(&path) { + Some(bytes) => ResponseTemplate::new(200) + .insert_header("ETag", blake3_raw_cid(bytes).to_string()) + .insert_header("Content-Length", bytes.len().to_string()), + None => ResponseTemplate::new(404), + } + } +} + +async fn start_mock() -> (MockServer, Stash) { + let server = MockServer::start().await; + let stash: Stash = Arc::new(Mutex::new(HashMap::new())); + Mock::given(method("PUT")) + .respond_with(PutResponder { stash: stash.clone() }) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(GetResponder { stash: stash.clone() }) + .mount(&server) + .await; + Mock::given(method("HEAD")) + .respond_with(HeadResponder { stash: stash.clone() }) + .mount(&server) + .await; + (server, stash) +} + +fn build_client(uri: &str, cache: &std::path::Path, secret: SecretKey) -> EncryptedClient { + let mut config = Config::new(uri).with_token("test-jwt"); + config.walkable_v8_writer_enabled = true; + config.block_cache_enabled = true; + config.block_cache_path = Some(cache.to_path_buf()); + EncryptedClient::new(config, EncryptionConfig::from_secret_key(secret)) + .expect("EncryptedClient::new") +} + +fn body_for(name: &str) -> Vec { + format!("issue36-unique-payload::{name}").into_bytes() +} + +/// Acceptance #1: an EXISTING long-lived client whose cached forest +/// predates another device's upload sees the new file after +/// `invalidate_forest_cache` + re-list — no client rebuild. Also pins +/// the bug premise this binding exists to escape: WITHOUT the +/// invalidate, the listing stays session-stale indefinitely. +#[tokio::test] +async fn stale_listing_refreshes_after_invalidate_without_client_rebuild() { + let state = TempDir::new().expect("state dir"); + std::env::set_var("FULA_STATE_DIR", state.path()); + + let (server, _stash) = start_mock().await; + let secret = SecretKey::generate(); + let bucket = "issue36-stale-listing"; + + let cache_a = TempDir::new().unwrap(); + let cache_b = TempDir::new().unwrap(); + let client_a = build_client(&server.uri(), &cache_a.path().join("a.redb"), secret.clone()); + let client_b = build_client(&server.uri(), &cache_b.path().join("b.redb"), secret.clone()); + + // Device A writes two files; its forest is now cached in-session. + for name in ["a-1", "a-2"] { + client_a + .put_object_flat(bucket, &format!("/docs/{name}.txt"), Bytes::from(body_for(name)), None) + .await + .unwrap_or_else(|e| panic!("client A put {name}: {e:?}")); + } + assert_eq!( + client_a.list_files_from_forest(bucket).await.unwrap().len(), + 2, + "device A baseline listing" + ); + + // Device B (another device, same user) uploads a third file. + client_b + .put_object_flat(bucket, "/docs/b-1.txt", Bytes::from(body_for("b-1")), None) + .await + .expect("client B put"); + + // The bug premise (#36): device A's listing is pinned to its + // session-cached forest and does NOT see B's upload, no matter how + // much later it lists. If this assert ever fails, the SDK started + // auto-revalidating and the binding's docs should be revisited. + assert_eq!( + client_a.list_files_from_forest(bucket).await.unwrap().len(), + 2, + "without invalidation a long-lived session must (still) be \ + lifetime-pinned — this is the premise #36's binding escapes" + ); + + // THE FIX PATH: invalidate + re-list on the SAME client instance. + client_a.invalidate_forest_cache(bucket); + let listing = client_a.list_files_from_forest(bucket).await.unwrap(); + assert_eq!( + listing.len(), + 3, + "after invalidate_forest_cache the existing client must see the \ + cross-device upload, got: {:?}", + listing.iter().map(|f| f.original_key.clone()).collect::>() + ); + + // And the new file is fully readable through the refreshed forest. + let got = client_a + .get_object_flat(bucket, "/docs/b-1.txt") + .await + .expect("client A must download B's file after invalidate"); + assert_eq!(got.to_vec(), body_for("b-1")); +} + +/// Acceptance #2: a forest carrying pending (unsaved) local changes is +/// NOT evicted — `invalidate_forest_cache` is safe to call from any app +/// refresh path without risking unflushed uploads. +#[tokio::test] +async fn dirty_forest_is_not_evicted_by_invalidate() { + let state = TempDir::new().expect("state dir"); + std::env::set_var("FULA_STATE_DIR", state.path()); + + let (server, _stash) = start_mock().await; + let secret = SecretKey::generate(); + let bucket = "issue36-dirty-safe"; + + let cache = TempDir::new().unwrap(); + let client = build_client(&server.uri(), &cache.path().join("c.redb"), secret.clone()); + + // Deferred put: object uploaded, forest entry pending in memory only. + client + .put_object_flat_deferred(bucket, "/docs/pending.txt", Bytes::from(body_for("pending")), None) + .await + .expect("deferred put"); + assert!( + client.has_pending_forest_changes(bucket).await, + "setup: forest must be dirty before the invalidate" + ); + + // Invalidate MUST keep the dirty forest (and its pending entry). + client.invalidate_forest_cache(bucket); + assert!( + client.has_pending_forest_changes(bucket).await, + "issue #36 contract: a dirty forest is NOT evicted by \ + invalidate_forest_cache — eviction here would silently drop the \ + unsaved upload's index entry" + ); + let listing = client.list_files_from_forest(bucket).await.unwrap(); + assert_eq!(listing.len(), 1, "pending entry must survive the invalidate"); + + // Flush → clean → now invalidation takes effect, and the reloaded + // forest still carries the (persisted) file. + client.flush_forest(bucket).await.expect("flush"); + assert!(!client.has_pending_forest_changes(bucket).await); + client.invalidate_forest_cache(bucket); + let listing = client.list_files_from_forest(bucket).await.unwrap(); + assert_eq!( + listing.len(), + 1, + "after flush + invalidate the reloaded-from-storage forest must \ + still list the persisted file" + ); +} + +/// Per-bucket isolation: invalidating bucket A must not touch bucket +/// B's cached forest — in EITHER direction (A refreshes, B stays +/// pinned until ITS invalidate). Guards against key mangling in the +/// cache map. +#[tokio::test] +async fn invalidate_is_per_bucket_isolated() { + let state = TempDir::new().expect("state dir"); + std::env::set_var("FULA_STATE_DIR", state.path()); + + let (server, _stash) = start_mock().await; + let secret = SecretKey::generate(); + let bucket_a = "issue36-isolated-a"; + let bucket_b = "issue36-isolated-b"; + + let cache_1 = TempDir::new().unwrap(); + let cache_2 = TempDir::new().unwrap(); + let client = build_client(&server.uri(), &cache_1.path().join("c.redb"), secret.clone()); + let other = build_client(&server.uri(), &cache_2.path().join("o.redb"), secret.clone()); + + // Both buckets cached on `client` with one file each. + for b in [bucket_a, bucket_b] { + client + .put_object_flat(b, "/docs/first.txt", Bytes::from(body_for(b)), None) + .await + .expect("seed put"); + } + // Another device adds a second file to BOTH buckets. + for b in [bucket_a, bucket_b] { + other + .put_object_flat(b, "/docs/second.txt", Bytes::from(body_for("second")), None) + .await + .expect("other-device put"); + } + + // Invalidate ONLY bucket A. + client.invalidate_forest_cache(bucket_a); + + assert_eq!( + client.list_files_from_forest(bucket_a).await.unwrap().len(), + 2, + "invalidated bucket must refresh and see the cross-device upload" + ); + assert_eq!( + client.list_files_from_forest(bucket_b).await.unwrap().len(), + 1, + "NON-invalidated bucket must keep its session-pinned view — \ + eviction leaked across bucket keys" + ); + + // And B refreshes once IT is invalidated. + client.invalidate_forest_cache(bucket_b); + assert_eq!( + client.list_files_from_forest(bucket_b).await.unwrap().len(), + 2, + "bucket B must refresh after its own invalidate" + ); +} + +/// Acceptance #3 (bulk variant): `invalidate_all_forest_caches` drops +/// every clean forest (so they refresh) and keeps every dirty one. +#[tokio::test] +async fn invalidate_all_drops_clean_forests_and_keeps_dirty_ones() { + let state = TempDir::new().expect("state dir"); + std::env::set_var("FULA_STATE_DIR", state.path()); + + let (server, _stash) = start_mock().await; + let secret = SecretKey::generate(); + let clean_bucket = "issue36-all-clean"; + let dirty_bucket = "issue36-all-dirty"; + + let cache_a = TempDir::new().unwrap(); + let cache_b = TempDir::new().unwrap(); + let client_a = build_client(&server.uri(), &cache_a.path().join("a.redb"), secret.clone()); + let client_b = build_client(&server.uri(), &cache_b.path().join("b.redb"), secret.clone()); + + // clean_bucket: flushed write → clean cached forest on A. + client_a + .put_object_flat(clean_bucket, "/docs/clean-1.txt", Bytes::from(body_for("clean-1")), None) + .await + .expect("clean put"); + // dirty_bucket: deferred write → dirty cached forest on A. + client_a + .put_object_flat_deferred(dirty_bucket, "/docs/dirty-1.txt", Bytes::from(body_for("dirty-1")), None) + .await + .expect("dirty put"); + + // Another device adds a second file to the CLEAN bucket. + client_b + .put_object_flat(clean_bucket, "/docs/clean-2.txt", Bytes::from(body_for("clean-2")), None) + .await + .expect("client B put"); + + client_a.invalidate_all_forest_caches(); + + // Clean bucket was dropped → re-list reloads and sees B's file. + assert_eq!( + client_a.list_files_from_forest(clean_bucket).await.unwrap().len(), + 2, + "clean forest must have been dropped and refreshed by invalidate_all" + ); + // Dirty bucket was kept → pending change and its entry survive. + assert!( + client_a.has_pending_forest_changes(dirty_bucket).await, + "dirty forest must survive invalidate_all" + ); + assert_eq!( + client_a.list_files_from_forest(dirty_bucket).await.unwrap().len(), + 1, + "dirty forest's pending entry must survive invalidate_all" + ); +} diff --git a/crates/fula-flutter/Cargo.toml b/crates/fula-flutter/Cargo.toml index ae656fb..9dd7506 100644 --- a/crates/fula-flutter/Cargo.toml +++ b/crates/fula-flutter/Cargo.toml @@ -5,7 +5,7 @@ description = "Flutter bindings for Fula decentralized storage - works on Androi # to parse `*.workspace = true` keys in its own manifest scan. Keep # these in sync with `[workspace.package]` in the root Cargo.toml. # (Same workaround as crates/fula-js.) -version = "0.6.8" +version = "0.6.9" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/crates/fula-flutter/src/api/forest.rs b/crates/fula-flutter/src/api/forest.rs index c4c8ef0..a26b52f 100644 --- a/crates/fula-flutter/src/api/forest.rs +++ b/crates/fula-flutter/src/api/forest.rs @@ -63,6 +63,36 @@ pub async fn has_pending_changes(client: &EncryptedClientHandle, bucket: String) guard.has_pending_forest_changes(&bucket).await } +/// Invalidate the cached forest for `bucket` (issue #36). +/// +/// The encrypted client caches each bucket's forest for the client +/// lifetime, so a long-lived session never observes another device's +/// uploads — `list_from_forest` / `get_flat` keep resolving against the +/// session-stale index. Calling this drops the cached forest so the next +/// forest operation reloads it from storage and sees cross-device writes. +/// +/// **Dirty-safe**: a forest with pending (unsaved) local changes is NOT +/// evicted — call [`flush_forest`] first to persist, then invalidate. +/// Use [`has_pending_changes`] to inspect. This mirrors the underlying +/// `EncryptedClient::invalidate_forest_cache` contract. +/// +/// Typical app wiring: call on pull-to-refresh, tab-resume, reconnect, +/// or any cache-revalidation path, then re-run `list_from_forest`. +pub async fn invalidate_forest_cache(client: &EncryptedClientHandle, bucket: String) { + let guard = client.inner.read().await; + guard.invalidate_forest_cache(&bucket); +} + +/// Invalidate every cached bucket forest on this client (issue #36). +/// +/// Bulk variant of [`invalidate_forest_cache`]: all clean forests are +/// dropped; forests with pending (unsaved) changes are kept, matching +/// the per-bucket dirty-safe contract. +pub async fn invalidate_all_forest_caches(client: &EncryptedClientHandle) { + let guard = client.inner.read().await; + guard.invalidate_all_forest_caches(); +} + // ============================================================================ // Flat Namespace Operations // ============================================================================ diff --git a/crates/fula-flutter/src/frb_generated.rs b/crates/fula-flutter/src/frb_generated.rs index b5078a7..01c921d 100644 --- a/crates/fula-flutter/src/frb_generated.rs +++ b/crates/fula-flutter/src/frb_generated.rs @@ -39,7 +39,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueNom, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2120515634; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1846171626; // Section: executor @@ -3135,6 +3135,111 @@ fn wire__crate__api__client__head_object_impl( }, ) } +fn wire__crate__api__forest__invalidate_all_forest_caches_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + client: impl CstDecode< + RustOpaqueNom< + flutter_rust_bridge::for_generated::RustAutoOpaqueInner, + >, + >, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "invalidate_all_forest_caches", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_client = client.cst_decode(); + move |context| async move { + transform_result_dco::<_, _, ()>( + (move || async move { + let mut api_client_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_client, + 0, + false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_client_guard = + Some(api_client.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_client_guard = api_client_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::forest::invalidate_all_forest_caches(&*api_client_guard) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__forest__invalidate_forest_cache_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + client: impl CstDecode< + RustOpaqueNom< + flutter_rust_bridge::for_generated::RustAutoOpaqueInner, + >, + >, + bucket: impl CstDecode, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "invalidate_forest_cache", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let api_client = client.cst_decode(); + let api_bucket = bucket.cst_decode(); + move |context| async move { + transform_result_dco::<_, _, ()>( + (move || async move { + let mut api_client_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_client, + 0, + false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_client_guard = + Some(api_client.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_client_guard = api_client_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::forest::invalidate_forest_cache( + &*api_client_guard, + api_bucket, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__encrypted__is_flat_namespace_impl( port_: flutter_rust_bridge::for_generated::MessagePort, client: impl CstDecode< @@ -9776,6 +9881,23 @@ mod io { wire__crate__api__client__head_object_impl(port_, client, bucket, key) } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_fula_client_wire__crate__api__forest__invalidate_all_forest_caches( + port_: i64, + client: usize, + ) { + wire__crate__api__forest__invalidate_all_forest_caches_impl(port_, client) + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_fula_client_wire__crate__api__forest__invalidate_forest_cache( + port_: i64, + client: usize, + bucket: *mut wire_cst_list_prim_u_8_strict, + ) { + wire__crate__api__forest__invalidate_forest_cache_impl(port_, client, bucket) + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_fula_client_wire__crate__api__encrypted__is_flat_namespace( port_: i64, @@ -12743,6 +12865,23 @@ mod web { wire__crate__api__client__head_object_impl(port_, client, bucket, key) } + #[wasm_bindgen] + pub fn wire__crate__api__forest__invalidate_all_forest_caches( + port_: flutter_rust_bridge::for_generated::MessagePort, + client: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + ) { + wire__crate__api__forest__invalidate_all_forest_caches_impl(port_, client) + } + + #[wasm_bindgen] + pub fn wire__crate__api__forest__invalidate_forest_cache( + port_: flutter_rust_bridge::for_generated::MessagePort, + client: flutter_rust_bridge::for_generated::wasm_bindgen::JsValue, + bucket: String, + ) { + wire__crate__api__forest__invalidate_forest_cache_impl(port_, client, bucket) + } + #[wasm_bindgen] pub fn wire__crate__api__encrypted__is_flat_namespace( port_: flutter_rust_bridge::for_generated::MessagePort, diff --git a/crates/fula-flutter/tests/issue_36_invalidate_bridge_test.rs b/crates/fula-flutter/tests/issue_36_invalidate_bridge_test.rs new file mode 100644 index 0000000..5064876 --- /dev/null +++ b/crates/fula-flutter/tests/issue_36_invalidate_bridge_test.rs @@ -0,0 +1,54 @@ +//! Issue #36 — bridge-level tests for the newly exposed forest-cache +//! invalidation functions. +//! +//! The behavioural acceptance criteria (cross-device staleness escape + +//! dirty-safe contract) are pinned at the `EncryptedClient` layer in +//! `fula-client/tests/issue_36_invalidate_forest_cache.rs` — both the +//! Flutter and JS bindings are thin delegates to those methods. What +//! THIS file pins is the binding wiring itself: the functions exist on +//! the `crate::api` surface flutter_rust_bridge scans (so a codegen run +//! exposes them to Dart), take the documented signatures, and are safe +//! no-ops on a client with no loaded forest (no network, no panic) — +//! apps may call them from any refresh path unconditionally. + +#![cfg(not(target_arch = "wasm32"))] + +use fula_flutter::api::client::create_encrypted_client; +use fula_flutter::api::forest::{ + has_pending_changes, invalidate_all_forest_caches, invalidate_forest_cache, +}; +use fula_flutter::api::types::{EncryptedClientHandle, EncryptionConfig, FulaConfig}; +use futures::executor::block_on; + +/// Construct a handle without touching the network: client construction +/// is local; the endpoint below is never contacted because the test only +/// exercises cache-management calls. +fn test_handle() -> EncryptedClientHandle { + let config = FulaConfig { + endpoint: "http://127.0.0.1:1".to_string(), + ..Default::default() + }; + let encryption = EncryptionConfig { + secret_key: Some(vec![0x36u8; 32]), + ..Default::default() + }; + block_on(create_encrypted_client(config, encryption)).expect("create encrypted client") +} + +/// The new bridge functions must be callable, idempotent, and safe on a +/// client that has never loaded the bucket's forest — the "call it from +/// every SWR revalidation path" usage pattern from the issue. +#[test] +fn invalidate_bridge_functions_are_callable_and_idempotent() { + let handle = test_handle(); + + block_on(invalidate_forest_cache(&handle, "issue36-bucket".to_string())); + block_on(invalidate_forest_cache(&handle, "issue36-bucket".to_string())); + block_on(invalidate_all_forest_caches(&handle)); + block_on(invalidate_all_forest_caches(&handle)); + + assert!( + !block_on(has_pending_changes(&handle, "issue36-bucket".to_string())), + "no pending changes can exist on a never-written bucket" + ); +} diff --git a/crates/fula-js/Cargo.toml b/crates/fula-js/Cargo.toml index bcb8b0b..72e54d0 100644 --- a/crates/fula-js/Cargo.toml +++ b/crates/fula-js/Cargo.toml @@ -4,7 +4,7 @@ description = "JavaScript/TypeScript SDK for Fula decentralized storage - WASM b # Hard-coded (not workspace-inherited) because wasm-pack <= 0.13 fails # to parse `*.workspace = true` keys in its own manifest scan. Keep # these in sync with `[workspace.package]` in the root Cargo.toml. -version = "0.6.8" +version = "0.6.9" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/crates/fula-js/src/lib.rs b/crates/fula-js/src/lib.rs index 232b6ca..74a154e 100644 --- a/crates/fula-js/src/lib.rs +++ b/crates/fula-js/src/lib.rs @@ -957,6 +957,43 @@ pub async fn list_directory( .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) } +// ============================================================================ +// Forest Cache Management (issue #36) +// ============================================================================ + +/// Invalidate the cached forest index for a bucket. +/// +/// The encrypted client caches each bucket's forest for the client +/// lifetime, so a long-lived session (e.g. a browser tab left open) never +/// observes another device's uploads — listings and downloads keep +/// resolving against the session-stale index. Call this on your app's +/// refresh / revalidation paths, then re-list: the next forest operation +/// reloads from storage and sees cross-device writes. +/// +/// **Dirty-safe**: a forest with pending (unsaved) local changes is NOT +/// evicted — flush first, then invalidate. Mirrors the fula-flutter +/// binding of the same name for cross-platform parity. +/// +/// @param client - EncryptedClient handle +/// @param bucket - Bucket name +#[wasm_bindgen(js_name = invalidateForestCache)] +pub async fn invalidate_forest_cache(client: &EncryptedClient, bucket: &str) { + let guard = client.inner.lock().await; + guard.invalidate_forest_cache(bucket); +} + +/// Invalidate every cached bucket forest on this client. +/// +/// Bulk variant of `invalidateForestCache`: all clean forests are +/// dropped; forests with pending (unsaved) changes are kept. +/// +/// @param client - EncryptedClient handle +#[wasm_bindgen(js_name = invalidateAllForestCaches)] +pub async fn invalidate_all_forest_caches(client: &EncryptedClient) { + let guard = client.inner.lock().await; + guard.invalidate_all_forest_caches(); +} + // ============================================================================ // Bucket Operations // ============================================================================ diff --git a/packages/fula_client/CHANGELOG.md b/packages/fula_client/CHANGELOG.md index f71b249..40dad21 100644 --- a/packages/fula_client/CHANGELOG.md +++ b/packages/fula_client/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.9] - 2026-06-12 + +### Added + +- **`invalidateForestCache` / `invalidateAllForestCaches` bindings + ([#36](https://github.com/functionland/fula-api/issues/36)).** The + encrypted client caches each bucket's forest for the client lifetime, + so a long-lived session never observed another device's uploads — + listings and downloads kept resolving against the session-stale index. + The escape hatch (`EncryptedClient::invalidate_forest_cache`) existed + in the Rust core but was unreachable from Dart and JS. Both bindings + now expose it: call on refresh / tab-resume / reconnect / cache- + revalidation paths, then re-list — the next forest operation reloads + from storage and sees cross-device writes, with no client rebuild. + Dirty-safe per the existing core contract: a forest with pending + (unsaved) local changes is NOT evicted; flush first, then invalidate. + Exposed identically in `fula-flutter` (Dart: `invalidateForestCache`, + `invalidateAllForestCaches`) and `fula-js` (same names) for + cross-platform parity; CI now guards the npm `.d.ts` exports. + ## [0.6.8] - 2026-06-12 ### Fixed diff --git a/packages/fula_client/ios/fula_client.podspec b/packages/fula_client/ios/fula_client.podspec index 7f81d46..b81b20b 100644 --- a/packages/fula_client/ios/fula_client.podspec +++ b/packages/fula_client/ios/fula_client.podspec @@ -6,7 +6,7 @@ Pod::Spec.new do |s| s.name = 'fula_client' - s.version = '0.6.8' + s.version = '0.6.9' s.summary = 'Flutter SDK for Fula decentralized storage' s.description = <<-DESC A Flutter plugin providing client-side encryption, metadata privacy, diff --git a/packages/fula_client/lib/src/api/forest.dart b/packages/fula_client/lib/src/api/forest.dart index d10d58a..1ddd066 100644 --- a/packages/fula_client/lib/src/api/forest.dart +++ b/packages/fula_client/lib/src/api/forest.dart @@ -57,6 +57,40 @@ Future hasPendingChanges({ bucket: bucket, ); +/// Invalidate the cached forest for `bucket` (issue #36). +/// +/// The encrypted client caches each bucket's forest for the client +/// lifetime, so a long-lived session never observes another device's +/// uploads — `list_from_forest` / `get_flat` keep resolving against the +/// session-stale index. Calling this drops the cached forest so the next +/// forest operation reloads it from storage and sees cross-device writes. +/// +/// **Dirty-safe**: a forest with pending (unsaved) local changes is NOT +/// evicted — call [`flush_forest`] first to persist, then invalidate. +/// Use [`has_pending_changes`] to inspect. This mirrors the underlying +/// `EncryptedClient::invalidate_forest_cache` contract. +/// +/// Typical app wiring: call on pull-to-refresh, tab-resume, reconnect, +/// or any cache-revalidation path, then re-run `list_from_forest`. +Future invalidateForestCache({ + required EncryptedClientHandle client, + required String bucket, +}) => RustLib.instance.api.crateApiForestInvalidateForestCache( + client: client, + bucket: bucket, +); + +/// Invalidate every cached bucket forest on this client (issue #36). +/// +/// Bulk variant of [`invalidate_forest_cache`]: all clean forests are +/// dropped; forests with pending (unsaved) changes are kept, matching +/// the per-bucket dirty-safe contract. +Future invalidateAllForestCaches({ + required EncryptedClientHandle client, +}) => RustLib.instance.api.crateApiForestInvalidateAllForestCaches( + client: client, +); + /// Upload a file with immediate forest save /// /// This is the recommended method for most use cases. diff --git a/packages/fula_client/lib/src/frb_generated.dart b/packages/fula_client/lib/src/frb_generated.dart index 6dfbab5..5eadb38 100644 --- a/packages/fula_client/lib/src/frb_generated.dart +++ b/packages/fula_client/lib/src/frb_generated.dart @@ -73,7 +73,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 2120515634; + int get rustContentHash => 1846171626; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -423,6 +423,15 @@ abstract class RustLibApi extends BaseApi { required String key, }); + Future crateApiForestInvalidateAllForestCaches({ + required EncryptedClientHandle client, + }); + + Future crateApiForestInvalidateForestCache({ + required EncryptedClientHandle client, + required String bucket, + }); + Future crateApiEncryptedIsFlatNamespace({ required EncryptedClientHandle client, }); @@ -3153,6 +3162,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["client", "bucket", "key"], ); + @override + Future crateApiForestInvalidateAllForestCaches({ + required EncryptedClientHandle client, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEncryptedClientHandle( + client, + ); + return wire.wire__crate__api__forest__invalidate_all_forest_caches( + port_, + arg0, + ); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiForestInvalidateAllForestCachesConstMeta, + argValues: [client], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiForestInvalidateAllForestCachesConstMeta => + const TaskConstMeta( + debugName: "invalidate_all_forest_caches", + argNames: ["client"], + ); + + @override + Future crateApiForestInvalidateForestCache({ + required EncryptedClientHandle client, + required String bucket, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + var arg0 = + cst_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEncryptedClientHandle( + client, + ); + var arg1 = cst_encode_String(bucket); + return wire.wire__crate__api__forest__invalidate_forest_cache( + port_, + arg0, + arg1, + ); + }, + codec: DcoCodec( + decodeSuccessData: dco_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiForestInvalidateForestCacheConstMeta, + argValues: [client, bucket], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiForestInvalidateForestCacheConstMeta => + const TaskConstMeta( + debugName: "invalidate_forest_cache", + argNames: ["client", "bucket"], + ); + @override Future crateApiEncryptedIsFlatNamespace({ required EncryptedClientHandle client, diff --git a/packages/fula_client/lib/src/frb_generated.io.dart b/packages/fula_client/lib/src/frb_generated.io.dart index 7c511b7..f2479d3 100644 --- a/packages/fula_client/lib/src/frb_generated.io.dart +++ b/packages/fula_client/lib/src/frb_generated.io.dart @@ -3997,6 +3997,52 @@ class RustLibWire implements BaseWire { ) >(); + void wire__crate__api__forest__invalidate_all_forest_caches( + int port_, + int client, + ) { + return _wire__crate__api__forest__invalidate_all_forest_caches( + port_, + client, + ); + } + + late final _wire__crate__api__forest__invalidate_all_forest_cachesPtr = + _lookup>( + 'frbgen_fula_client_wire__crate__api__forest__invalidate_all_forest_caches', + ); + late final _wire__crate__api__forest__invalidate_all_forest_caches = + _wire__crate__api__forest__invalidate_all_forest_cachesPtr + .asFunction(); + + void wire__crate__api__forest__invalidate_forest_cache( + int port_, + int client, + ffi.Pointer bucket, + ) { + return _wire__crate__api__forest__invalidate_forest_cache( + port_, + client, + bucket, + ); + } + + late final _wire__crate__api__forest__invalidate_forest_cachePtr = + _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Int64, + ffi.UintPtr, + ffi.Pointer, + ) + > + >('frbgen_fula_client_wire__crate__api__forest__invalidate_forest_cache'); + late final _wire__crate__api__forest__invalidate_forest_cache = + _wire__crate__api__forest__invalidate_forest_cachePtr + .asFunction< + void Function(int, int, ffi.Pointer) + >(); + void wire__crate__api__encrypted__is_flat_namespace(int port_, int client) { return _wire__crate__api__encrypted__is_flat_namespace(port_, client); } diff --git a/packages/fula_client/lib/src/frb_generated.web.dart b/packages/fula_client/lib/src/frb_generated.web.dart index 211e104..7cf4081 100644 --- a/packages/fula_client/lib/src/frb_generated.web.dart +++ b/packages/fula_client/lib/src/frb_generated.web.dart @@ -2486,6 +2486,24 @@ class RustLibWire implements BaseWire { key, ); + void wire__crate__api__forest__invalidate_all_forest_caches( + NativePortType port_, + int client, + ) => wasmModule.wire__crate__api__forest__invalidate_all_forest_caches( + port_, + client, + ); + + void wire__crate__api__forest__invalidate_forest_cache( + NativePortType port_, + int client, + String bucket, + ) => wasmModule.wire__crate__api__forest__invalidate_forest_cache( + port_, + client, + bucket, + ); + void wire__crate__api__encrypted__is_flat_namespace( NativePortType port_, int client, @@ -3461,6 +3479,17 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject { String key, ); + external void wire__crate__api__forest__invalidate_all_forest_caches( + NativePortType port_, + int client, + ); + + external void wire__crate__api__forest__invalidate_forest_cache( + NativePortType port_, + int client, + String bucket, + ); + external void wire__crate__api__encrypted__is_flat_namespace( NativePortType port_, int client, diff --git a/packages/fula_client/pubspec.yaml b/packages/fula_client/pubspec.yaml index 048e684..edca1da 100644 --- a/packages/fula_client/pubspec.yaml +++ b/packages/fula_client/pubspec.yaml @@ -1,6 +1,6 @@ name: fula_client description: Flutter SDK for Fula decentralized storage with client-side encryption, metadata privacy, and secure sharing. -version: 0.6.8 +version: 0.6.9 homepage: https://fx.land repository: https://github.com/functionland/fula-api issue_tracker: https://github.com/functionland/fula-api/issues