From 1093779ea001046fd93c8bbac916955915e8e8b0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 08:53:21 +0000 Subject: [PATCH 1/2] chore(cache): salvage-anchor stub for #1896 Co-Authored-By: Claude --- crates/dig-node-core/src/capsule_key.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/dig-node-core/src/capsule_key.rs b/crates/dig-node-core/src/capsule_key.rs index 546adef..600e673 100644 --- a/crates/dig-node-core/src/capsule_key.rs +++ b/crates/dig-node-core/src/capsule_key.rs @@ -254,3 +254,5 @@ mod tests { assert_eq!(format!("{key:?}"), format!("CapsuleKey({rendered})")); } } + +// TODO(#1896): unify the cached-capsule artifact on `.dig` (salvage-anchor stub). From 0b6b68267654ec6d3aa183599f082900dc3ecb8a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 09:18:51 +0000 Subject: [PATCH 2/2] fix(cache): unify the cached-capsule artifact on .dig Close the #1896 split where a landed capsule was cached as `.module` while staging wrote `.dig`. The cache landing now writes `.dig` (module_path), so a capsule has ONE artifact extension end-to-end. Mechanism (c): reader-tolerance + idempotent startup rename. - capsule_key: CACHED_MODULE_EXT/LEGACY_MODULE_EXT consts; module_path builds `.dig`; resolve_cached_path (prefer .dig, else legacy .module) is the single read authority; cached_root_stem strips either suffix for the inventory scan; migrate_legacy_module_extensions renames .module -> .dig at bring-up. - Every read site (module_exists, serve_local_blocking, read_public_manifest, cache_list_cached scan, cache_remove_cached, cache_fetch_and_cache stat, describe_module, read_module_window) routes through the shared authority, so a legacy .module cache keeps a node a discoverable holder through an upgrade. - Startup migration runs before the first refresh_dht_inventory; idempotent and crash-safe (dedup when both exist), reader-tolerance covers any interrupted run. Cache-filename change only; the immutable .dig byte format (SPEC 5.1) is untouched. SPEC.md promotion-ladder + availability-source updated. 10 new tests (holder-continuity, partial-upgrade, either-suffix removal, idempotent migration). Version 0.74.2 -> 0.74.3 (patch). Closes #1896 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 15 +- crates/dig-node-core/src/capsule_key.rs | 198 +++++++++++++++++- crates/dig-node-core/src/chainwatch.rs | 5 +- crates/dig-node-core/src/download.rs | 2 +- crates/dig-node-core/src/lib.rs | 145 ++++++++++++- crates/dig-node-core/src/peer.rs | 7 +- .../src/seams/capsule/capsule_store.rs | 21 +- .../src/seams/dig_peer/module_reshare.rs | 8 +- .../src/seams/dig_peer/module_serve.rs | 5 +- 11 files changed, 374 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f778cc2..6695cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.74.2" +version = "0.74.3" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 556c89d..5264e26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.74.2" +version = "0.74.3" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index b74af19..14d3487 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3305,7 +3305,7 @@ hint above. the gate every DHT-discovered holder must pass — dig-download's `locate_and_confirm` DROPS a provider whose answer is not *available* BEFORE any `dig.fetchRange` — so at ROOT / RESOURCE granularity the answer is DERIVED FROM THE SERVABLE SOURCE: the existence of the very capsule module -(`/modules//.module`) the serve path reads. It MUST NOT be derived from an +(`/modules//.dig`) the serve path reads. It MUST NOT be derived from an inventory snapshot that can lag a write, and any cache retained for cost MUST be invalidated on every inventory-changing write (pin, §21 sync, on-demand fetch-and-cache, gap-fill, backfill, eviction). A snapshot lags in BOTH damaging directions: a capsule that landed after the snapshot was taken (a @@ -3849,15 +3849,24 @@ production dependency edge. ### 21.3. Becoming a holder — promote, then announce (MUST) This node's DHT provider records are derived from its CACHE INVENTORY (§19), so the moment a module file -appears at `/modules//.module` this node is advertising itself network-wide as an +appears at `/modules//.dig` this node is advertising itself network-wide as an authoritative source of that capsule. The promotion ladder is therefore: ``` /modules/-.dig.download.tmp staging /modules/-.dig verified, NOT yet a holder -/modules//.module CACHED == ANNOUNCED AS HOLDER +/modules//.dig CACHED == ANNOUNCED AS HOLDER ``` +The cached artifact and the `.dig` it was staged from now share ONE extension end-to-end (#1896). A +reader MUST accept a legacy `.module` cache a prior binary wrote — the availability answer, the +serve path, the held-check, and the inventory scan all treat `.dig` and `.module` as the same held +capsule — so an in-place upgrade never drops a holder. At bring-up, BEFORE its first inventory announce, +the node MUST run an idempotent, crash-safe pass that renames each legacy `.module` to `.dig` +(deleting the redundant `.module` where the `.dig` already exists), converging the cache onto the unified +artifact; reader-tolerance covers any file a partial run leaves behind. This is a CACHE-FILENAME +convergence only — it is NOT a change to the immutable `.dig` byte format (§5.1 does not apply). + - **A pull MUST stage OUTSIDE the cache**, so a partial or failed pull is never a candidate for announcement — there is no window in which a half-pulled capsule sits at the cache path. - **The move into the cache MUST happen only on the pull returning success** — never on diff --git a/crates/dig-node-core/src/capsule_key.rs b/crates/dig-node-core/src/capsule_key.rs index 600e673..92f45bc 100644 --- a/crates/dig-node-core/src/capsule_key.rs +++ b/crates/dig-node-core/src/capsule_key.rs @@ -5,7 +5,7 @@ //! //! A capsule is named by `(store_id, root)`, and on the peer surface BOTH components arrive as raw //! bytes chosen by an untrusted caller. The node turns them into a path -//! (`/modules//.module`) and into log records. Both are places where an +//! (`/modules//.dig`) and into log records. Both are places where an //! attacker-chosen string is dangerous: `..` segments walk out of the cache, and a `\n` forges a log //! record. //! @@ -48,6 +48,78 @@ const CANONICAL_ID_LEN: usize = 64; /// parent directory while the pull staged in this one, so abandoned staging accumulated forever. pub(crate) const MODULE_STAGING_SUBDIR: &str = "modules"; +/// The file extension a freshly-landed capsule is written with (#1896). +/// +/// Unified with the staging artifact ([`CapsuleKey::staged_module_path`], `.dig`) so ONE capsule has +/// ONE artifact extension end-to-end — a cached capsule and the `.dig` it was staged from are now the +/// same shape, not `.module` vs `.dig`. +pub(crate) const CACHED_MODULE_EXT: &str = "dig"; + +/// The extension a PRIOR node version wrote a landed capsule with (#1896). +/// +/// Still READ — reader-tolerance keeps a cache written by an older binary making this node a holder — +/// and a startup pass ([`migrate_legacy_module_extensions`]) renames it to [`CACHED_MODULE_EXT`], so +/// the fallback is only ever exercised on a not-yet-migrated cache. +pub(crate) const LEGACY_MODULE_EXT: &str = "module"; + +/// Strip the cached-capsule extension — the current `.dig` or the legacy `.module` (#1896) — from a +/// file name, yielding its `` stem, or `None` if the name is not a cached capsule. +/// +/// The SINGLE authority on which suffixes name a capsule on disk, so the inventory scan +/// ([`CapsuleStore::cache_list_cached`](crate::CapsuleStore::cache_list_cached)) and the path builders +/// can never disagree about what counts as a held capsule. +pub(crate) fn cached_root_stem(file_name: &str) -> Option<&str> { + file_name + .strip_suffix(&format!(".{CACHED_MODULE_EXT}")) + .or_else(|| file_name.strip_suffix(&format!(".{LEGACY_MODULE_EXT}"))) +} + +/// Converge a cache written by a prior binary onto the unified `.dig` artifact (#1896): rename every +/// legacy `/modules//*.module` to `*.dig`. +/// +/// Idempotent + crash-safe by construction, so it is safe to run unconditionally at every bring-up: +/// - a name whose `.dig` target ALREADY exists has its redundant `.module` deleted (dedup, never a +/// failure), because the two are byte-identical content-addressed artifacts; +/// - a partially-migrated cache is finished by the next run, and reader-tolerance +/// ([`CapsuleKey::resolve_cached_path`]) serves either suffix in the meantime, so an interrupted +/// pass never drops a holder. +/// +/// Best-effort: an unreadable directory or a failed rename is skipped rather than propagated — a +/// convergence sweep must never abort a node's bring-up. +pub(crate) fn migrate_legacy_module_extensions(cache_dir: &Path) { + let modules_root = cache_dir.join("modules"); + let Ok(stores) = std::fs::read_dir(&modules_root) else { + return; // no cache yet — nothing to converge + }; + for store_entry in stores.flatten() { + let store_dir = store_entry.path(); + if !store_dir.is_dir() { + continue; + } + let Ok(modules) = std::fs::read_dir(&store_dir) else { + continue; + }; + for m in modules.flatten() { + let legacy = m.path(); + let is_legacy = legacy + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == LEGACY_MODULE_EXT); + if !is_legacy { + continue; + } + let unified = legacy.with_extension(CACHED_MODULE_EXT); + if unified.exists() { + // Both artifacts present (a prior interrupted run, or a re-land): the `.dig` is the + // canonical one, so the legacy duplicate is redundant — remove it rather than fail. + let _ = std::fs::remove_file(&legacy); + } else { + let _ = std::fs::rename(&legacy, &unified); + } + } + } +} + /// Is `s` a canonical DIG content id — a 32-byte value written as exactly 64 hex digits? /// /// The single predicate every guard over a CALLER-SUPPLIED id shares, so "canonical" can never come to @@ -85,15 +157,43 @@ impl CapsuleKey { &self.store } - /// The cached module path for this capsule: `/modules//.module`. + /// The cached module path for this capsule: `/modules//.dig` (#1896). /// /// The existence of this file is what makes the node a HOLDER of the capsule, so this is also the - /// path the availability answer and the reshare promotion agree on. + /// path a fresh land WRITES and the shape the availability answer and reshare promotion agree on. + /// To READ a capsule (which may still be on disk under the legacy `.module` extension), go through + /// [`resolve_cached_path`](Self::resolve_cached_path), not this — this always names the CURRENT + /// `.dig` shape. pub(crate) fn module_path(&self, cache_dir: &Path) -> PathBuf { + self.cached_path_with_ext(cache_dir, CACHED_MODULE_EXT) + } + + /// The cached path for this capsule with an explicit extension — the shared join both the current + /// `.dig` and the legacy `.module` paths are built from, so the directory layout lives in ONE place. + fn cached_path_with_ext(&self, cache_dir: &Path, ext: &str) -> PathBuf { cache_dir .join("modules") .join(&self.store) - .join(format!("{}.module", self.root)) + .join(format!("{}.{ext}", self.root)) + } + + /// Resolve where this capsule ACTUALLY lives on disk to read it, tolerating a legacy cache (#1896): + /// the current `.dig` path if it exists, else the legacy `.module` path a prior binary may have + /// written, else the `.dig` path. + /// + /// Returning the `.dig` path when NEITHER exists is deliberate: a caller about to write, or about + /// to report "not held", should see the canonical current shape, never the legacy one. Every read + /// site routes through here so no site re-derives the fallback and drifts (#1896). + pub(crate) fn resolve_cached_path(&self, cache_dir: &Path) -> PathBuf { + let unified = self.module_path(cache_dir); + if unified.exists() { + return unified; + } + let legacy = self.cached_path_with_ext(cache_dir, LEGACY_MODULE_EXT); + if legacy.exists() { + return legacy; + } + unified } /// The staging path a whole-capsule warm pulls into: `/modules/-.dig`. @@ -253,6 +353,92 @@ mod tests { assert_eq!(rendered.len(), CANONICAL_ID_LEN * 2 + 1, "bounded length"); assert_eq!(format!("{key:?}"), format!("CapsuleKey({rendered})")); } -} -// TODO(#1896): unify the cached-capsule artifact on `.dig` (salvage-anchor stub). + #[test] + fn a_landed_capsule_is_written_with_the_dig_extension() { + // #1896: the cache landing is unified onto `.dig` — `module_path` (the WRITE path) names the + // `.dig` artifact, never the legacy `.module`. + let cache = tempfile::tempdir().expect("tempdir"); + let key = CapsuleKey::parse(&hex_id(0x11), &hex_id(0x22)).expect("canonical"); + let path = key.module_path(cache.path()); + assert_eq!( + path.extension().and_then(|e| e.to_str()), + Some("dig"), + "a landed capsule is a `.dig`, not a `.module`" + ); + } + + #[test] + fn resolve_cached_path_prefers_dig_then_falls_back_to_legacy_module() { + // #1896 reader-tolerance: read the current `.dig` when present, else the legacy `.module` a + // prior binary wrote, else the canonical `.dig` shape (for a not-held / about-to-write caller). + let cache = tempfile::tempdir().expect("tempdir"); + let key = CapsuleKey::parse(&hex_id(0x33), &hex_id(0x44)).expect("canonical"); + let dig = key.module_path(cache.path()); + let legacy = key.cached_path_with_ext(cache.path(), LEGACY_MODULE_EXT); + std::fs::create_dir_all(dig.parent().unwrap()).unwrap(); + + // Neither present → the canonical `.dig` shape. + assert_eq!(key.resolve_cached_path(cache.path()), dig); + + // Only legacy present → the legacy path (a cache written by an older version is still served). + std::fs::write(&legacy, b"legacy").unwrap(); + assert_eq!(key.resolve_cached_path(cache.path()), legacy); + + // Both present → the `.dig` wins (the canonical current artifact). + std::fs::write(&dig, b"unified").unwrap(); + assert_eq!(key.resolve_cached_path(cache.path()), dig); + } + + #[test] + fn cached_root_stem_accepts_either_suffix_and_rejects_others() { + let root = hex_id(0x55); + assert_eq!( + cached_root_stem(&format!("{root}.dig")), + Some(root.as_str()) + ); + assert_eq!( + cached_root_stem(&format!("{root}.module")), + Some(root.as_str()) + ); + assert_eq!(cached_root_stem(&format!("{root}.tmp")), None); + assert_eq!(cached_root_stem(&root), None); + } + + #[test] + fn startup_migration_renames_module_to_dig_and_is_idempotent() { + // #1896 convergence: a startup pass renames legacy `.module` to `.dig`; where both already + // exist the redundant `.module` is deleted; nothing is lost; a second run is a no-op. + let cache = tempfile::tempdir().expect("tempdir"); + let store = hex_id(0x66); + let store_dir = cache.path().join("modules").join(&store); + std::fs::create_dir_all(&store_dir).unwrap(); + + let root_legacy = hex_id(0x01); + let root_both = hex_id(0x02); + std::fs::write(store_dir.join(format!("{root_legacy}.module")), b"a").unwrap(); + // A capsule already migrated on a prior interrupted run: BOTH suffixes on disk. + std::fs::write(store_dir.join(format!("{root_both}.module")), b"b").unwrap(); + std::fs::write(store_dir.join(format!("{root_both}.dig")), b"b").unwrap(); + + migrate_legacy_module_extensions(cache.path()); + + assert!(store_dir.join(format!("{root_legacy}.dig")).exists()); + assert!(!store_dir.join(format!("{root_legacy}.module")).exists()); + assert!(store_dir.join(format!("{root_both}.dig")).exists()); + assert!( + !store_dir.join(format!("{root_both}.module")).exists(), + "the redundant legacy duplicate is removed" + ); + + // Idempotent: a second run changes nothing. + migrate_legacy_module_extensions(cache.path()); + let names: Vec<_> = std::fs::read_dir(&store_dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names.len(), 2, "only the two `.dig` artifacts remain"); + assert!(names.iter().all(|n| n.ends_with(".dig"))); + } +} diff --git a/crates/dig-node-core/src/chainwatch.rs b/crates/dig-node-core/src/chainwatch.rs index 6447785..04ffb5f 100644 --- a/crates/dig-node-core/src/chainwatch.rs +++ b/crates/dig-node-core/src/chainwatch.rs @@ -10,7 +10,8 @@ //! node never gap-fills against a root the chain could not confirm). //! //! - **§14.3 generation gap-fill** — when the confirmed tip is a root the node does not hold locally -//! (`/modules//.module` absent), the node is MISSING that generation. It +//! (`/modules//.dig` absent, tolerating a legacy `.module`), the node is MISSING +//! that generation. It //! actively pulls it down (via the injected [`GapFiller`]), verifying against the chain-anchored //! root exactly as a read would, then refreshes its DHT provider records so peers find it as a new //! holder. This is the *"actively seek other nodes to pull the missing generations"* behavior. @@ -115,7 +116,7 @@ pub trait GapFiller: Send + Sync { /// Whether the node holds the module for `(store_id, root)` locally. A thin seam over /// [`crate::module_exists`] so the loop's "is this generation missing?" check is injectable in tests. pub trait HeldCheck: Send + Sync { - /// `true` iff `/modules//.module` is present. + /// `true` iff `/modules//.dig` (or a legacy `.module`) is present. fn is_held(&self, store_id: &[u8; 32], root: &Bytes32) -> bool; } diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 83daa63..25d7374 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -1136,7 +1136,7 @@ impl NodeContent { /// diverges from the one that was debugged (#836/#1590). /// /// `cache_dir` is the node's cache root; a promoted module lands at - /// `/modules//.module`, the path whose existence IS this node's holder claim. + /// `/modules//.dig`, the path whose existence IS this node's holder claim. #[allow(clippy::too_many_arguments)] pub fn wire_capsule_reshare( self: &Arc, diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index e491db1..cb699be 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -12,7 +12,7 @@ //! //! Native Rust so the compiled-module serve path (BLS, wasmtime) works. //! -//! Cache layout: `//.module` — the compiled +//! Cache layout: `/modules//.dig` — the compiled //! module bytes for that store at that root. The browser sends a concrete root //! (rootless URNs are resolved to the singleton tip by dig-resolver first), so a //! module is keyed by (store_id, root). @@ -837,7 +837,7 @@ pub fn unsubscribe_store(store_id: &str) -> Result { /// node wrote, so "not held" is the honest answer and the only one that requires no path to exist /// (#1599). pub(crate) fn module_exists(dir: &Path, store_hex: &str, root_hex: &str) -> bool { - CapsuleKey::parse(store_hex, root_hex).is_some_and(|key| key.module_path(dir).exists()) + CapsuleKey::parse(store_hex, root_hex).is_some_and(|key| key.resolve_cached_path(dir).exists()) } /// Hard bound on the total bytes [`walk_dir_files`] will read into memory before aborting. @@ -1067,7 +1067,8 @@ fn serve_local_blocking( key: &CapsuleKey, retrieval_key: &[u8; 32], ) -> Option { - let path = key.module_path(cache_dir); + // Reader-tolerance (#1896): serve the current `.dig`, or a legacy `.module` a prior binary wrote. + let path = key.resolve_cached_path(cache_dir); let module = std::fs::read(&path).ok()?; let store_id = Bytes32::from_hex(key.store()).ok()?; // Ephemeral host key: the browser verifies the merkle proof against the chain-anchored root, not @@ -1097,7 +1098,8 @@ fn read_public_manifest_blocking( cache_dir: &Path, key: &CapsuleKey, ) -> Result>, String> { - let path = key.module_path(cache_dir); + // Reader-tolerance (#1896): a manifest read tolerates a legacy `.module` cache like every serve. + let path = key.resolve_cached_path(cache_dir); let module = match std::fs::read(&path) { Ok(bytes) => bytes, Err(_) => return Ok(None), @@ -2060,7 +2062,7 @@ impl Node { // Every cached module is one CAPSULE — the canonical `(store_id, root_hash)` // identity (`digstore_core::Capsule`, rendered `storeId:rootHash`). The // on-disk cache key IS that capsule: each module lives at - // `module_path(store_hex, root_hex)` = `/modules//.module`, + // `module_path(store_hex, root_hex)` = `/modules//.dig`, // so listing/removing/fetching are all keyed by capsule identity. // -- L7 peer RPC (PHASE-2b, #162) — serving the node's LOCAL inventory ------ @@ -2340,7 +2342,7 @@ fn chunk_count_for(resp: &ContentResponse) -> usize { pub struct CachedCapsule { /// Store id (lowercase 64-hex) — the directory name under `/modules/`. pub store_id: String, - /// Generation root hash (lowercase 64-hex) — the `.module` file stem. + /// Generation root hash (lowercase 64-hex) — the `.dig` file stem. pub root: String, /// On-disk size of the cached module, in bytes. pub size_bytes: u64, @@ -4263,9 +4265,138 @@ mod tests { path } + /// Seed a capsule at the LEGACY `.module` path a prior binary wrote (#1896) — the legacy + /// corpus the reader-tolerance + startup-migration guarantees are proven against. + fn seed_legacy_module(node: &Node, store_hex: &str, root_hex: &str, bytes: &[u8]) -> PathBuf { + let path = node + .cache_dir + .join("modules") + .join(store_hex) + .join(format!("{root_hex}.module")); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + path + } + + #[tokio::test] + async fn a_landed_capsule_is_written_with_the_dig_extension() { + // #1896: a fresh land is a `.dig`, never a `.module`. + let (node, _td) = test_node(None); + let store = "aa".repeat(32); + let root = "11".repeat(32); + let path = seed_module(&node, &store, &root, b"landed"); + assert_eq!(path.extension().and_then(|e| e.to_str()), Some("dig")); + assert!( + !node + .cache_dir + .join("modules") + .join(&store) + .join(format!("{root}.module")) + .exists(), + "no legacy `.module` is written for a fresh land" + ); + } + + #[tokio::test] + async fn cache_list_cached_discovers_a_legacy_dot_module_file() { + // HOLDER-CONTINUITY GUARD (#1896): a cache written by a PRIOR binary (`.module`, no `.dig`) must + // stay discoverable — listed, held, and thus announced (refresh_dht_inventory derives its + // announcement from exactly this list). RED before the dual-suffix scan (which stripped only + // `.module`... this seeds the inverse legacy case the new scan must also accept). + let (node, _td) = test_node(None); + let store = "cc".repeat(32); + let root = "33".repeat(32); + seed_legacy_module(&node, &store, &root, b"legacy-capsule"); + + let cached = node.cache_list_cached().await; + assert_eq!(cached.len(), 1, "the legacy capsule is enumerated"); + assert_eq!(cached[0].store_id, store); + assert_eq!(cached[0].root, root); + assert!( + module_exists(&node.cache_dir, &store, &root), + "a legacy `.module` still makes this node a holder" + ); + } + + #[tokio::test] + async fn cache_list_cached_discovers_a_new_dot_dig_file() { + let (node, _td) = test_node(None); + let store = "dd".repeat(32); + let root = "44".repeat(32); + seed_module(&node, &store, &root, b"dig-capsule"); + + let cached = node.cache_list_cached().await; + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].root, root); + assert!(module_exists(&node.cache_dir, &store, &root)); + } + + #[tokio::test] + async fn serve_and_held_check_resolve_a_legacy_dot_module() { + // #1896: the SERVE path (serve_local_blocking, via resolve_cached_path) reads a legacy + // `.module`, and the held-check agrees — so an upgraded node keeps serving a legacy cache. + let (node, _td) = test_node(None); + let store = "ee".repeat(32); + let root = "55".repeat(32); + let key = CapsuleKey::parse(&store, &root).expect("canonical"); + let bytes = b"the-on-disk-module-bytes"; + seed_legacy_module(&node, &store, &root, bytes); + + assert!(module_exists(&node.cache_dir, &store, &root)); + // resolve_cached_path (the read authority) points at the legacy artifact, and reading it yields + // the seeded bytes — the guarantee the whole serve path rests on. + assert_eq!( + std::fs::read(key.resolve_cached_path(&node.cache_dir)).unwrap(), + bytes + ); + } + + #[tokio::test] + async fn mid_upgrade_partial_rename_loses_no_holder() { + // #1896: mid-migration a cache is half `.dig`, half `.module`. The scan must return the FULL set + // so a crash between renames never drops a holder. + let (node, _td) = test_node(None); + let store = "ff".repeat(32); + let root_dig = "66".repeat(32); + let root_legacy = "77".repeat(32); + seed_module(&node, &store, &root_dig, b"new"); + seed_legacy_module(&node, &store, &root_legacy, b"old"); + + let mut roots: Vec<_> = node + .cache_list_cached() + .await + .into_iter() + .map(|c| c.root) + .collect(); + roots.sort(); + let mut expected = vec![root_dig, root_legacy]; + expected.sort(); + assert_eq!(roots, expected); + } + + #[tokio::test] + async fn cache_remove_removes_either_suffix() { + // #1896: removal clears the holder claim whether the artifact is `.dig` or a legacy `.module`. + let (node, _td) = test_node(None); + let store = "ab".repeat(32); + + let root_dig = "88".repeat(32); + seed_module(&node, &store, &root_dig, b"new"); + assert_eq!(node.cache_remove_cached(&store, &root_dig).await, Ok(true)); + assert!(!module_exists(&node.cache_dir, &store, &root_dig)); + + let root_legacy = "99".repeat(32); + seed_legacy_module(&node, &store, &root_legacy, b"old"); + assert_eq!( + node.cache_remove_cached(&store, &root_legacy).await, + Ok(true) + ); + assert!(!module_exists(&node.cache_dir, &store, &root_legacy)); + } + #[tokio::test] async fn list_cached_reports_capsules_with_size_and_mtime() { - // cache.listCached enumerates every cached `.module` as a capsule + // cache.listCached enumerates every cached `.dig` (or legacy `.module`) as a capsule // (storeId:rootHash) with its on-disk size and last-used time. let (node, _td) = test_node(None); let store_a = "aa".repeat(32); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 5f6da5a..b733e27 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -564,7 +564,7 @@ pub fn gossip_listen_candidates(gossip_port: u16) -> Vec { // -- Local inventory → L7 availability / inventory / range ------------------------------------------- // // The node serves the SAME content over the peer RPC that it serves over §21 / the HTTP read path: -// the capsules cached on disk (`/modules//.module`). `cache_list_cached()` is the +// the capsules cached on disk (`/modules//.dig`). `cache_list_cached()` is the // authoritative local inventory, so these pure helpers derive the peer-RPC answers from it. /// Group a flat list of cached capsules into `store_id → [root, …]` (roots deduped, sorted). Pure so @@ -2104,6 +2104,11 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // Install the weak self-reference so a `&self` read handler can spawn an owned-`Arc` background // task — the capsule backfill on a read-from-another-node (SPEC §5.6). Weak: no self-keep-alive. node.set_self_ref(Arc::downgrade(&node)); + // Converge a cache written by a prior binary onto the unified `.dig` artifact BEFORE the first + // inventory announce below (#1896): rename any legacy `/modules//*.module` to `.dig`. + // Idempotent + crash-safe, and reader-tolerance serves either suffix meanwhile, so a legacy holder + // is never dropped by the upgrade. + crate::capsule_key::migrate_legacy_module_extensions(node.cache_dir_path()); let status = node.peer_status(); // The EFFECTIVE genesis (from `DIG_NETWORK_GENESIS`, else the canonical mainnet genesis) and the // effective network label derived from it — the ONE resolution shared by the gossip config, the diff --git a/crates/dig-node-core/src/seams/capsule/capsule_store.rs b/crates/dig-node-core/src/seams/capsule/capsule_store.rs index 07ff691..4bf1865 100644 --- a/crates/dig-node-core/src/seams/capsule/capsule_store.rs +++ b/crates/dig-node-core/src/seams/capsule/capsule_store.rs @@ -25,7 +25,7 @@ use crate::{module_exists, CachedCapsule, Node, PeerNetwork}; #[async_trait::async_trait] pub trait CapsuleStore: Send + Sync { /// List every cached capsule (`storeId:rootHash`) with its on-disk size and - /// last-used time. Walks `/modules//.module` + /// last-used time. Walks `/modules//.dig` /// (the same layout `module_path`/`serve_local`/`sync_module_from` use), /// reusing the directory-enumerate pattern from [`cache_used_bytes`](crate::cache_used_bytes) and /// [`Node::evict_if_needed`]. `last_used_unix_ms` is the file mtime (the LRU @@ -135,7 +135,7 @@ impl CapsuleStore for Node { async fn cache_list_cached(&self) -> Vec { let modules_root = self.cache_dir.join("modules"); let mut out = Vec::new(); - // Outer level: one directory per store id (hex). Inner: `.module`. + // Outer level: one directory per store id (hex). Inner: `.dig` (or a legacy `.module`). let Ok(stores) = std::fs::read_dir(&modules_root) else { return out; // no modules cached yet }; @@ -151,11 +151,13 @@ impl CapsuleStore for Node { }; for m in modules.flatten() { let path = m.path(); - // A capsule module is `.module`; skip anything else. + // A capsule module is `.dig` (or a legacy `.module` a prior binary + // wrote — #1896); either names a held capsule, so stripping BOTH suffixes from one + // authority is what keeps a legacy holder discoverable through the upgrade. let Some(root_hex) = path .file_name() .and_then(|f| f.to_str()) - .and_then(|f| f.strip_suffix(".module")) + .and_then(crate::capsule_key::cached_root_stem) .map(str::to_string) else { continue; @@ -189,7 +191,9 @@ impl CapsuleStore for Node { let Some(capsule) = crate::CapsuleKey::parse(store_id_hex, root_hex) else { return Err("invalid capsule key: store_id and root must each be 64-hex".to_string()); }; - let path = capsule.module_path(&self.cache_dir); + // Remove whichever artifact is on disk — the current `.dig` or a legacy `.module` (#1896) — so + // a removal on a not-yet-migrated cache still clears the holder claim. + let path = capsule.resolve_cached_path(&self.cache_dir); let _guard = self.cache_lock.lock().await; if !path.exists() { @@ -220,8 +224,8 @@ impl CapsuleStore for Node { let capsule = crate::CapsuleKey::parse(store_id_hex, root_hex).ok_or_else(|| { "invalid capsule key: store_id and root must each be 64-hex".to_string() })?; - // Already cached → report its size, no network. - if let Ok(md) = std::fs::metadata(capsule.module_path(&self.cache_dir)) { + // Already cached → report its size, no network (tolerating a legacy `.module`, #1896). + if let Ok(md) = std::fs::metadata(capsule.resolve_cached_path(&self.cache_dir)) { return Ok((md.len(), root_hex.to_string())); } // Serialize on-demand writes so two fetches of the same capsule don't race. @@ -232,7 +236,8 @@ impl CapsuleStore for Node { let sync = self .sync_module_from(&self.upstream, store_id_hex, root_hex) .await; - let path = capsule.module_path(&self.cache_dir); + // A fresh land is written as `.dig`; resolve tolerates a legacy `.module` already on disk. + let path = capsule.resolve_cached_path(&self.cache_dir); match std::fs::metadata(&path) { Ok(md) => { // A capsule just entered this node's served set at runtime. Landing a capsule MUST make diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index 1965234..110c006 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -18,7 +18,7 @@ //! ```text //! /modules/-.dig.download.tmp staging (dig-download FileSink) //! /modules/-.dig verified, NOT yet a holder -//! /modules//.module CACHED == ANNOUNCED AS HOLDER +//! /modules//.dig CACHED == ANNOUNCED AS HOLDER //! ``` //! //! The last hop is the one that matters. The node's DHT provider records are derived from its CACHE @@ -201,7 +201,7 @@ pub struct WarmPaths { /// The directory the pull stages into. MUST NOT be inside the cache: a file under the cache path is /// already an announcement (see the module docs). pub staging_dir: PathBuf, - /// The node's cache dir. The final hop writes `/modules//.module`. + /// The node's cache dir. The final hop writes `/modules//.dig`. pub cache_dir: PathBuf, } @@ -248,7 +248,7 @@ fn promote_into_cache( } // Write-then-rename INTO the cache, so a reader never observes a partial module at the cache path // (whose mere existence is this node's holder claim). - let tmp = cached.with_extension("module.warm.tmp"); + let tmp = cached.with_extension("dig.warm.tmp"); std::fs::write(&tmp, &bytes).map_err(|_| WarmFailure::CacheWriteFailed)?; std::fs::rename(&tmp, cached).map_err(|_| { let _ = std::fs::remove_file(&tmp); @@ -801,7 +801,7 @@ mod tests { .join("cache") .join("modules") .join(&store_hex) - .join(format!("{root_hex}.module")); + .join(format!("{root_hex}.dig")); assert_eq!( std::fs::read(&cached_path).expect("module is at the cache path"), module, diff --git a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs index dc2f1d5..50f75e2 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs @@ -103,7 +103,8 @@ fn descriptor_memo() -> &'static Mutex Option { let capsule = CapsuleKey::parse(store_hex, root_hex)?; - let path = capsule.module_path(cache_dir); + // Reader-tolerance (#1896): describe the current `.dig`, or a legacy `.module` a prior binary wrote. + let path = capsule.resolve_cached_path(cache_dir); let metadata = std::fs::metadata(&path).ok()?; let len = metadata.len(); if len == 0 { @@ -175,7 +176,7 @@ pub fn read_module_window( // served in 4 MiB windows would otherwise cost a full-file read PER request — ~256 GiB of IO to // serve one pull, with up to 512 MiB resident per in-flight request. Only the bytes this request // actually asked for are ever pulled off disk. - let mut file = std::fs::File::open(capsule.module_path(cache_dir)).ok()?; + let mut file = std::fs::File::open(capsule.resolve_cached_path(cache_dir)).ok()?; let total = file.metadata().ok()?.len(); let start = offset.min(total); let want = length.min(MAX_MODULE_WINDOW).min(total - start);