From 1f972e0ba579b68d5a072a778cfc68f440e0f002 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 17:29:24 -0700 Subject: [PATCH 1/5] chore: open #2215 lane (PeerSoftware type + peerStatus software field) From 274a9cbd5a0d734c6c9a2f1aabc8ed17a5336603 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 18:17:55 -0700 Subject: [PATCH 2/5] feat(results): add PeerSoftware, the peer-build reading of a gossip handshake PeerSoftware maps a peer's advertised software_version string to a build, once, at the control boundary. It implements neither Ord nor Default: Unknown has no position on a version line, and every peer built before this contract advertises the legacy "0.0.0" sentinel, so an ordering would silently rank most of the live network as ancient. control.peerStatus gains an always-present `software` member on each peer entry. No new control method: control.status.version already reports this node's own build, and the snapshot covers both the point lookup and the census. Refs dig_ecosystem#2215 --- Cargo.lock | 11 ++ Cargo.toml | 3 + SPEC.md | 46 +++++++- src/kats.rs | 82 +++++++++++++ src/lib.rs | 4 + src/method.rs | 2 +- src/results.rs | 312 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea0d4d1..82c74c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ version = "0.3.0" dependencies = [ "async-trait", "futures", + "semver", "serde", "serde_json", ] @@ -147,6 +148,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index 747888a..e62ade0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # `async fn` in the node-facing `ControlHandler` trait (matches the sibling contract crates). async-trait = "0.1" +# The parsed `version` inside `results::PeerSoftware::Reported`. Serializes as its string form, so +# it adds a type to the contract without changing the JSON a peer-status reader sees. +semver = { version = "1", features = ["serde"] } [dev-dependencies] # Drive the async `ControlHandler` KATs to completion without pulling a full runtime dependency diff --git a/SPEC.md b/SPEC.md index 7de192e..a2e313c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -87,7 +87,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `control.pairing.list` | master | owned | — | (pending + issued tokens) | | `control.pairing.approve` | master | owned | `{pairing_id:string}` | `{approved, client_name, token_id}` | | `control.pairing.revoke` | master | owned | `{token_id:string}` | `{revoked, token_id}` | -| `control.peerStatus` | yes | delegated | — | (peer-pool snapshot) | +| `control.peerStatus` | yes | delegated | — | (peer-pool snapshot; each peer entry carries `software`) | | `control.peers.connect` | yes | delegated | `{peer:string}` | `{connected, peer_id}` | | `control.peers.disconnect` | yes | delegated | `{peer:string}` | `{disconnected, peer_id}` | | `control.subscribe` | yes | delegated | `{store_id:string}` | `{subscribed, added, store_id}` | @@ -124,6 +124,50 @@ Proxied results (`control.updater.*`, `control.pairing.list`, `control.peerStatu underlying source's shape verbatim and are modelled as an opaque JSON value; consumers MUST NOT freeze a struct over them. +- **`PeerSoftware`** — a peer's advertised SOFTWARE build, the one member of the otherwise-proxied + `control.peerStatus` snapshot whose shape this contract owns. Every entry of the snapshot's + `connected` array MUST carry a `software` member; a peer entry that omits it is a serialization + defect, NOT a peer of unknown build. Two forms, tagged by `kind`: + + ```json + {"kind": "unknown"} + {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"} + ``` + + `unknown` MUST carry no `version` member — never `"0.0.0"`, never `""`, never `null`. + + The node derives it from the peer's gossip `Handshake.software_version` string. The mapping is + normative: + + | Advertised string | Result | + |---|---| + | `product/semver`, both parts non-empty, version parsing as semver | `reported` | + | `""` (the peer advertised nothing, or coarsened its build off) | `unknown` | + | `"0.0.0"` — the LEGACY SENTINEL | `unknown` | + | `product/0.0.0` | `unknown` | + | anything else unparseable | `unknown` | + + The product/version split is at the LAST `/`, so a product name may itself contain one. + Surrounding whitespace is trimmed before parsing. + + **Why `"0.0.0"` is `unknown` and not a version.** Every dig-node built before this contract + advertises the literal `"0.0.0"`: three of dig-gossip's four handshake send sites hardcoded it. A + reader that treated it as a version would classify the entire live network as running software + 0.0.0, and any `>=` comparison would call all of it ancient. + + **`PeerSoftware` MUST NOT implement `Ord`, `PartialOrd`, or `Default`.** `unknown` has no position + on a version line, and most peers are `unknown` today; a comparison is reachable only after + destructuring `reported`, which forces a caller to decide what `unknown` means for its question. + + **Privacy.** Reporting a peer's exact build is a fingerprinting aid — it identifies which peers run + a version with a publicly disclosed defect. Accepted for the diagnostic value on a pre-release + network. A node that declines to advertise sends an empty string, which reads as `unknown` here and + is indistinguishable from a build predating the field. + +- **`StatusResult.version`** already reports THIS node's own build; there is no separate method for + it, and `control.peerStatus` covers both the point lookup ("what is that peer running") and the + census (a group-by over the returned array). + ## 5. Error taxonomy The numeric codes are a published wire contract and never change once assigned. `origin` classifies diff --git a/src/kats.rs b/src/kats.rs index 8cd27de..c8d37b8 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -583,6 +583,88 @@ fn every_catalog_method_dispatches_without_panicking() { } } +/// **dig_ecosystem#2215** — the `software` member every `control.peerStatus` peer entry carries. +/// +/// `control.peerStatus` is a PROXIED result: SPEC §4.1 forbids freezing a struct over the snapshot, +/// because its shape belongs to the node's peer pool. So this KAT pins the one member this contract +/// DOES own — `software` — in situ, inside a representative `connected` array, rather than pinning +/// the envelope around it. +/// +/// The vector carries one REPORTED peer and one UNKNOWN peer together. A vector with only a +/// reported peer would pass against an implementation that omits `software` whenever it is Unknown, +/// which is precisely the bug the always-present rule exists to prevent. +#[test] +fn peer_status_software_member_golden_vector() { + let snapshot = json!({ + "connected": [ + { + "peer_id": "aa00", + "address": "[2001:db8::1]:9444", + "outbound": true, + "software": { + "kind": "reported", + "product": "dig-node", + "version": "0.99.1", + "raw": "dig-node/0.99.1" + } + }, + { + "peer_id": "bb11", + "address": "[2001:db8::2]:9444", + "outbound": false, + "software": { "kind": "unknown" } + } + ] + }); + + let entries = snapshot["connected"].as_array().expect("connected array"); + + // Always present: EVERY entry carries `software`, including the peer whose build is unknown. + for entry in entries { + assert!( + entry.get("software").is_some(), + "every peerStatus entry must carry `software`; omitting it is a serialization bug, not an Unknown peer" + ); + } + + // Each member decodes to the typed value and re-encodes byte-identically. + for entry in entries { + let wire = entry["software"].clone(); + let parsed: results::PeerSoftware = + serde_json::from_value(wire.clone()).expect("software member must decode"); + assert_eq!( + serde_json::to_value(&parsed).unwrap(), + wire, + "the software member is not byte-stable" + ); + } + + // And the decoded values are the ones the vector names, so a decode that silently collapsed + // both entries to the same value could not pass. + let reported: results::PeerSoftware = + serde_json::from_value(entries[0]["software"].clone()).unwrap(); + assert_eq!(reported, results::PeerSoftware::parse("dig-node/0.99.1")); + let unknown: results::PeerSoftware = + serde_json::from_value(entries[1]["software"].clone()).unwrap(); + assert_eq!(unknown, results::PeerSoftware::Unknown); + assert_ne!(reported, unknown); +} + +/// The legacy sentinel a peer is advertising RIGHT NOW must reach a reader as Unknown, not as a +/// version — the whole live fleet depends on this one mapping (dig_ecosystem#2215). +#[test] +fn a_legacy_peer_entry_reads_as_unknown_not_as_version_zero() { + let software = results::PeerSoftware::parse("0.0.0"); + assert_eq!(software, results::PeerSoftware::Unknown); + let wire = serde_json::to_value(&software).unwrap(); + assert_eq!(wire, json!({"kind": "unknown"})); + assert_eq!( + wire.to_string().find("0.0.0"), + None, + "no rendering of a legacy peer may contain the sentinel as a version" + ); +} + /// The smallest valid params object for a method, so the coverage sweep above never trips /// `INVALID_PARAMS` for a param-taking method. fn minimal_params(m: ControlMethod) -> Value { diff --git a/src/lib.rs b/src/lib.rs index 5c73e15..166657d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,9 @@ //! - [`params`] — a typed request-params struct per method, each bound (via [`ControlCall`]) to its //! method and its typed result. //! - [`results`] — the typed result payloads, field-for-field with what dig-node emits. +//! Includes [`PeerSoftware`], the one interpreted member of the otherwise-proxied +//! `control.peerStatus` snapshot: a peer's advertised software build, defined here so every +//! client reads a gossip handshake's `software_version` string the same way. //! - [`error`] — the stable control-error taxonomy ([`ControlErrorCode`]) + the [`ControlError`] //! envelope a client branches its UX off. //! - [`envelope`] — the minimal JSON-RPC 2.0 request/response the catalog rides in. @@ -69,6 +72,7 @@ mod kats; pub use error::{ControlError, ControlErrorCode, ControlErrorData}; pub use method::{Category, ControlMethod, Routing}; +pub use results::PeerSoftware; pub use traits::{ControlCall, ControlClient, ControlHandler, DefaultControlClient}; /// The crate's semantic version, exposed so consumers can assert compatibility at runtime without diff --git a/src/method.rs b/src/method.rs index a36d8a9..924a02e 100644 --- a/src/method.rs +++ b/src/method.rs @@ -287,7 +287,7 @@ impl ControlMethod { ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).", ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).", ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).", - ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array.", + ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array; each entry carries an always-present `software` field (the peer's advertised build).", ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.", ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).", ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.", diff --git a/src/results.rs b/src/results.rs index 898b076..c46ae56 100644 --- a/src/results.rs +++ b/src/results.rs @@ -28,6 +28,108 @@ pub struct SyncAvailability { pub available: bool, } +/// A peer's advertised SOFTWARE build, as read from the gossip handshake (dig_ecosystem#2215). +/// +/// dig-gossip carries the peer's `Handshake.software_version` as an opaque sanitized string and +/// deliberately does not interpret it. This type is where that string becomes meaning, once, at the +/// control boundary — so the interpretation is defined in one place and every client agrees. +/// +/// # This is NOT the protocol version +/// +/// Wire compatibility is a separate field that dig-gossip gates connections on. Two peers can speak +/// the same protocol while running builds months apart; this type reports the latter. It MUST NOT +/// be used to decide whether to talk to a peer. +/// +/// # Why there is no `Ord` and no `Default` +/// +/// [`Unknown`](PeerSoftware::Unknown) has no position on a version line: it is the absence of a +/// measurement, not a low value. Deriving `Ord` would place it somewhere — and every peer built +/// before #2215 is Unknown, so "somewhere" would silently become a verdict about most of the live +/// network. Comparison is therefore reachable only by destructuring +/// [`Reported`](PeerSoftware::Reported), which forces the caller to say what Unknown means for +/// their question. There is no `Default` for the same reason: a defaulted Unknown that appears from +/// nowhere is a different fact from one that was measured, and the two must not be confusable. +/// +/// # JSON +/// +/// ```json +/// {"kind": "unknown"} +/// {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"} +/// ``` +/// +/// Unknown carries no `version` member at all — never `"0.0.0"`, never `""`, never `null` in a +/// field a consumer might read as a version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PeerSoftware { + /// The peer's build is not known: it advertised nothing, advertised the legacy `"0.0.0"` + /// sentinel, or advertised something this contract cannot parse. See [`PeerSoftware::parse`] + /// for why those three are one case. + Unknown, + /// The peer advertised a well-formed `product/semver` build. + Reported { + /// The product name, e.g. `dig-node`. Everything before the LAST `/`. + product: String, + /// The parsed semantic version, e.g. `0.99.1`. Serializes as its string form. + version: semver::Version, + /// Exactly what the peer advertised, after trimming. + /// + /// **Currently reconstructible, deliberately kept.** The grammar this parser accepts is + /// lossless — `semver::Version` re-renders every string it accepts byte-identically — so + /// today `raw` always equals `format!("{product}/{version}")`, and no test can distinguish + /// this field from that expression. It is retained as the honest source: the moment the + /// grammar accepts anything non-canonical (a `v` prefix, a two-part version, a vendor + /// suffix), a diagnostic reader must see what the peer actually sent rather than this + /// parser's opinion of it, and callers that already read `raw` will not need to change. + raw: String, + }, +} + +/// The legacy sentinel every peer built before dig_ecosystem#2215 advertises. +/// +/// Three of dig-gossip's four handshake send sites hardcoded this literal (the outbound dial and +/// both introducer dials), so it is not a hypothetical value — it is what the live fleet is sending +/// right now. It means "this build predates the field", which is [`PeerSoftware::Unknown`], and +/// mapping it to a *version* would make the whole existing network read as ancient. +const LEGACY_UNVERSIONED_SENTINEL: &str = "0.0.0"; + +/// The separator between the product and the version in a `product/semver` advertisement. +const PRODUCT_VERSION_SEPARATOR: char = '/'; + +impl PeerSoftware { + /// Interpret a peer's advertised `software_version` string. + /// + /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for the legacy + /// `"0.0.0"` sentinel, and for anything that is not `product/semver` with both + /// parts non-empty and the version parsing as semver. A version that is *itself* the legacy + /// sentinel (`dig-node/0.0.0`) is also Unknown: the sentinel means "unversioned" whether or not + /// a product name was attached to it. + /// + /// A product name may contain `/`; the split is at the LAST separator. + pub fn parse(advertised: &str) -> Self { + let raw = advertised.trim(); + + // No separator at all: an empty advertisement, a bare version, a product with no version, + // or the bare legacy `"0.0.0"` sentinel — which contains no `/` and so lands here rather + // than needing a clause of its own. None of them name a build. + let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else { + return Self::Unknown; + }; + if product.is_empty() || version == LEGACY_UNVERSIONED_SENTINEL { + return Self::Unknown; + } + let Ok(version) = version.parse::() else { + return Self::Unknown; + }; + + Self::Reported { + product: product.to_string(), + version, + raw: raw.to_string(), + } + } +} + /// `control.status` — a rich node status snapshot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StatusResult { @@ -376,4 +478,214 @@ mod tests { json!({"status": "approved", "token": "deadbeef"}) ); } + + // ---- PeerSoftware (dig_ecosystem#2215) ---- + + /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL + /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that + /// maps only `""` to Unknown would therefore read the entire live fleet as "software version + /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything + /// unparseable must all be Unknown, and this test is that mapping's guard. + #[test] + fn unknown_covers_empty_the_legacy_sentinel_and_garbage() { + for raw in [ + "", // a peer advertising nothing, or `off` coarsening + "0.0.0", // the pre-#2215 legacy sentinel + " ", // whitespace only + "dig-node", // no version part + "dig-node/", // empty version part + "dig-node/not-a-version", // unparseable version + "/1.2.3", // empty product part + "1.2.3", // bare version, no product + "dig-node/0.0.0", // the sentinel, however it is dressed up + ] { + assert_eq!( + PeerSoftware::parse(raw), + PeerSoftware::Unknown, + "{raw:?} must map to Unknown" + ); + } + } + + /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw` + /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer + /// did not actually advertise. + #[test] + fn reported_carries_product_version_and_the_raw_advertisement() { + let parsed = PeerSoftware::parse("dig-node/0.99.1"); + let PeerSoftware::Reported { + product, + version, + raw, + } = parsed + else { + panic!("a well-formed advertisement must be Reported"); + }; + assert_eq!(product, "dig-node"); + assert_eq!(version, semver::Version::new(0, 99, 1)); + assert_eq!(raw, "dig-node/0.99.1"); + } + + /// A product name may itself contain a `/`; only the LAST separator splits product from + /// version. Pinning this stops a future reader from switching to a first-separator split, + /// which would silently reclassify such a peer as Unknown. + #[test] + fn product_is_split_at_the_last_separator() { + let PeerSoftware::Reported { + product, version, .. + } = PeerSoftware::parse("acme/dig-node/1.2.3") + else { + panic!("expected Reported"); + }; + assert_eq!(product, "acme/dig-node"); + assert_eq!(version, semver::Version::new(1, 2, 3)); + } + + /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED + /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not + /// spaces, so a padded advertisement reaches this parser intact and must not be classified as + /// unparseable merely for having been padded. + #[test] + fn surrounding_whitespace_is_trimmed_before_parsing() { + let PeerSoftware::Reported { + product, + version, + raw, + } = PeerSoftware::parse(" dig-node/1.2.3 ") + else { + panic!("a padded advertisement must still be Reported"); + }; + assert_eq!(product, "dig-node"); + assert_eq!(version, semver::Version::new(1, 2, 3)); + assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value"); + } + + /// A pre-release/build-metadata semver survives intact, because that is what a nightly build + /// advertises and dropping it would make every nightly indistinguishable from its release. + #[test] + fn prerelease_versions_are_preserved() { + let PeerSoftware::Reported { version, raw, .. } = + PeerSoftware::parse("dig-node/1.0.0-nightly.20260805") + else { + panic!("expected Reported"); + }; + assert_eq!(version.to_string(), "1.0.0-nightly.20260805"); + assert_eq!(raw, "dig-node/1.0.0-nightly.20260805"); + } + + /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a + /// version field where a consumer might read it as a number. + #[test] + fn unknown_serializes_as_a_tagged_object_with_no_version_field() { + let v = serde_json::to_value(PeerSoftware::Unknown).unwrap(); + assert_eq!(v, json!({"kind": "unknown"})); + assert!( + v.get("version").is_none(), + "Unknown must not carry a version field at all" + ); + } + + /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's + /// response unchanged. + #[test] + fn both_variants_round_trip_byte_identically() { + for wire in [ + json!({"kind": "unknown"}), + json!({ + "kind": "reported", + "product": "dig-node", + "version": "0.99.1", + "raw": "dig-node/0.99.1" + }), + ] { + let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); + } + } + + /// Parsing a wire string and serializing the result produces the documented JSON, so the two + /// halves of the contract cannot drift from each other. + #[test] + fn parse_then_serialize_matches_the_documented_json() { + assert_eq!( + serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(), + json!({ + "kind": "reported", + "product": "dig-node", + "version": "0.99.1", + "raw": "dig-node/0.99.1" + }) + ); + assert_eq!( + serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(), + json!({"kind": "unknown"}) + ); + } + + // ---- Trait-absence probes (dig_ecosystem#2215) ---- + // + // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather + // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl + // precedence: `Probe::::has_it()` resolves to the inherent impl (returning `true`) only when + // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`). + // + // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a + // probe broken so that it always answers `false` would pass while proving nothing. + + struct Probe(core::marker::PhantomData); + + trait ProbeFallback { + fn is_ord() -> bool { + false + } + } + impl ProbeFallback for Probe {} + + impl Probe { + fn is_ord() -> bool { + true + } + } + + /// A version comparison must be unreachable without first destructuring `Reported`, so that a + /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is + /// Unknown, would quietly become a verdict about most of the live network. + #[test] + fn peer_software_is_not_ordered() { + assert!( + Probe::::is_ord(), + "control: the probe must detect a type that IS Ord, or it proves nothing" + ); + assert!( + !Probe::::is_ord(), + "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported" + ); + } + + /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and + /// `Default` would make the two indistinguishable at the point of construction. + #[test] + fn peer_software_has_no_default() { + struct DefaultProbe(core::marker::PhantomData); + trait DefaultFallback { + fn is_default() -> bool { + false + } + } + impl DefaultFallback for DefaultProbe {} + impl DefaultProbe { + fn is_default() -> bool { + true + } + } + + assert!( + DefaultProbe::::is_default(), + "control: the probe must detect a type that IS Default, or it proves nothing" + ); + assert!( + !DefaultProbe::::is_default(), + "PeerSoftware must not implement Default" + ); + } } From 59adeec7437129bf88c94e960c8105153270fee6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 18:20:18 -0700 Subject: [PATCH 3/5] chore(release): bump to 0.4.0 for the additive PeerSoftware type Refs dig_ecosystem#2215 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82c74c9..631c235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index e62ade0..fb60a0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" From 858e4cc7bf1400fade881c184d453c1b66757bc9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 18:45:27 -0700 Subject: [PATCH 4/5] feat(results): add SoftwareVersionDetail, the coarsening dial for what a node advertises Rendering lives beside PeerSoftware's parsing because they are two halves of one format; a node that hand-rolled its own product/version string would re-implement half the contract and drift from it. Minor renders MAJOR.MINOR.0, not a bare MAJOR.MINOR: two-part versions are not valid semver, so the coarse setting would be read as Unknown and become a second spelling of Off. It also strips pre-release and build metadata, since a nightly identifier is more precisely identifying than the patch number beside it. Refs dig_ecosystem#2215 --- SPEC.md | 19 ++++++ src/lib.rs | 2 +- src/results.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index a2e313c..f78029a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -164,6 +164,25 @@ a struct over them. network. A node that declines to advertise sends an empty string, which reads as `unknown` here and is indistinguishable from a build predating the field. +- **`SoftwareVersionDetail`** — how much of its own build a node reveals when it advertises. Wire + tokens `"full"` (default) | `"minor"` | `"off"`, rendering: + + | Mode | Advertised for version `0.99.1` | Read back as | + |---|---|---| + | `full` | `dig-node/0.99.1` | `reported`, exact | + | `minor` | `dig-node/0.99.0` | `reported`, patch level hidden | + | `off` | `""` | `unknown` | + + `minor` MUST render `MAJOR.MINOR.0`, never a bare `MAJOR.MINOR`: a two-part version is not valid + semver, so the coarse setting would be read as `unknown` and become a confusing second spelling of + `off`. `minor` MUST also strip pre-release and build metadata — a nightly identifier is more + precisely identifying than the patch number beside it, so retaining it would coarsen nothing for + exactly the builds that most want it. A coarsened `1.4.0` is indistinguishable from a genuine + `1.4.0`; that is the purpose of coarsening, not a defect in it. + + Rendering is specified here, beside the parsing, because they are two halves of one format. A node + MUST NOT hand-roll its own `product/version` string. + - **`StatusResult.version`** already reports THIS node's own build; there is no separate method for it, and `control.peerStatus` covers both the point lookup ("what is that peer running") and the census (a group-by over the returned array). diff --git a/src/lib.rs b/src/lib.rs index 166657d..fe32a1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,7 +72,7 @@ mod kats; pub use error::{ControlError, ControlErrorCode, ControlErrorData}; pub use method::{Category, ControlMethod, Routing}; -pub use results::PeerSoftware; +pub use results::{PeerSoftware, SoftwareVersionDetail}; pub use traits::{ControlCall, ControlClient, ControlHandler, DefaultControlClient}; /// The crate's semantic version, exposed so consumers can assert compatibility at runtime without diff --git a/src/results.rs b/src/results.rs index c46ae56..90caee7 100644 --- a/src/results.rs +++ b/src/results.rs @@ -28,6 +28,58 @@ pub struct SyncAvailability { pub available: bool, } +/// How much of its own build a node reveals when it advertises (dig_ecosystem#2215). +/// +/// Advertising an exact build is a fingerprinting aid — it tells an observer precisely which peers +/// run a version with a publicly disclosed defect. This is the operator's dial between that cost +/// and the diagnostic value of knowing what the network is running. +/// +/// It lives here, beside [`PeerSoftware`], because rendering and parsing are two halves of one +/// format: a node that hand-rolled its own `product/version` string would be re-implementing half +/// the contract, and the two halves would drift. A node picks a mode; this type renders it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SoftwareVersionDetail { + /// Advertise the exact build, e.g. `dig-node/0.99.1`. The default: the diagnostic value is why + /// the field exists, and an operator who disagrees opts down explicitly. + #[default] + Full, + /// Advertise only the major and minor level, e.g. `dig-node/0.99.0`. Hides the patch level, and + /// any pre-release or build metadata, while remaining READABLE at the far end. + Minor, + /// Advertise nothing. Indistinguishable from a peer built before this field existed, and reads + /// as [`PeerSoftware::Unknown`]. + Off, +} + +impl SoftwareVersionDetail { + /// Render the advertisement a node with this setting puts on its handshake. + /// + /// The result is always either the empty string or a value + /// [`PeerSoftware::parse`] reads back as `Reported` — coarsening reduces PRECISION, never + /// readability. That is why [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0` + /// rather than a bare `MAJOR.MINOR`: two-part versions are not valid semver, so the coarse + /// setting would collapse to Unknown and become a second, confusing spelling of + /// [`Off`](SoftwareVersionDetail::Off). + /// + /// A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`. That is the point of + /// coarsening, not a defect in it. + pub fn render(self, product: &str, version: &semver::Version) -> String { + match self { + Self::Full => format!("{product}/{version}"), + // A pre-release identifier (`-nightly.20260805`) is more precisely identifying than the + // patch number beside it, so a "coarse" advertisement that kept it would coarsen + // nothing for exactly the builds that most want it. `Version::new` drops both it and + // any build metadata. + Self::Minor => format!( + "{product}/{}", + semver::Version::new(version.major, version.minor, 0) + ), + Self::Off => String::new(), + } + } +} + /// A peer's advertised SOFTWARE build, as read from the gossip handshake (dig_ecosystem#2215). /// /// dig-gossip carries the peer's `Handshake.software_version` as an opaque sanitized string and @@ -688,4 +740,111 @@ mod tests { "PeerSoftware must not implement Default" ); } + + // ---- SoftwareVersionDetail (dig_ecosystem#2215) ---- + + /// Each mode renders a value the PARSER reads back at the intended level of detail. + /// + /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the + /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that + /// ignores the mode entirely pass. + #[test] + fn each_detail_mode_round_trips_to_the_intended_precision() { + let v = semver::Version::new(0, 99, 1); + + let full = SoftwareVersionDetail::Full.render("dig-node", &v); + assert_eq!(full, "dig-node/0.99.1"); + assert_eq!( + PeerSoftware::parse(&full), + PeerSoftware::parse("dig-node/0.99.1") + ); + + let minor = SoftwareVersionDetail::Minor.render("dig-node", &v); + assert_ne!(minor, full, "Minor must actually coarsen"); + let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else { + panic!("a coarsened advertisement must still be READABLE, not Unknown"); + }; + assert_eq!(version.major, 0); + assert_eq!(version.minor, 99); + assert_eq!(version.patch, 0, "the patch level is what Minor hides"); + + let off = SoftwareVersionDetail::Off.render("dig-node", &v); + assert_eq!(off, ""); + assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown); + } + + /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`. + /// + /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that + /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which + /// is what `Off` is for. This test is the guard on that distinction. + #[test] + fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() { + let rendered = + SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7)); + assert_eq!(rendered, "dig-node/1.4.0"); + assert_ne!( + PeerSoftware::parse(&rendered), + PeerSoftware::Unknown, + "a coarsened build must remain readable; `product/1.4` would not be" + ); + } + + /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely + /// identifying than the patch number it accompanies, so leaving it in place would make `Minor` + /// coarsen nothing at all for exactly the builds that most want it. + #[test] + fn minor_mode_strips_prerelease_and_build_metadata() { + let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap(); + let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v); + assert_eq!(rendered, "dig-node/1.0.0"); + assert!( + !rendered.contains("nightly"), + "the nightly identifier must not survive coarsening" + ); + assert!( + !rendered.contains("abc123"), + "build metadata must not survive coarsening" + ); + } + + /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable + /// from a peer built before the field existed. + #[test] + fn off_mode_reveals_nothing_for_any_version() { + for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] { + let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap()); + assert_eq!( + rendered, "", + "Off must reveal nothing, including the product name" + ); + } + } + + /// The default is the most informative setting: the diagnostic value is the reason the field + /// exists, and an operator who disagrees opts down explicitly. + #[test] + fn detail_defaults_to_full() { + assert_eq!( + SoftwareVersionDetail::default(), + SoftwareVersionDetail::Full + ); + } + + /// The wire tokens are the lowercase words an operator writes in a config file, and they are a + /// published contract once a config carries them. + #[test] + fn detail_uses_lowercase_wire_tokens() { + for (mode, token) in [ + (SoftwareVersionDetail::Full, "\"full\""), + (SoftwareVersionDetail::Minor, "\"minor\""), + (SoftwareVersionDetail::Off, "\"off\""), + ] { + assert_eq!(serde_json::to_string(&mode).unwrap(), token); + assert_eq!( + serde_json::from_str::(token).unwrap(), + mode + ); + } + } } From 1a04458665f91a9c27c7dc5bc87328dfc863800d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 19:22:40 -0700 Subject: [PATCH 5/5] fix(results): state the sentinel and the no-ordering rule over their classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gate findings, all one shape — a rule implemented over a literal whose rationale is stated over a class. The version-zero sentinel was matched as the string "0.0.0", so 0.0.0+build, 0.0.0-rc.1 and 0.0.0-0 were reported as real builds at version zero. It is now matched on the parsed major/minor/patch triple, after the parse, ignoring pre-release and build metadata. The trait-absence probe guarded Ord alone, but Ord: PartialOrd — a one-word derive(PartialOrd) satisfied the probe while Unknown < Reported(..) still compiled and evaluated. A PartialOrd probe now subsumes it, with controls on a PartialOrd-but-not-Ord type and on a fully ordered one. render's stated invariant was false: Minor of a 0.0.x build coarsened to version zero, which reads as Unknown. Minor now renders the empty string there, because hiding the patch of a 0.0.x build leaves no coarser representable value and the sentinel must never be advertised. The invariant is now tested over the class it is stated over. Also adds the raw tripwire: raw is asserted equal to the parsed parts across the accepted grammar, so the test goes red exactly when raw becomes load-bearing. Refs dig_ecosystem#2215 --- SPEC.md | 24 ++++- src/results.rs | 243 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 235 insertions(+), 32 deletions(-) diff --git a/SPEC.md b/SPEC.md index f78029a..74eecb8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -143,18 +143,27 @@ a struct over them. |---|---| | `product/semver`, both parts non-empty, version parsing as semver | `reported` | | `""` (the peer advertised nothing, or coarsened its build off) | `unknown` | - | `"0.0.0"` — the LEGACY SENTINEL | `unknown` | - | `product/0.0.0` | `unknown` | + | any advertisement whose version is VERSION ZERO — the LEGACY SENTINEL | `unknown` | | anything else unparseable | `unknown` | The product/version split is at the LAST `/`, so a product name may itself contain one. Surrounding whitespace is trimmed before parsing. - **Why `"0.0.0"` is `unknown` and not a version.** Every dig-node built before this contract + **Version zero is a CLASS, not a string.** The rule MUST be applied to the parsed + major/minor/patch triple, ignoring pre-release and build metadata: the bare `0.0.0`, a + product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`, `0.0.0+build`, + `0.0.0-0`) are all `unknown`. A string comparison would let the decorated forms through as real + builds at version zero. + + **Why version zero is `unknown` and not a version.** Every dig-node built before this contract advertises the literal `"0.0.0"`: three of dig-gossip's four handshake send sites hardcoded it. A reader that treated it as a version would classify the entire live network as running software 0.0.0, and any `>=` comparison would call all of it ancient. + **Version zero MUST NEVER BE ADVERTISED.** It is a value received from a legacy peer, never one a + conforming node sends — see `SoftwareVersionDetail` below for the one place that constraint + binds. + **`PeerSoftware` MUST NOT implement `Ord`, `PartialOrd`, or `Default`.** `unknown` has no position on a version line, and most peers are `unknown` today; a comparison is reachable only after destructuring `reported`, which forces a caller to decide what `unknown` means for its question. @@ -172,10 +181,17 @@ a struct over them. | `full` | `dig-node/0.99.1` | `reported`, exact | | `minor` | `dig-node/0.99.0` | `reported`, patch level hidden | | `off` | `""` | `unknown` | + | `minor` of a `0.0.x` build | `""` | `unknown` | `minor` MUST render `MAJOR.MINOR.0`, never a bare `MAJOR.MINOR`: a two-part version is not valid semver, so the coarse setting would be read as `unknown` and become a confusing second spelling of - `off`. `minor` MUST also strip pre-release and build metadata — a nightly identifier is more + `off`. For the same reason, `minor` of a `0.0.x` build MUST render the EMPTY STRING: its + coarsening is version zero, which is the `unknown` sentinel, and there is no coarser representable + value — so it advertises nothing rather than advertising the sentinel as if it were a report. + + The binding invariant: **every rendering is either the empty string or a value that reads back as + `reported`.** Coarsening reduces precision; it never yields a value that reads as `unknown` while + looking like a report. `minor` MUST also strip pre-release and build metadata — a nightly identifier is more precisely identifying than the patch number beside it, so retaining it would coarsen nothing for exactly the builds that most want it. A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`; that is the purpose of coarsening, not a defect in it. diff --git a/src/results.rs b/src/results.rs index 90caee7..33eba48 100644 --- a/src/results.rs +++ b/src/results.rs @@ -55,12 +55,16 @@ pub enum SoftwareVersionDetail { impl SoftwareVersionDetail { /// Render the advertisement a node with this setting puts on its handshake. /// - /// The result is always either the empty string or a value - /// [`PeerSoftware::parse`] reads back as `Reported` — coarsening reduces PRECISION, never - /// readability. That is why [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0` - /// rather than a bare `MAJOR.MINOR`: two-part versions are not valid semver, so the coarse - /// setting would collapse to Unknown and become a second, confusing spelling of - /// [`Off`](SoftwareVersionDetail::Off). + /// The result is ALWAYS either the empty string or a value [`PeerSoftware::parse`] reads back + /// as `Reported`. Coarsening reduces PRECISION; it never produces a value that reads as + /// Unknown while pretending to be a report. Two consequences follow, and both are tested: + /// + /// - [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0`, never a bare + /// `MAJOR.MINOR` — two-part versions are not valid semver, so that spelling would read as + /// Unknown and become a second, confusing spelling of [`Off`](SoftwareVersionDetail::Off). + /// - `Minor` of a `0.0.x` build renders the EMPTY STRING, because its coarsening is version + /// zero and version zero is the "unknown" sentinel. There is no coarser representable value, + /// so it advertises nothing rather than advertising the sentinel as if it were a report. /// /// A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`. That is the point of /// coarsening, not a defect in it. @@ -71,10 +75,18 @@ impl SoftwareVersionDetail { // patch number beside it, so a "coarse" advertisement that kept it would coarsen // nothing for exactly the builds that most want it. `Version::new` drops both it and // any build metadata. - Self::Minor => format!( - "{product}/{}", - semver::Version::new(version.major, version.minor, 0) - ), + Self::Minor => { + let coarsened = semver::Version::new(version.major, version.minor, 0); + // Hiding the patch of a `0.0.x` build leaves version zero, which the wire reserves + // as the "unknown" sentinel. There is no coarser representable value, so advertise + // nothing rather than advertise the sentinel dressed up as a report. (This differs + // from the rejected two-part `MAJOR.MINOR` spelling: there a representable coarse + // value existed and the wrong one was chosen; here none exists.) + if is_version_zero(&coarsened) { + return String::new(); + } + format!("{product}/{coarsened}") + } Self::Off => String::new(), } } @@ -109,14 +121,14 @@ impl SoftwareVersionDetail { /// {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"} /// ``` /// -/// Unknown carries no `version` member at all — never `"0.0.0"`, never `""`, never `null` in a +/// Unknown carries no `version` member at all — never version zero, never `""`, never `null` in a /// field a consumer might read as a version. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PeerSoftware { - /// The peer's build is not known: it advertised nothing, advertised the legacy `"0.0.0"` - /// sentinel, or advertised something this contract cannot parse. See [`PeerSoftware::parse`] - /// for why those three are one case. + /// The peer's build is not known: it advertised nothing, advertised VERSION ZERO — the legacy + /// sentinel, in any decoration (`0.0.0`, `0.0.0-rc.1`, `0.0.0+build`) — or advertised something + /// this contract cannot parse. See [`PeerSoftware::parse`] for why those three are one case. Unknown, /// The peer advertised a well-formed `product/semver` build. Reported { @@ -137,13 +149,19 @@ pub enum PeerSoftware { }, } -/// The legacy sentinel every peer built before dig_ecosystem#2215 advertises. +/// Is this VERSION ZERO — the legacy "no version" sentinel, whatever it is dressed in? /// -/// Three of dig-gossip's four handshake send sites hardcoded this literal (the outbound dial and -/// both introducer dials), so it is not a hypothetical value — it is what the live fleet is sending -/// right now. It means "this build predates the field", which is [`PeerSoftware::Unknown`], and -/// mapping it to a *version* would make the whole existing network read as ancient. -const LEGACY_UNVERSIONED_SENTINEL: &str = "0.0.0"; +/// Three of dig-gossip's four handshake send sites hardcoded `"0.0.0"` before dig_ecosystem#2215, +/// so version zero is not a hypothetical value: it is what the live fleet is sending right now. It +/// means "this build predates the field", which is [`PeerSoftware::Unknown`]; mapping it to a +/// *version* would make the whole existing network read as ancient. +/// +/// The test is over the major/minor/patch TRIPLE, ignoring any pre-release or build metadata. A +/// peer advertising `0.0.0-rc.1` is no more versioned than one advertising `0.0.0`, and matching +/// the bare string would let the decorated forms through as real builds at version zero. +fn is_version_zero(version: &semver::Version) -> bool { + version.major == 0 && version.minor == 0 && version.patch == 0 +} /// The separator between the product and the version in a `product/semver` advertisement. const PRODUCT_VERSION_SEPARATOR: char = '/'; @@ -151,28 +169,37 @@ const PRODUCT_VERSION_SEPARATOR: char = '/'; impl PeerSoftware { /// Interpret a peer's advertised `software_version` string. /// - /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for the legacy - /// `"0.0.0"` sentinel, and for anything that is not `product/semver` with both - /// parts non-empty and the version parsing as semver. A version that is *itself* the legacy - /// sentinel (`dig-node/0.0.0`) is also Unknown: the sentinel means "unversioned" whether or not - /// a product name was attached to it. + /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for anything that + /// is not `product/semver` with both parts non-empty and the version parsing as semver, and for + /// any advertisement whose version is VERSION ZERO. + /// + /// Version zero is the legacy sentinel and is matched as a CLASS, not as a string: the bare + /// `0.0.0`, a product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`, + /// `0.0.0+build`, `0.0.0-0`) all mean "unversioned". A peer advertising `0.0.0-rc.1` is no more + /// versioned than one advertising `0.0.0`. /// /// A product name may contain `/`; the split is at the LAST separator. pub fn parse(advertised: &str) -> Self { let raw = advertised.trim(); // No separator at all: an empty advertisement, a bare version, a product with no version, - // or the bare legacy `"0.0.0"` sentinel — which contains no `/` and so lands here rather - // than needing a clause of its own. None of them name a build. + // or a bare version-zero sentinel (`0.0.0`, `0.0.0-rc.1`) — none of which contain a `/`, so + // they land here rather than needing a clause of their own. None of them name a build. let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else { return Self::Unknown; }; - if product.is_empty() || version == LEGACY_UNVERSIONED_SENTINEL { + if product.is_empty() { return Self::Unknown; } let Ok(version) = version.parse::() else { return Self::Unknown; }; + // The sentinel is VERSION ZERO, a class — not the three-character string. Comparing the + // PARSED version is what makes `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` Unknown too; a + // string comparison would report each of them as a real build at version zero. + if is_version_zero(&version) { + return Self::Unknown; + } Self::Reported { product: product.to_string(), @@ -699,6 +726,19 @@ mod tests { } } + struct PartialOrdProbe(core::marker::PhantomData); + trait PartialOrdFallback { + fn is_partial_ord() -> bool { + false + } + } + impl PartialOrdFallback for PartialOrdProbe {} + impl PartialOrdProbe { + fn is_partial_ord() -> bool { + true + } + } + /// A version comparison must be unreachable without first destructuring `Reported`, so that a /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is /// Unknown, would quietly become a verdict about most of the live network. @@ -847,4 +887,151 @@ mod tests { ); } } + + // ---- Gate round 1 regressions (dig_ecosystem#2215) ---- + + /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can + /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)` + /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real + /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names + /// all three traits; this pins the weakest of them, which subsumes `Ord`. + #[test] + fn peer_software_is_not_partially_ordered_either() { + assert!( + PartialOrdProbe::::is_partial_ord(), + "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves nothing about the gap between the two" + ); + assert!( + PartialOrdProbe::::is_partial_ord(), + "control: a fully-ordered type must also be detected" + ); + assert!( + !PartialOrdProbe::::is_partial_ord(), + "PeerSoftware must implement neither PartialOrd nor Ord" + ); + } + + /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.** + /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a + /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported* + /// version zero — the exact reading every one of those three prose statements forbids. + #[test] + fn version_zero_is_unknown_however_it_is_decorated() { + for raw in [ + "dig-node/0.0.0", + "dig-node/0.0.0+build", + "dig-node/0.0.0-rc.1", + "x/0.0.0-0", + "dig-node/0.0.0-alpha+sha.abc123", + ] { + assert_eq!( + PeerSoftware::parse(raw), + PeerSoftware::Unknown, + "{raw:?} is version zero and must be Unknown" + ); + } + } + + /// A version that is merely CLOSE to zero is still a real build and must be reported — without + /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above. + #[test] + fn a_nonzero_version_near_zero_is_still_reported() { + for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] { + assert_ne!( + PeerSoftware::parse(raw), + PeerSoftware::Unknown, + "{raw:?} is a real build, not the sentinel" + ); + } + } + + /// **`render`'s stated invariant, tested over the class it is stated over.** + /// + /// The doc promises: every rendering is either the empty string or a value `parse` reads back + /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose + /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same + /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door. + #[test] + fn every_rendering_is_empty_or_readable() { + let versions = [ + "0.0.1", + "0.0.7", + "0.0.99", // the class the 1.4.7 fixture cannot see + "0.1.0", + "0.99.1", + "1.0.0", + "1.4.7", + "10.20.30", + "1.0.0-nightly.20260805+sha.abc123", + "0.0.1-rc.1", + ]; + for mode in [ + SoftwareVersionDetail::Full, + SoftwareVersionDetail::Minor, + SoftwareVersionDetail::Off, + ] { + for v in versions { + let rendered = mode.render("dig-node", &v.parse().unwrap()); + if rendered.is_empty() { + continue; + } + assert_ne!( + PeerSoftware::parse(&rendered), + PeerSoftware::Unknown, + "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a non-empty rendering must always be readable" + ); + } + } + } + + /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately. + /// + /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as + /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is + /// the empty string rather than the sentinel dressed up as a report. This differs from the + /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen; + /// here none exists. + #[test] + fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() { + let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap()); + assert_eq!(rendered, ""); + assert_ne!( + rendered, "dig-node/0.0.0", + "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer" + ); + } + + /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every + /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This + /// asserts that equivalence deliberately. + /// + /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting + /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now + /// the only record of what the peer actually sent. Do not "fix" it by deleting the field; + /// replace this test with real assertions on the divergent inputs. + #[test] + fn raw_is_still_reconstructible_from_the_parsed_parts() { + for advertised in [ + "dig-node/0.0.1", + "dig-node/0.99.1", + "dig-node/1.0.0-nightly.20260805", + "dig-node/1.0.0+sha.abc123", + "dig-node/1.0.0-rc.1+build.7", + "acme/dig-node/1.2.3", + ] { + let PeerSoftware::Reported { + product, + version, + raw, + } = PeerSoftware::parse(advertised) + else { + panic!("{advertised:?} must be Reported"); + }; + assert_eq!( + raw, + format!("{product}/{version}"), + "raw diverged from the parsed parts for {advertised:?} — `raw` is now load-bearing; see this test's doc comment before changing anything" + ); + } + } }