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
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"
# 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.67.0"
version = "0.68.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
37 changes: 34 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,8 +455,8 @@ Allowed methods: `GET`, `POST`, `OPTIONS`. Allowed request headers: `Content-Typ
node-first path) can READ them — a cross-origin `fetch` can otherwise read only a short safelist, and
a resolver that cannot see `X-Dig-Verified` fails CLOSED and drops to the verified rpc tier. The
exposed set is: `X-Dig-Verified`, `X-Dig-Root`, `X-Dig-Inclusion-Proof`, `X-Dig-Chunk-Lens`,
`X-Dig-Source`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`, `X-Dig-Owner-Puzzle-Hash`,
`X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only
`X-Dig-Source`, `X-Dig-Peer-Tier`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`,
`X-Dig-Owner-Puzzle-Hash`, `X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only
readability. (Cross-repo contract with dig-urn-resolver — mirrored in `SYSTEM.md`.)

**Private Network Access (PNA, #285).** The server MUST advertise `allow_private_network` on the
Expand Down Expand Up @@ -598,6 +598,23 @@ 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.

**Peer-tier attachment (every serve, #1763).** Every served response MUST additionally carry
`X-Dig-Peer-Tier: attached|unattached`, reporting whether the P2P content engine was attached at the
moment the read was ROUTED — i.e. whether Tier 2 was consultable at all. The node MUST capture this
once, before any tier runs, and report the same value regardless of which tier ultimately served the
bytes; it MUST NOT be derived from `X-Dig-Source`.

The two are independent and both are required, because `X-Dig-Source` alone cannot express the
difference between a gateway serve that MISSED on the peer tier and a gateway serve that never had
one. The node serves content from the moment its HTTP surface opens, which is BEFORE the peer network
attaches (§7.8) — availability is deliberately not traded away for readiness — so reads inside that
window skip Tier 2 entirely and are answered by the public RPC. `unattached` is the node stating that;
a caller MUST NOT treat such a read as evidence about peer replication. `unattached` is also the
permanent value on the in-process/FFI path, which brings up no peer network.

`X-Dig-Peer-Tier` reports engine attachment ONLY: not peer count, not reachability, not whether a
fetch was attempted, and never verification (that is `X-Dig-Verified`).

**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
Expand Down Expand Up @@ -882,11 +899,18 @@ are asserted by classification only).
### 6.1. `GET /health`

Returns `{ status: "ok", service, version, commit, mode: "local-node", addr, upstream, cache:
{ dir, cap_bytes, used_bytes, shared }, methods: [names…] }`. The fields `status`, `version`,
{ dir, cap_bytes, used_bytes, shared }, sync: { available }, peer_tier: { attached },
methods: [names…] }`. The fields `status`, `version`,
`mode`, `upstream`, `cache` are the stable probe contract (the v0.2 server's health shape);
additions MUST be additive. `cache.shared` reports whether the effective cache dir is the shared
canonical one (`true`) or a process-private fallback (`false`), from the read path's resolver.

`peer_tier.attached` (#1763) reports whether the P2P content engine is attached RIGHT NOW — the same
state a served response reports as `X-Dig-Peer-Tier` (§4.6). `status: "ok"` means the node is live and
answering; it does NOT imply a usable peer tier, since the HTTP surface opens before the peer network
attaches (§7.8). A client or harness that needs the peer tier MUST poll `peer_tier.attached` until it
is `true` rather than waiting a fixed interval.

### 6.2. `GET /version`

Returns `{ service, version, commit, protocol }` (§2; `version` is the one canonical version).
Expand Down Expand Up @@ -1173,6 +1197,13 @@ token-gated test is read from `<state_dir>/control-token` after startup; a test
`DIG_NODE_STATE_DIR` to an isolated temp dir (§7.3a) so its token/paired-token state is hermetic
regardless of any real machine-wide state dir on the host.

**The peer tier is NOT up when `/health` first answers (#1763).** The HTTP surface opens immediately,
while the peer network attaches seconds later, so a read issued as soon as `/health` responds skips
Tier 2 and is answered by the public RPC. A harness that intends to exercise the P2P path MUST poll
`peer_tier.attached` on `/health` (§6.1) until it is `true` — a fixed sleep is neither sufficient nor
checkable — and MUST confirm `X-Dig-Peer-Tier: attached` on the response it measures (§4.6). A result
gathered from a response carrying `unattached` is a measurement of the gateway, not of peer replication.

### 7.9. Cache-method families (open `cache.*` vs gated `control.cache.*`)

The node exposes cache operations under TWO method families, BY DESIGN — a consumer picks the one
Expand Down
2 changes: 1 addition & 1 deletion crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name = "dig-node-core"
# compatibility-relevant change even though the crate is consumed in-workspace and is not published to
# crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for
# what the exported types are (dig_ecosystem#1785c).
version = "0.24.0"
version = "0.25.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
15 changes: 15 additions & 0 deletions crates/dig-node-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,21 @@ impl crate::Node {
self.p2p_content.get()
}

/// Whether the peer (Tier-2) content path is consultable RIGHT NOW (#1763).
///
/// The peer network attaches ~30 s after the HTTP surface starts answering, so this is
/// [`PeerTier::Unattached`](crate::content_serve::PeerTier::Unattached) for the whole
/// cold-start window — and permanently on the FFI/in-process path, which brings up no peer
/// network. It is reported per read (`X-Dig-Peer-Tier`) and on `GET /health`, which is what
/// lets a caller wait for the peer tier deterministically instead of sleeping a guessed
/// interval and hoping.
pub fn peer_tier(&self) -> crate::content_serve::PeerTier {
match self.p2p_content() {
Some(_) => crate::content_serve::PeerTier::Attached,
None => crate::content_serve::PeerTier::Unattached,
}
}

/// Decide the #165 miss outcome for `content` at redirect depth `depth`: fetch-through when
/// configured (falling back to redirect if the fetch fails), else locate + redirect within the
/// hop budget, else not-found. NEVER a silent 404 while a provider exists.
Expand Down
101 changes: 100 additions & 1 deletion crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6209,6 +6209,7 @@ mod tests {
root_hex,
verified,
source,
peer_tier,
owner_puzzle_hash,
generation,
} => {
Expand All @@ -6219,6 +6220,9 @@ mod tests {
"the chain-anchored pin is enforced → verified=true"
);
assert_eq!(source, ServeSource::Local);
// No peer network is brought up on this node, so the read skipped Tier 2 (#1763) —
// reported honestly even though the bytes came from disk and never needed a peer.
assert_eq!(peer_tier, crate::content_serve::PeerTier::Unattached);
// The injected resolver (`MockResolver::one`) reports no owner (#486) — the header
// must be OMITTED, never guessed.
assert_eq!(owner_puzzle_hash, None);
Expand Down Expand Up @@ -6293,6 +6297,89 @@ mod tests {
assert_eq!(latest.resources.len(), 2);
}

/// **Regression (#1763):** `peer_tier` reports whether the P2P content engine was ATTACHED when
/// a read was routed — a fact about the node, independent of which tier ended up serving. Before
/// this fix nothing carried it, so a read taken inside the ~30 s cold-start window looked exactly
/// like a read taken after attach and any peer-replication conclusion drawn from it was unfounded.
///
/// **The fixture varies ONE actor.** Both arms drive the IDENTICAL locally-seeded, chain-anchored
/// capsule and both are served from disk (`ServeSource::Local`) — the only difference is whether an
/// engine is attached. That is deliberate: the nearest wrong implementation derives the value from
/// the SERVE SOURCE (`peer_tier = if source == Rpc { Unattached } else { Attached }`), which is
/// indistinguishable from the real thing on a gateway-serve fixture, and which this pair kills from
/// both directions — arm 1 is Local-and-Unattached (that impl says Attached) while arm 2 is
/// Local-and-Attached, so it cannot be satisfied by any constant either.
///
/// The end-to-end cold-start case — a real gateway serve inside the window, reported over HTTP —
/// is `dig-node-service`'s `cold_start_gateway_serve_reports_the_peer_tier_as_unattached`.
#[test]
fn serve_reports_peer_tier_attachment_independently_of_the_serving_tier() {
use crate::content_serve::{PeerTier, PlaintextOutcome, ServeSource};
use crate::ContentServer;
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 files = vec![("index.html".to_string(), b"<h1>local</h1>".to_vec())];
let (root, module) =
compile_fixture_module(store, digstore_core::Visibility::Public, true, &files);

for attach in [false, true] {
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);
if attach {
// An engine with NO providers: the peer tier EXISTS but holds nothing, so the read is
// still served from disk. Attachment, not peer availability, is what is under test.
let (content, _unrelated_root, _pt) =
anchored_sealed_content(Bytes32([24u8; 32]), "index.html", b"elsewhere");
attach_p2p(&node, vec![], content, MissMode::FetchThrough, &td);
}

// The node-level accessor `/health` reads (#1763) must flip with attachment too — the
// header path and the health path share this one source of truth, so a `peer_tier()`
// pinned to either arm is caught here rather than only at whichever surface has coverage.
assert_eq!(
node.peer_tier(),
if attach {
PeerTier::Attached
} else {
PeerTier::Unattached
},
"attach={attach}: Node::peer_tier must track engine attachment"
);

let out = rt.block_on(node.serve_content_plaintext(
&store.to_hex(),
&root.to_hex(),
"index.html",
None,
crate::download::ReadOrigin::Local,
));
match out {
PlaintextOutcome::Served {
source, peer_tier, ..
} => {
assert_eq!(
source,
ServeSource::Local,
"attach={attach}: both arms serve from disk — the serving tier is the CONTROL"
);
assert_eq!(
peer_tier,
if attach {
PeerTier::Attached
} else {
PeerTier::Unattached
},
"attach={attach}: peer_tier must track engine attachment, not the serve source"
);
}
other => panic!("attach={attach}: expected a local Served, got {other:?}"),
}
}
}

/// **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
Expand Down Expand Up @@ -6976,9 +7063,21 @@ mod tests {
));
std::env::remove_var("DIG_NODE_PIN");
match out {
PlaintextOutcome::Served { bytes, source, .. } => {
PlaintextOutcome::Served {
bytes,
source,
peer_tier,
..
} => {
assert_eq!(source, ServeSource::Peer, "must serve from the P2P holder");
assert_eq!(bytes, plaintext, "the decrypted P2P bytes match the source");
// The post-attach half of #1763: a read that genuinely reached a peer reports the
// tier as attached, so it is distinguishable from a cold-start read that could not.
assert_eq!(
peer_tier,
crate::content_serve::PeerTier::Attached,
"a peer-served read was routed with the engine up"
);
}
other => panic!("expected a peer Served (no upstream), got {other:?}"),
}
Expand Down
Loading
Loading