Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
# 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:
Expand Down Expand Up @@ -69,38 +76,38 @@

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
with:
persist-credentials: false
- name: Install Rust (stable + components)
run: |
rustup toolchain install stable --component rustfmt --component clippy
rustup default stable
rustc --version
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: ${{ runner.os }}-companion-check-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-companion-check-
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked

# ---------------------------------------------------------------------------
# Build the binary per OS/arch.
# ---------------------------------------------------------------------------
build:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: build (${{ matrix.out_name }})
needs: check
strategy:
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,24 @@ verified server-side — `false` only when the node-side pin is disabled via `DI
`X-Dig-Root: <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: <n>` —
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: <storeId:root>` — the capsule id
(the canonical `storeId:rootHash` pairing); `X-Dig-Resource-Key: <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
Expand Down
2 changes: 1 addition & 1 deletion crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
116 changes: 107 additions & 9 deletions crates/dig-node-core/src/content_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<u64>,
},
/// 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.
Expand Down Expand Up @@ -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<String> = None;
let pinned_root: Option<Bytes32> = 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<Option<Bytes32>, 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) => {
Expand All @@ -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);
Expand All @@ -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.
}
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
})
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<u64> {
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<String>,
generation: Option<u64>,
) -> PlaintextOutcome {
if let PlaintextOutcome::Served {
owner_puzzle_hash: o,
generation: g,
..
} = &mut outcome
{
*o = owner_puzzle_hash;
*g = generation;
}
outcome
}

#[cfg(test)]
Expand Down
Loading
Loading