diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10b9ed0..dfee6bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,13 +83,24 @@ jobs: with: tool: cargo-llvm-cov - # Runs the whole workspace test suite AND measures line coverage in a single - # instrumented build. Coverage is measure-only for now (reported in the log + - # summary, NOT gated) — the ≥80% floor is tracked separately (#157). Raising the - # gate here is a one-line change: add `--fail-under-lines 80`. - - name: cargo llvm-cov (test + coverage) + - name: Install cargo-nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest + + # cargo-nextest drives the whole workspace test suite, WITH `--retries 2` so a flaky + # test gets a second chance before failing the job (#489) instead of blocking a + # release on a false negative; nextest surfaces a retried-but-passed test distinctly + # (flaky, not a silent pass) in its run summary. `cargo llvm-cov nextest` runs nextest + # itself through the SAME instrumented build so coverage is still collected — + # running nextest and llvm-cov as two separate steps collects NO coverage at all, so + # this must stay one combined invocation. Coverage is measure-only for now (reported, + # NOT gated) — the ≥80% floor across the whole workspace (CLI/service-shell files in + # particular) is tracked separately; raising the gate here is a one-line addition of + # `--fail-under-lines 80`. + - name: cargo llvm-cov nextest (test + coverage, flaky-retried) run: | - cargo llvm-cov --workspace --locked --summary-only | tee coverage-summary.txt + cargo llvm-cov nextest --workspace --locked --retries 2 --summary-only | tee coverage-summary.txt - name: Coverage summary to job summary if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e87d77..233052a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,13 @@ name: Release dig-node # if/when an arm64-Linux consumer appears (would need vendored/cross OpenSSL). # # A push to main also builds (no publish) so a broken build is caught before tagging. +# +# The `check` gate below runs fmt + clippy ONLY (#489) — it deliberately does NOT re-run the +# test suite. The PR's own required CI (`ci.yml`) already ran fmt/clippy/tests/coverage against +# this exact merged tree before merge (§2.4a); re-running `cargo test` here on the tag/main-push +# path added nothing but a second chance for a flaky test to block a release that had already +# passed its gate once (the #488 v1.96.0 incident). fmt/clippy stay: they are deterministic +# (never flaky) and are a cheap fail-fast before spending 4 platforms' worth of build minutes. on: push: @@ -69,11 +76,11 @@ env: jobs: # --------------------------------------------------------------------------- - # Gate: fmt + clippy + tests on Linux, once. A tag that doesn't pass the gate - # never produces release binaries. + # Gate: fmt + clippy on Linux, once. A tag that doesn't pass the gate never + # produces release binaries. No test re-run here — see the header comment (#489). # --------------------------------------------------------------------------- check: - name: fmt + clippy + test + name: fmt + clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index ba79b7d..0e340c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1912,7 +1912,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.6.0" +version = "0.7.0" dependencies = [ "async-trait", "axum", @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.25.1" +version = "0.26.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 7cf0885..2858d71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[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.25.1" +version = "0.26.0" # 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 3b43e2c..80baf28 100644 --- a/SPEC.md +++ b/SPEC.md @@ -406,6 +406,24 @@ verified server-side — `false` only when the node-side pin is disabled via `DI `X-Dig-Root: ` (the resolved root served against), and `X-Dig-Source: local|peer|rpc` (the tier that served the MAIN resource). A consumer's DIG Shields / toolbar reads these. +**Serve-metadata headers (every serve, #486).** Alongside the provenance set, every served resource +carries: `X-Dig-Store-Id: <64-hex>` (the storeId serving this resource); `X-Dig-Owner-Puzzle-Hash: +<64-hex>` — the store's on-chain OWNER puzzle hash, resolved from the SAME chain read as the +anchored-root pin (§14.4) with no extra coinset call; **THE gate for tippability** — a consumer treats +a response carrying this header as tippable, one carrying no header as not; `X-Dig-Generation: ` — +the 0-based commit ordinal that last wrote the resource, per the store's embedded `PublicManifest` +(§5.5.1), a local-only lookup (never a chain call); `X-Dig-Capsule: ` — the capsule id +(the canonical `storeId:rootHash` pairing); `X-Dig-Resource-Key: ` — the resource/retrieval key of +the served resource (a bare/empty request normalizes to `index.html`, the resolved default view, never +an empty header value). All five describe the MAIN resource served; a value that is unknowable — +`X-Dig-Owner-Puzzle-Hash` when the chain-anchored pin did not run (`DIG_NODE_PIN=off`) or the resolver +could not supply it, `X-Dig-Generation` when the module carries no `PublicManifest` (an older `.dig` or +a private store) or lists no entry for the exact key — is OMITTED, never an empty placeholder. These +headers are attached ONLY on a genuine served resource (never on an error/`404`/non-DIG response), and +are present identically on a `HEAD` request to the same route (axum dispatches `HEAD` to the +registered `GET` handler and strips the body, so the full header set arrives with no body — no +separate HEAD code path). + **Local-first store cache (#290).** Resolution order per `(store, root)`: 1. a synced+verified `.dig` module on disk → serve LOCAL, no network (the DEFAULT once cached); 2. not held → serve the immediate resource from a peer / the public RPC AND trigger a single-flight diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 27d1a5f..0cb9ded 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-node-core" -version = "0.6.0" +version = "0.7.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/content_serve.rs b/crates/dig-node-core/src/content_serve.rs index 45acc38..e535687 100644 --- a/crates/dig-node-core/src/content_serve.rs +++ b/crates/dig-node-core/src/content_serve.rs @@ -74,6 +74,17 @@ pub enum PlaintextOutcome { root_hex: String, verified: bool, source: ServeSource, + /// The store's on-chain OWNER puzzle hash (64-hex) — the future tip recipient, surfaced + /// as `X-Dig-Owner-Puzzle-Hash` (#486). `None` when the chain-anchored pin did not run + /// (`DIG_NODE_PIN=off`) or the resolver could not supply it — the header is OMITTED + /// rather than guessed. Resolved ONCE per request and applied uniformly across whichever + /// tier served the bytes (see [`with_serve_metadata`]). + owner_puzzle_hash: Option, + /// The 0-based commit ordinal that last wrote this resource, per the store's embedded + /// `PublicManifest` (data-section id 13) — surfaced as `X-Dig-Generation` (#486). `None` + /// when the module carries no manifest (an older `.dig`) or lists no entry for this + /// exact key — the header is OMITTED rather than guessed. Local-only (no chain call). + generation: Option, }, /// The resource is genuinely not available at the resolved root (a real content miss). Carries the /// resolved root so the HTTP layer can look up the store's manifest for the SPA-fallback decision. @@ -251,8 +262,21 @@ impl Node { // A concrete, valid requested root; "latest"/malformed ⇒ rootless (resolve the tip). let requested_root = Bytes32::from_hex(requested_root_hex).ok(); let enforced = pin_enforced(); + // The store's current on-chain OWNER puzzle hash (#486), resolved from the SAME chain + // read as the anchored-root pin below — no second coinset call. Stays `None` when the + // pin is disabled (`DIG_NODE_PIN=off`) or the resolver can't supply it. + let mut owner_puzzle_hash: Option = None; let pinned_root: Option = if enforced { - let anchored = self.anchored_root_resolver.anchored_root(&store_id.0).await; + let anchored_state = self + .anchored_root_resolver + .anchored_state(&store_id.0) + .await; + owner_puzzle_hash = match &anchored_state { + Ok(Some(state)) => state.owner_puzzle_hash.map(|ph| ph.to_hex()), + _ => None, + }; + let anchored: Result, String> = + anchored_state.map(|opt| opt.map(|state| state.root)); match decide_pin(true, requested_root, anchored) { PinDecision::ServeAt(root) => Some(root), PinDecision::Reject(code, message) => { @@ -270,6 +294,14 @@ impl Node { .map(|r| r.to_hex()) .unwrap_or_else(|| requested_root_hex.to_string()); let verified = enforced; + // The store generation this resource came from (#486) — a local-only manifest lookup, no + // chain call. `None` on a rootless/pin-off request or when the generation is unknowable. + let generation = if root_hex.is_empty() { + None + } else { + self.resource_generation(store_hex, &root_hex, effective_key) + .await + }; let retrieval_key = derive_retrieval_key(&store_id, effective_key).0; let rk_hex = hex::encode(retrieval_key); @@ -292,7 +324,7 @@ impl Node { &root_hex, verified, ) { - return served; + return with_serve_metadata(served, owner_puzzle_hash, generation); } // else: a decoy / verify or decrypt failure → fall through to peer/RPC. } @@ -317,7 +349,7 @@ impl Node { { // A peer served the resource; warm the whole capsule locally for next time (#290). self.maybe_backfill_capsule(store_hex, &root_hex); - return peer; + return with_serve_metadata(peer, owner_puzzle_hash, generation); } } @@ -348,12 +380,18 @@ impl Node { &proof, None, ); - PlaintextOutcome::Served { - bytes, - root_hex: root_hex.clone(), - verified, - source: ServeSource::Rpc, - } + with_serve_metadata( + PlaintextOutcome::Served { + bytes, + root_hex: root_hex.clone(), + verified, + source: ServeSource::Rpc, + owner_puzzle_hash: None, + generation: None, + }, + owner_puzzle_hash, + generation, + ) } // The gateway returned bytes that do not verify against the anchored root — a // decoy for a missing key (or tampered). Either way the resource is NOT @@ -451,6 +489,10 @@ impl Node { root_hex: root_hex.to_string(), verified, source: ServeSource::Local, + // Stamped by the caller (`serve_content_plaintext`) via `with_serve_metadata` — + // both fields are resolved ONCE per request, not per tier. + owner_puzzle_hash: None, + generation: None, } }) } @@ -501,6 +543,9 @@ impl Node { root_hex: root_hex.to_string(), verified, source: ServeSource::Peer, + // Stamped by the caller via `with_serve_metadata` (see `decrypt_local`). + owner_puzzle_hash: None, + generation: None, }) } // A verify/decrypt failure on the peer bytes is NOT fatal to the serve — fall through to @@ -599,6 +644,59 @@ impl Node { _ => None, } } + + /// The store generation (0-based commit ordinal) that most recently wrote `resource_key`, per + /// the store's embedded `PublicManifest` (id 13) at `(store, root)` — surfaced as + /// `X-Dig-Generation` (#486). `None` when this node does not hold the capsule, the module + /// carries no manifest (an older `.dig` or a private store whose paths stay opaque), or the + /// manifest lists no entry for this exact key (a resource outside the normalized public-path + /// surface). Local-only (mirrors [`manifest_paths`](Self::manifest_paths)) — never a chain call. + pub async fn resource_generation( + &self, + store_hex: &str, + root_hex: &str, + resource_key: &str, + ) -> Option { + let cache_dir = self.cache_dir.clone(); + let (store, root, key) = ( + store_hex.to_string(), + root_hex.to_string(), + resource_key.to_string(), + ); + let outcome = tokio::task::spawn_blocking(move || { + crate::read_public_manifest_blocking(&cache_dir, &store, &root) + }) + .await + .ok()?; + match outcome { + Ok(Some(Some(pm))) => pm + .entries + .into_iter() + .find(|e| e.path == key) + .map(|e| e.generation_index), + _ => None, + } + } +} + +/// Stamp the request-scoped serve-metadata (#486) onto a `Served` outcome — the owner puzzle hash +/// and generation resolved ONCE in [`Node::serve_content_plaintext`], applied uniformly across +/// whichever tier (local/peer/rpc) actually served the bytes. A no-op on any other variant. +fn with_serve_metadata( + mut outcome: PlaintextOutcome, + owner_puzzle_hash: Option, + generation: Option, +) -> PlaintextOutcome { + if let PlaintextOutcome::Served { + owner_puzzle_hash: o, + generation: g, + .. + } = &mut outcome + { + *o = owner_puzzle_hash; + *g = generation; + } + outcome } #[cfg(test)] diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 038043e..ad4ffc5 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -2148,6 +2148,38 @@ pub trait AnchoredRootResolver: Send + Sync { /// Resolve `store_id`'s current on-chain root, or `None` if the store has no /// confirmed generation yet, or `Err` if the chain is unreachable. async fn anchored_root(&self, store_id: &[u8; 32]) -> Result, String>; + + /// The richer form of [`anchored_root`](Self::anchored_root): the SAME resolution, ALSO + /// carrying the store's current on-chain OWNER puzzle hash — the future tip recipient + /// surfaced by the local content-serve path as `X-Dig-Owner-Puzzle-Hash` (#486). Default + /// impl wraps `anchored_root` with `owner_puzzle_hash: None` (used by resolvers — e.g. test + /// mocks — that only know the root). [`CoinsetResolver`] overrides this to capture BOTH + /// fields from the single `sync_datastore` walk it already performs, so content-serve never + /// needs a second coinset round trip to learn the owner. + async fn anchored_state( + &self, + store_id: &[u8; 32], + ) -> Result, String> { + Ok(self + .anchored_root(store_id) + .await? + .map(|root| AnchoredStoreState { + root, + owner_puzzle_hash: None, + })) + } +} + +/// The store's on-chain DataStore singleton state, as resolved by walking its lineage to the +/// unspent tip (`sync_datastore`): its CURRENT content root (the read-path anchor, #127) and its +/// CURRENT owner puzzle hash (the tip recipient, #486). Bundled because both come from the SAME +/// chain read — no second coinset call is needed to serve owner metadata alongside the root. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnchoredStoreState { + pub root: Bytes32, + /// `None` when the resolver cannot supply it (see [`AnchoredRootResolver::anchored_state`]'s + /// default impl) — content-serve OMITS `X-Dig-Owner-Puzzle-Hash` rather than guess. + pub owner_puzzle_hash: Option, } /// Production resolver: walks the store's DataStore singleton lineage on @@ -2160,6 +2192,13 @@ struct CoinsetResolver; #[async_trait::async_trait] impl AnchoredRootResolver for CoinsetResolver { async fn anchored_root(&self, store_id: &[u8; 32]) -> Result, String> { + Ok(self.anchored_state(store_id).await?.map(|s| s.root)) + } + + async fn anchored_state( + &self, + store_id: &[u8; 32], + ) -> Result, String> { let launcher = chia_protocol::Bytes32::new(*store_id); match sync_datastore(&resolution_coinset(), launcher).await { Ok(store) => { @@ -2167,7 +2206,12 @@ impl AnchoredRootResolver for CoinsetResolver { // node's content-root type), mirroring the CLI clone/pull pin. let mut a = [0u8; 32]; a.copy_from_slice(store.info.metadata.root_hash.as_ref()); - Ok(Some(Bytes32(a))) + let mut o = [0u8; 32]; + o.copy_from_slice(store.info.owner_puzzle_hash.as_ref()); + Ok(Some(AnchoredStoreState { + root: Bytes32(a), + owner_puzzle_hash: Some(Bytes32(o)), + })) } Err(e) => { // A "not minted yet" / "launcher unspent" lineage error is a @@ -3144,6 +3188,9 @@ mod tests { /// `Ok(None)` = no confirmed generation; `Err(msg)` = chain unreachable. struct MockResolver { outcomes: std::collections::HashMap, String>>, + /// Optional owner puzzle hash `anchored_state` reports alongside the root (#486 test + /// support). `None` ⇒ the trait's default owner-less wrapping (most tests don't need it). + owner: Option, } impl MockResolver { @@ -3151,7 +3198,25 @@ mod tests { fn one(store_hex: &str, root: Bytes32) -> Arc { let mut outcomes = std::collections::HashMap::new(); outcomes.insert(store_hex.to_string(), Ok(Some(root))); - Arc::new(MockResolver { outcomes }) + Arc::new(MockResolver { + outcomes, + owner: None, + }) + } + /// Like [`one`](Self::one) but ALSO reports `owner` from `anchored_state` (#486): the + /// content-serve `X-Dig-Owner-Puzzle-Hash` tests need a resolver that supplies both the + /// root and the owner, mirroring `CoinsetResolver`'s single-chain-read shape. + fn one_with_owner( + store_hex: &str, + root: Bytes32, + owner: Bytes32, + ) -> Arc { + let mut outcomes = std::collections::HashMap::new(); + outcomes.insert(store_hex.to_string(), Ok(Some(root))); + Arc::new(MockResolver { + outcomes, + owner: Some(owner), + }) } /// A resolver whose every lookup is `outcome` (e.g. chain-unreachable). fn always(outcome: Result, String>) -> Arc { @@ -3161,6 +3226,7 @@ mod tests { m.insert("*".to_string(), outcome); m }, + owner: None, }) } } @@ -3175,6 +3241,19 @@ mod tests { .cloned() .unwrap_or(Ok(None)) } + + async fn anchored_state( + &self, + store_id: &[u8; 32], + ) -> Result, String> { + Ok(self + .anchored_root(store_id) + .await? + .map(|root| AnchoredStoreState { + root, + owner_puzzle_hash: self.owner, + })) + } } /// Build a `Node` with a throwaway cache dir and an optional identity seed. The @@ -5627,6 +5706,8 @@ mod tests { root_hex, verified, source, + owner_puzzle_hash, + generation, } => { assert_eq!(bytes, b"

hi

"); assert_eq!(root_hex, root.to_hex()); @@ -5635,6 +5716,11 @@ mod tests { "the chain-anchored pin is enforced → verified=true" ); assert_eq!(source, ServeSource::Local); + // The injected resolver (`MockResolver::one`) reports no owner (#486) — the header + // must be OMITTED, never guessed. + assert_eq!(owner_puzzle_hash, None); + // The fixture module embeds a PublicManifest (single commit) → generation 0. + assert_eq!(generation, Some(0)); } other => panic!("expected a local Served, got {other:?}"), } @@ -5698,6 +5784,135 @@ mod tests { assert_eq!(latest.resources.len(), 2); } + /// **Proves:** the serve-metadata `X-Dig-Owner-Puzzle-Hash` source (#486) — when the chain-anchored + /// pin is ENFORCED and the resolver reports the store's on-chain owner, `serve_content_plaintext` + /// surfaces it on the `Served` outcome, resolved from the SAME chain read as the root pin (no second + /// coinset call). **Catches:** the owner silently staying `None` when the resolver DOES supply it, + /// or the field being guessed/fabricated rather than sourced from the resolver. + #[test] + fn serve_content_plaintext_reports_the_resolver_owner_puzzle_hash_when_pin_enforced() { + use crate::content_serve::PlaintextOutcome; + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); // enforce the chain-anchored pin (the default) + let rt = pin_test_rt(); + let store = Bytes32([23u8; 32]); + let owner = Bytes32([0xaa; 32]); + let files = vec![("index.html".to_string(), b"

owned

".to_vec())]; + let (root, module) = + compile_fixture_module(store, digstore_core::Visibility::Public, true, &files); + let (node, _td) = test_node_with_resolver( + None, + MockResolver::one_with_owner(&store.to_hex(), root, owner), + ); + seed_cached_module(&node.cache_dir, &store.to_hex(), &root.to_hex(), &module); + + let out = rt.block_on(node.serve_content_plaintext( + &store.to_hex(), + &root.to_hex(), + "index.html", + None, + )); + match out { + PlaintextOutcome::Served { + owner_puzzle_hash, + generation, + .. + } => { + assert_eq!( + owner_puzzle_hash, + Some(owner.to_hex()), + "the resolver's owner puzzle hash must be surfaced verbatim" + ); + assert_eq!(generation, Some(0)); + } + other => panic!("expected a local Served, got {other:?}"), + } + } + + /// **Proves:** `X-Dig-Owner-Puzzle-Hash` is OMITTED (never a placeholder) when the chain-anchored + /// pin did not run (`DIG_NODE_PIN=off`) — the owner is genuinely unknowable without a chain read, so + /// the serve-metadata source (#486) must not guess. The LOCAL-only generation lookup is unaffected + /// (it never calls the chain) and still resolves. + #[test] + fn serve_content_plaintext_omits_owner_puzzle_hash_when_pin_is_off() { + use crate::content_serve::PlaintextOutcome; + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("DIG_NODE_PIN", "off"); + let rt = pin_test_rt(); + let store = Bytes32([24u8; 32]); + let owner = Bytes32([0xbb; 32]); + let files = vec![("index.html".to_string(), b"

unpinned

".to_vec())]; + let (root, module) = + compile_fixture_module(store, digstore_core::Visibility::Public, true, &files); + // Even though the resolver COULD supply an owner, the pin being off means it is never consulted. + let (node, _td) = test_node_with_resolver( + None, + MockResolver::one_with_owner(&store.to_hex(), root, owner), + ); + seed_cached_module(&node.cache_dir, &store.to_hex(), &root.to_hex(), &module); + + let out = rt.block_on(node.serve_content_plaintext( + &store.to_hex(), + &root.to_hex(), + "index.html", + None, + )); + match out { + PlaintextOutcome::Served { + owner_puzzle_hash, + generation, + .. + } => { + assert_eq!( + owner_puzzle_hash, None, + "pin off ⇒ owner is unknowable, never guessed" + ); + assert_eq!( + generation, + Some(0), + "the local manifest lookup is independent of the chain pin" + ); + } + other => panic!("expected a local Served, got {other:?}"), + } + std::env::remove_var("DIG_NODE_PIN"); + } + + /// **Proves:** `X-Dig-Generation` is OMITTED when the served module carries NO `PublicManifest` + /// section (a private store, or an older `.dig` compiled before #176) — the generation is genuinely + /// unknowable from the module alone, never fabricated. + #[test] + fn serve_content_plaintext_omits_generation_when_manifest_absent() { + use crate::content_serve::PlaintextOutcome; + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); + let rt = pin_test_rt(); + let store = Bytes32([25u8; 32]); + let files = vec![("secret.txt".to_string(), b"top secret".to_vec())]; + // include_public_manifest = false: no PublicManifest section embedded (a public store here, + // so the resource still decrypts with no salt — only the manifest presence is under test). + let (root, module) = + compile_fixture_module(store, digstore_core::Visibility::Public, false, &files); + let (node, _td) = test_node_with_resolver(None, MockResolver::one(&store.to_hex(), root)); + seed_cached_module(&node.cache_dir, &store.to_hex(), &root.to_hex(), &module); + + let out = rt.block_on(node.serve_content_plaintext( + &store.to_hex(), + &root.to_hex(), + "secret.txt", + None, + )); + match out { + PlaintextOutcome::Served { generation, .. } => { + assert_eq!( + generation, None, + "no manifest section ⇒ generation is unknowable, never fabricated" + ); + } + other => panic!("expected a local Served, got {other:?}"), + } + } + /// **Proves:** the serve path fails CLOSED when the requested root is not the chain-anchored tip /// (#127) — never decrypting/serving a generation the chain did not confirm. **Catches:** a serve /// that trusts the caller's root over the chain. diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 5fd834c..4126670 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -1054,7 +1054,18 @@ async fn serve_resource(state: &AppState, sp: StorePath) -> Response { root_hex, verified, source, - } => served_response(&sp, &sp.resource, bytes, &root_hex, verified, source), + owner_puzzle_hash, + generation, + } => served_response( + &sp, + &sp.resource, + bytes, + &root_hex, + verified, + source, + owner_puzzle_hash.as_deref(), + generation, + ), PlaintextOutcome::NotFound { root_hex } => serve_miss(state, &sp, &root_hex).await, PlaintextOutcome::InvalidParams { message } => { error_response(StatusCode::BAD_REQUEST, &message) @@ -1093,14 +1104,35 @@ async fn serve_miss(state: &AppState, sp: &StorePath, root_hex: &str) -> Respons root_hex, verified, source, - } => served_response(sp, "index.html", bytes, &root_hex, verified, source), + owner_puzzle_hash, + generation, + } => served_response( + sp, + "index.html", + bytes, + &root_hex, + verified, + source, + owner_puzzle_hash.as_deref(), + generation, + ), _ => not_found(), } } /// Build the `200` response for a served resource: the ecosystem content-type + `nosniff`, the -/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source` provenance headers (#292), and — for HTML — the -/// injected store-root ``/`` plus the hardened store CSP. +/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source` provenance headers (#292), the serve-metadata HEAD +/// (#486: `X-Dig-Store-Id`/`X-Dig-Capsule`/`X-Dig-Resource-Key` always, `X-Dig-Owner-Puzzle-Hash`/ +/// `X-Dig-Generation` when resolvable), and — for HTML — the injected store-root +/// ``/`` plus the hardened store CSP. +/// +/// The serve-metadata headers describe THIS response's MAIN resource; a HEAD request lands on the +/// SAME handler (axum dispatches `HEAD` to the registered `GET` route and strips the body), so the +/// full header set is present with an empty body — no separate HEAD code path is needed. +/// +/// `owner_puzzle_hash`/`generation` are OMITTED (not an empty placeholder) when unknowable — see +/// [`PlaintextOutcome::Served`]'s field docs. +#[allow(clippy::too_many_arguments)] fn served_response( sp: &StorePath, resource: &str, @@ -1108,15 +1140,33 @@ fn served_response( root_hex: &str, verified: bool, source: ServeSource, + owner_puzzle_hash: Option<&str>, + generation: Option, ) -> Response { let content_type = content_type_for(resource); + // The MAIN resource actually served: an empty key (a bare store-root request) resolved to the + // default view `index.html` internally, so the header reports that, never a blank string. + let resource_key = if resource.is_empty() { + "index.html" + } else { + resource + }; let mut builder = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header("X-Content-Type-Options", "nosniff") .header("X-Dig-Verified", if verified { "true" } else { "false" }) .header("X-Dig-Root", root_hex) - .header("X-Dig-Source", source.as_str()); + .header("X-Dig-Source", source.as_str()) + .header("X-Dig-Store-Id", sp.store_id.as_str()) + .header("X-Dig-Capsule", format!("{}:{}", sp.store_id, root_hex)) + .header("X-Dig-Resource-Key", resource_key); + if let Some(owner) = owner_puzzle_hash { + builder = builder.header("X-Dig-Owner-Puzzle-Hash", owner); + } + if let Some(gen) = generation { + builder = builder.header("X-Dig-Generation", gen.to_string()); + } let body = if is_html(content_type) { builder = builder.header(header::CONTENT_SECURITY_POLICY, STORE_CSP); diff --git a/crates/dig-node-service/tests/content_serve.rs b/crates/dig-node-service/tests/content_serve.rs index 9c1b5a6..085bf49 100644 --- a/crates/dig-node-service/tests/content_serve.rs +++ b/crates/dig-node-service/tests/content_serve.rs @@ -174,6 +174,23 @@ async fn serves_index_html_decrypted_with_headers_and_injected_base() { "served HTML carries the store CSP" ); + // Serve-metadata headers (#486). + assert_eq!(h.get("x-dig-store-id").unwrap(), &store.to_hex()); + assert_eq!( + h.get("x-dig-capsule").unwrap(), + &format!("{}:{}", store.to_hex(), root.to_hex()) + ); + assert_eq!(h.get("x-dig-resource-key").unwrap(), "index.html"); + // The fixture module embeds a PublicManifest (single commit) → generation 0, a LOCAL-only lookup + // unaffected by the pin being off. + assert_eq!(h.get("x-dig-generation").unwrap(), "0"); + // The pin is OFF ⇒ no chain read ran ⇒ the owner is genuinely unknowable → OMITTED, never a + // placeholder. + assert!( + h.get("x-dig-owner-puzzle-hash").is_none(), + "owner is unknowable with the pin off — must be omitted, not guessed" + ); + let body = resp.text().await.unwrap(); assert!(body.contains("hello dig"), "the HTML was decrypted: {body}"); assert!( @@ -209,6 +226,15 @@ async fn serves_js_asset_verbatim_without_html_injection() { .to_str() .unwrap() .starts_with("text/javascript")); + // Serve-metadata (#486): the resource key reports the ACTUAL asset served, not a default. + assert_eq!( + resp.headers().get("x-dig-resource-key").unwrap(), + "assets/app.js" + ); + assert_eq!( + resp.headers().get("x-dig-store-id").unwrap(), + &store.to_hex() + ); let body = resp.text().await.unwrap(); assert_eq!(body, "console.log(1)"); assert!(!body.contains("