diff --git a/Cargo.lock b/Cargo.lock index ea0d4d1..631c235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,10 +15,11 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.3.0" +version = "0.4.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..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" @@ -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..74eecb8 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,85 @@ 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` | + | 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. + + **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. + + **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. + +- **`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` 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`. 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. + + 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). + ## 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..fe32a1f 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, 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/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..33eba48 100644 --- a/src/results.rs +++ b/src/results.rs @@ -28,6 +28,187 @@ 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; 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. + 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 => { + 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(), + } + } +} + +/// 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 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 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 { + /// 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, + }, +} + +/// Is this VERSION ZERO — the legacy "no version" sentinel, whatever it is dressed in? +/// +/// 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 = '/'; + +impl PeerSoftware { + /// Interpret a peer's advertised `software_version` string. + /// + /// 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 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() { + 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(), + version, + raw: raw.to_string(), + } + } +} + /// `control.status` — a rich node status snapshot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StatusResult { @@ -376,4 +557,481 @@ 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 + } + } + + 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. + #[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" + ); + } + + // ---- 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 + ); + } + } + + // ---- 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" + ); + } + } }