Skip to main content

dig_node_control_interface/
results.rs

1//! Typed result payloads for the control methods.
2//!
3//! Each struct is field-for-field identical to what dig-node emits (snake_case wire fields), so a
4//! client deserializes the node's real response and re-serializes the same bytes — the property the
5//! conformance KATs pin. Genuinely open/proxied shapes (the updater beacon's status, the peer-pool
6//! snapshot, the pairing list) stay [`serde_json::Value`] on the call's `Output` rather than being
7//! frozen into a struct that would drift from the proxied source.
8
9use serde::{Deserialize, Serialize};
10
11/// The on-disk content-cache view (`control.cache.get`, and embedded in [`StatusResult`]).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CacheView {
14    /// The configured cache size cap, in bytes.
15    pub cap_bytes: u64,
16    /// Bytes currently used on disk.
17    pub used_bytes: u64,
18    /// The cache directory.
19    pub dir: String,
20    /// Whether the cache directory is the machine-wide shared cache.
21    pub shared: bool,
22}
23
24/// The §21 sync availability flag embedded in [`StatusResult`].
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SyncAvailability {
27    /// Whether authenticated §21 whole-store sync is available on this node.
28    pub available: bool,
29}
30
31/// How much of its own build a node reveals when it advertises (dig_ecosystem#2215).
32///
33/// Advertising an exact build is a fingerprinting aid — it tells an observer precisely which peers
34/// run a version with a publicly disclosed defect. This is the operator's dial between that cost
35/// and the diagnostic value of knowing what the network is running.
36///
37/// It lives here, beside [`PeerSoftware`], because rendering and parsing are two halves of one
38/// format: a node that hand-rolled its own `product/version` string would be re-implementing half
39/// the contract, and the two halves would drift. A node picks a mode; this type renders it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SoftwareVersionDetail {
43    /// Advertise the exact build, e.g. `dig-node/0.99.1`. The default: the diagnostic value is why
44    /// the field exists, and an operator who disagrees opts down explicitly.
45    #[default]
46    Full,
47    /// Advertise only the major and minor level, e.g. `dig-node/0.99.0`. Hides the patch level, and
48    /// any pre-release or build metadata, while remaining READABLE at the far end.
49    Minor,
50    /// Advertise nothing. Indistinguishable from a peer built before this field existed, and reads
51    /// as [`PeerSoftware::Unknown`].
52    Off,
53}
54
55impl SoftwareVersionDetail {
56    /// Render the advertisement a node with this setting puts on its handshake.
57    ///
58    /// The result is ALWAYS either the empty string or a value [`PeerSoftware::parse`] reads back
59    /// as `Reported`. Coarsening reduces PRECISION; it never produces a value that reads as
60    /// Unknown while pretending to be a report. Two consequences follow, and both are tested:
61    ///
62    /// - [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0`, never a bare
63    ///   `MAJOR.MINOR` — two-part versions are not valid semver, so that spelling would read as
64    ///   Unknown and become a second, confusing spelling of [`Off`](SoftwareVersionDetail::Off).
65    /// - `Minor` of a `0.0.x` build renders the EMPTY STRING, because its coarsening is version
66    ///   zero and version zero is the "unknown" sentinel. There is no coarser representable value,
67    ///   so it advertises nothing rather than advertising the sentinel as if it were a report.
68    ///
69    /// A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`. That is the point of
70    /// coarsening, not a defect in it.
71    pub fn render(self, product: &str, version: &semver::Version) -> String {
72        match self {
73            Self::Full => format!("{product}/{version}"),
74            // A pre-release identifier (`-nightly.20260805`) is more precisely identifying than the
75            // patch number beside it, so a "coarse" advertisement that kept it would coarsen
76            // nothing for exactly the builds that most want it. `Version::new` drops both it and
77            // any build metadata.
78            Self::Minor => {
79                let coarsened = semver::Version::new(version.major, version.minor, 0);
80                // Hiding the patch of a `0.0.x` build leaves version zero, which the wire reserves
81                // as the "unknown" sentinel. There is no coarser representable value, so advertise
82                // nothing rather than advertise the sentinel dressed up as a report. (This differs
83                // from the rejected two-part `MAJOR.MINOR` spelling: there a representable coarse
84                // value existed and the wrong one was chosen; here none exists.)
85                if is_version_zero(&coarsened) {
86                    return String::new();
87                }
88                format!("{product}/{coarsened}")
89            }
90            Self::Off => String::new(),
91        }
92    }
93}
94
95/// A peer's advertised SOFTWARE build, as read from the gossip handshake (dig_ecosystem#2215).
96///
97/// dig-gossip carries the peer's `Handshake.software_version` as an opaque sanitized string and
98/// deliberately does not interpret it. This type is where that string becomes meaning, once, at the
99/// control boundary — so the interpretation is defined in one place and every client agrees.
100///
101/// # This is NOT the protocol version
102///
103/// Wire compatibility is a separate field that dig-gossip gates connections on. Two peers can speak
104/// the same protocol while running builds months apart; this type reports the latter. It MUST NOT
105/// be used to decide whether to talk to a peer.
106///
107/// # Why there is no `Ord` and no `Default`
108///
109/// [`Unknown`](PeerSoftware::Unknown) has no position on a version line: it is the absence of a
110/// measurement, not a low value. Deriving `Ord` would place it somewhere — and every peer built
111/// before #2215 is Unknown, so "somewhere" would silently become a verdict about most of the live
112/// network. Comparison is therefore reachable only by destructuring
113/// [`Reported`](PeerSoftware::Reported), which forces the caller to say what Unknown means for
114/// their question. There is no `Default` for the same reason: a defaulted Unknown that appears from
115/// nowhere is a different fact from one that was measured, and the two must not be confusable.
116///
117/// # JSON
118///
119/// ```json
120/// {"kind": "unknown"}
121/// {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"}
122/// ```
123///
124/// Unknown carries no `version` member at all — never version zero, never `""`, never `null` in a
125/// field a consumer might read as a version.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(tag = "kind", rename_all = "snake_case")]
128pub enum PeerSoftware {
129    /// The peer's build is not known: it advertised nothing, advertised VERSION ZERO — the legacy
130    /// sentinel, in any decoration (`0.0.0`, `0.0.0-rc.1`, `0.0.0+build`) — or advertised something
131    /// this contract cannot parse. See [`PeerSoftware::parse`] for why those three are one case.
132    Unknown,
133    /// The peer advertised a well-formed `product/semver` build.
134    Reported {
135        /// The product name, e.g. `dig-node`. Everything before the LAST `/`.
136        product: String,
137        /// The parsed semantic version, e.g. `0.99.1`. Serializes as its string form.
138        version: semver::Version,
139        /// Exactly what the peer advertised, after trimming.
140        ///
141        /// **Currently reconstructible, deliberately kept.** The grammar this parser accepts is
142        /// lossless — `semver::Version` re-renders every string it accepts byte-identically — so
143        /// today `raw` always equals `format!("{product}/{version}")`, and no test can distinguish
144        /// this field from that expression. It is retained as the honest source: the moment the
145        /// grammar accepts anything non-canonical (a `v` prefix, a two-part version, a vendor
146        /// suffix), a diagnostic reader must see what the peer actually sent rather than this
147        /// parser's opinion of it, and callers that already read `raw` will not need to change.
148        raw: String,
149    },
150}
151
152/// Is this VERSION ZERO — the legacy "no version" sentinel, whatever it is dressed in?
153///
154/// Three of dig-gossip's four handshake send sites hardcoded `"0.0.0"` before dig_ecosystem#2215,
155/// so version zero is not a hypothetical value: it is what the live fleet is sending right now. It
156/// means "this build predates the field", which is [`PeerSoftware::Unknown`]; mapping it to a
157/// *version* would make the whole existing network read as ancient.
158///
159/// The test is over the major/minor/patch TRIPLE, ignoring any pre-release or build metadata. A
160/// peer advertising `0.0.0-rc.1` is no more versioned than one advertising `0.0.0`, and matching
161/// the bare string would let the decorated forms through as real builds at version zero.
162fn is_version_zero(version: &semver::Version) -> bool {
163    version.major == 0 && version.minor == 0 && version.patch == 0
164}
165
166/// The separator between the product and the version in a `product/semver` advertisement.
167const PRODUCT_VERSION_SEPARATOR: char = '/';
168
169impl PeerSoftware {
170    /// Interpret a peer's advertised `software_version` string.
171    ///
172    /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for anything that
173    /// is not `product/semver` with both parts non-empty and the version parsing as semver, and for
174    /// any advertisement whose version is VERSION ZERO.
175    ///
176    /// Version zero is the legacy sentinel and is matched as a CLASS, not as a string: the bare
177    /// `0.0.0`, a product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`,
178    /// `0.0.0+build`, `0.0.0-0`) all mean "unversioned". A peer advertising `0.0.0-rc.1` is no more
179    /// versioned than one advertising `0.0.0`.
180    ///
181    /// A product name may contain `/`; the split is at the LAST separator.
182    pub fn parse(advertised: &str) -> Self {
183        let raw = advertised.trim();
184
185        // No separator at all: an empty advertisement, a bare version, a product with no version,
186        // or a bare version-zero sentinel (`0.0.0`, `0.0.0-rc.1`) — none of which contain a `/`, so
187        // they land here rather than needing a clause of their own. None of them name a build.
188        let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else {
189            return Self::Unknown;
190        };
191        if product.is_empty() {
192            return Self::Unknown;
193        }
194        let Ok(version) = version.parse::<semver::Version>() else {
195            return Self::Unknown;
196        };
197        // The sentinel is VERSION ZERO, a class — not the three-character string. Comparing the
198        // PARSED version is what makes `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` Unknown too; a
199        // string comparison would report each of them as a real build at version zero.
200        if is_version_zero(&version) {
201            return Self::Unknown;
202        }
203
204        Self::Reported {
205            product: product.to_string(),
206            version,
207            raw: raw.to_string(),
208        }
209    }
210}
211
212/// `control.status` — a rich node status snapshot.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct StatusResult {
215    /// Always `true` for a responding node.
216    pub running: bool,
217    /// The service name (`"dig-node"`).
218    pub service: String,
219    /// The node binary's semantic version.
220    pub version: String,
221    /// The git commit the binary was built from (or `"unknown"`).
222    pub commit: String,
223    /// The DIG read protocol version the node speaks.
224    pub protocol: String,
225    /// Process uptime in seconds.
226    pub uptime_secs: u64,
227    /// The loopback `host:port` the node is bound to.
228    pub addr: String,
229    /// The upstream DIG RPC the node proxies/syncs to.
230    pub upstream: String,
231    /// The on-disk cache view.
232    pub cache: CacheView,
233    /// Distinct stores held (from the cache).
234    pub hosted_store_count: u64,
235    /// Cached capsule count.
236    pub cached_capsule_count: u64,
237    /// Pinned-store count.
238    pub pinned_store_count: u64,
239    /// §21 sync availability.
240    pub sync: SyncAvailability,
241}
242
243/// `control.config.get` — the node's effective configuration.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ConfigResult {
246    /// The bound `host:port`.
247    pub addr: String,
248    /// The bound port, as a string.
249    pub port: String,
250    /// The effective upstream DIG RPC.
251    pub upstream: String,
252    /// The persisted upstream override, or `null` when unset.
253    pub upstream_override: Option<String>,
254    /// The cache directory.
255    pub cache_dir: String,
256    /// Whether the cache is the machine-wide shared cache.
257    pub cache_shared: bool,
258    /// The node's config.json path.
259    pub config_path: String,
260    /// Whether authenticated §21 sync is available.
261    pub sync_available: bool,
262}
263
264/// `control.config.setUpstream` — the persisted override + a restart hint.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct SetUpstreamResult {
267    /// The normalized upstream that was persisted.
268    pub upstream: String,
269    /// Always `true` — the change takes effect on next node start.
270    pub requires_restart: bool,
271}
272
273/// `control.log.setLevel` — the applied filter directive.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct SetLevelResult {
276    /// The EnvFilter directive now in effect.
277    pub filter: String,
278}
279
280/// `control.cache.setCap` — the applied cap (after the 64 MiB floor).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct SetCapResult {
283    /// The cache cap now in effect, in bytes.
284    pub cap_bytes: u64,
285}
286
287/// `control.cache.clear` — the clear acknowledgement.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct CacheClearResult {
290    /// Always `true`.
291    pub cleared: bool,
292}
293
294/// One cached capsule of a store, as listed by the hosted-stores methods.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct CapsuleEntry {
297    /// The capsule reference (`storeId:rootHash`).
298    pub capsule: String,
299    /// The capsule root hash.
300    pub root: String,
301    /// The capsule size on disk, in bytes.
302    pub size_bytes: u64,
303    /// When the capsule was last served, in unix milliseconds.
304    pub last_used_unix_ms: u64,
305}
306
307/// One hosted/pinned store (`control.hostedStores.list`).
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct HostedStore {
310    /// The canonical lowercase 64-hex store id.
311    pub store_id: String,
312    /// Whether the operator has pinned this store.
313    pub pinned: bool,
314    /// The number of cached capsules of this store.
315    pub capsule_count: u64,
316    /// The total cached bytes across this store's capsules.
317    pub total_bytes: u64,
318    /// The cached capsules of this store.
319    pub capsules: Vec<CapsuleEntry>,
320}
321
322/// `control.hostedStores.list` — every held/pinned store.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct HostedStoresListResult {
325    /// The stores, one entry per distinct store id.
326    pub stores: Vec<HostedStore>,
327}
328
329/// `control.hostedStores.pin` — the pin acknowledgement + the pre-fetch outcome.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct PinResult {
332    /// The store id that was pinned.
333    pub store_id: String,
334    /// The pinned root, or `null` when pinned at store level.
335    pub root: Option<String>,
336    /// Always `true`.
337    pub pinned: bool,
338    /// The in-band pre-fetch outcome (`{status, …}`) — its shape varies with the fetch path.
339    pub fetch: serde_json::Value,
340}
341
342/// `control.hostedStores.unpin` — the unpin acknowledgement + eviction count.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct UnpinResult {
345    /// The store id that was unpinned.
346    pub store_id: String,
347    /// Whether a pin registry entry was actually removed.
348    pub unpinned: bool,
349    /// How many cached capsules of the store were evicted.
350    pub evicted_capsules: u64,
351}
352
353/// `control.hostedStores.status` — per-store pinned flag + cached capsules.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct HostedStoreStatusResult {
356    /// The store id queried.
357    pub store_id: String,
358    /// Whether the store is pinned.
359    pub pinned: bool,
360    /// The number of cached capsules.
361    pub capsule_count: u64,
362    /// The total cached bytes.
363    pub total_bytes: u64,
364    /// The cached capsules.
365    pub capsules: Vec<CapsuleEntry>,
366}
367
368/// `control.sync.status` — §21 sync availability + pin coverage.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct SyncStatusResult {
371    /// Whether authenticated §21 whole-store sync is available.
372    pub available: bool,
373    /// The sync method name.
374    pub method: String,
375    /// The number of pinned stores.
376    pub pinned_total: u64,
377    /// How many pinned stores currently have a cached capsule.
378    pub pinned_synced: u64,
379    /// Whether whole-store (root-less) sync is supported by this build.
380    pub whole_store_trigger_supported: bool,
381}
382
383/// `control.sync.trigger` — the synced-capsule outcome.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct SyncTriggerResult {
386    /// The store id synced.
387    pub store_id: String,
388    /// The capsule root synced.
389    pub root: String,
390    /// The outcome status (`"synced"`).
391    pub status: String,
392    /// The synced capsule size, in bytes.
393    pub size_bytes: u64,
394    /// The served root the node verified against.
395    pub served_root: String,
396}
397
398/// `control.pairing.approve` — the mint acknowledgement + the new token's id.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct PairingApproveResult {
401    /// Always `true`.
402    pub approved: bool,
403    /// The requesting client's declared name.
404    pub client_name: String,
405    /// The short id of the minted paired token (used to revoke it).
406    pub token_id: String,
407}
408
409/// `control.pairing.revoke` — the revoke acknowledgement.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct PairingRevokeResult {
412    /// Whether a token was actually removed.
413    pub revoked: bool,
414    /// The token id that was targeted.
415    pub token_id: String,
416}
417
418/// `control.peers.connect` — the connected peer's id.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct PeersConnectResult {
421    /// Always `true` on success.
422    pub connected: bool,
423    /// The connected peer's id.
424    pub peer_id: String,
425}
426
427/// `control.peers.disconnect` — the dropped peer's id.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct PeersDisconnectResult {
430    /// Always `true` (idempotent — dropping an absent peer still succeeds).
431    pub disconnected: bool,
432    /// The peer id that was targeted (trimmed + lower-cased).
433    pub peer_id: String,
434}
435
436/// `control.subscribe` — the subscription acknowledgement.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct SubscribeResult {
439    /// Always `true`.
440    pub subscribed: bool,
441    /// Whether the store was newly added (vs already subscribed).
442    pub added: bool,
443    /// The canonical persisted store id (trimmed + lower-cased).
444    pub store_id: String,
445}
446
447/// `control.unsubscribe` — the unsubscription acknowledgement.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct UnsubscribeResult {
450    /// Always `false`.
451    pub subscribed: bool,
452    /// Whether the store was actually removed.
453    pub removed: bool,
454    /// The canonical store id.
455    pub store_id: String,
456}
457
458/// `control.listSubscriptions` — the node's persisted subscription set.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ListSubscriptionsResult {
461    /// The subscribed store ids.
462    pub subscriptions: Vec<String>,
463    /// The subscription count.
464    pub count: u64,
465}
466
467/// `control.wallet.balance` — an address's balance for one asset, as the node's chain read saw it.
468///
469/// A READ-only result: this reports chain state, it never moves funds. It is a strict SUPERSET of
470/// dig-app's frozen `BalanceResponse { balance }` — the node emits the richer shape, and because
471/// dig-app's struct does not deny unknown fields it reads [`balance`](Self::balance) losslessly and
472/// ignores the rest. That superset relationship is the "no dig-app code change" guarantee, pinned by
473/// the conformance KAT.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct WalletBalanceResult {
476    /// The CONFIRMED, spendable balance in the asset's base unit (mojos for XCH, base units for DIG).
477    /// The only field dig-app 3.x reads.
478    pub balance: u64,
479    /// Incoming funds seen but not yet confirmed (asset base units); not yet spendable.
480    pub pending: u64,
481    /// Whether the node's chain view is caught up. When `false`, the figures are STALE.
482    pub synced: bool,
483    /// The peak block height the reported figures reflect, or `null` when the node has no height yet.
484    pub peak_height: Option<u32>,
485}
486
487/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub struct PairingRequestResult {
490    /// The opaque pairing id to poll with.
491    pub pairing_id: String,
492    /// A short numeric code the operator compares before approving.
493    pub pairing_code: String,
494    /// When the pending pairing expires, in unix milliseconds.
495    pub expires_ms: u64,
496}
497
498/// `pairing.poll` — the pairing poll outcome (OPEN, no token).
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500pub struct PairingPollResult {
501    /// The pairing status (`"pending"` / `"approved"` / …).
502    pub status: String,
503    /// The minted scoped token, present exactly once after approval.
504    #[serde(skip_serializing_if = "Option::is_none", default)]
505    pub token: Option<String>,
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use serde_json::json;
512
513    #[test]
514    fn status_result_round_trips_the_node_shape() {
515        let v = json!({
516            "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
517            "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
518            "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
519            "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
520            "sync": {"available": true}
521        });
522        let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
523        assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
524    }
525
526    #[test]
527    fn config_result_keeps_upstream_override_null_when_unset() {
528        let parsed = ConfigResult {
529            addr: "127.0.0.1:9256".into(),
530            port: "9256".into(),
531            upstream: "https://rpc.dig.net".into(),
532            upstream_override: None,
533            cache_dir: "/c".into(),
534            cache_shared: false,
535            config_path: "/c/config.json".into(),
536            sync_available: true,
537        };
538        let v = serde_json::to_value(&parsed).unwrap();
539        assert_eq!(v["upstream_override"], json!(null));
540        assert!(v.as_object().unwrap().contains_key("upstream_override"));
541    }
542
543    #[test]
544    fn pairing_poll_omits_token_until_approved() {
545        let pending = PairingPollResult {
546            status: "pending".into(),
547            token: None,
548        };
549        let v = serde_json::to_value(&pending).unwrap();
550        assert_eq!(v, json!({"status": "pending"}));
551        let approved = PairingPollResult {
552            status: "approved".into(),
553            token: Some("deadbeef".into()),
554        };
555        assert_eq!(
556            serde_json::to_value(&approved).unwrap(),
557            json!({"status": "approved", "token": "deadbeef"})
558        );
559    }
560
561    // ---- PeerSoftware (dig_ecosystem#2215) ----
562
563    /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL
564    /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that
565    /// maps only `""` to Unknown would therefore read the entire live fleet as "software version
566    /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything
567    /// unparseable must all be Unknown, and this test is that mapping's guard.
568    #[test]
569    fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
570        for raw in [
571            "",                       // a peer advertising nothing, or `off` coarsening
572            "0.0.0",                  // the pre-#2215 legacy sentinel
573            "   ",                    // whitespace only
574            "dig-node",               // no version part
575            "dig-node/",              // empty version part
576            "dig-node/not-a-version", // unparseable version
577            "/1.2.3",                 // empty product part
578            "1.2.3",                  // bare version, no product
579            "dig-node/0.0.0",         // the sentinel, however it is dressed up
580        ] {
581            assert_eq!(
582                PeerSoftware::parse(raw),
583                PeerSoftware::Unknown,
584                "{raw:?} must map to Unknown"
585            );
586        }
587    }
588
589    /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw`
590    /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer
591    /// did not actually advertise.
592    #[test]
593    fn reported_carries_product_version_and_the_raw_advertisement() {
594        let parsed = PeerSoftware::parse("dig-node/0.99.1");
595        let PeerSoftware::Reported {
596            product,
597            version,
598            raw,
599        } = parsed
600        else {
601            panic!("a well-formed advertisement must be Reported");
602        };
603        assert_eq!(product, "dig-node");
604        assert_eq!(version, semver::Version::new(0, 99, 1));
605        assert_eq!(raw, "dig-node/0.99.1");
606    }
607
608    /// A product name may itself contain a `/`; only the LAST separator splits product from
609    /// version. Pinning this stops a future reader from switching to a first-separator split,
610    /// which would silently reclassify such a peer as Unknown.
611    #[test]
612    fn product_is_split_at_the_last_separator() {
613        let PeerSoftware::Reported {
614            product, version, ..
615        } = PeerSoftware::parse("acme/dig-node/1.2.3")
616        else {
617            panic!("expected Reported");
618        };
619        assert_eq!(product, "acme/dig-node");
620        assert_eq!(version, semver::Version::new(1, 2, 3));
621    }
622
623    /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED
624    /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not
625    /// spaces, so a padded advertisement reaches this parser intact and must not be classified as
626    /// unparseable merely for having been padded.
627    #[test]
628    fn surrounding_whitespace_is_trimmed_before_parsing() {
629        let PeerSoftware::Reported {
630            product,
631            version,
632            raw,
633        } = PeerSoftware::parse("  dig-node/1.2.3	")
634        else {
635            panic!("a padded advertisement must still be Reported");
636        };
637        assert_eq!(product, "dig-node");
638        assert_eq!(version, semver::Version::new(1, 2, 3));
639        assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
640    }
641
642    /// A pre-release/build-metadata semver survives intact, because that is what a nightly build
643    /// advertises and dropping it would make every nightly indistinguishable from its release.
644    #[test]
645    fn prerelease_versions_are_preserved() {
646        let PeerSoftware::Reported { version, raw, .. } =
647            PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
648        else {
649            panic!("expected Reported");
650        };
651        assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
652        assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
653    }
654
655    /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a
656    /// version field where a consumer might read it as a number.
657    #[test]
658    fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
659        let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
660        assert_eq!(v, json!({"kind": "unknown"}));
661        assert!(
662            v.get("version").is_none(),
663            "Unknown must not carry a version field at all"
664        );
665    }
666
667    /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's
668    /// response unchanged.
669    #[test]
670    fn both_variants_round_trip_byte_identically() {
671        for wire in [
672            json!({"kind": "unknown"}),
673            json!({
674                "kind": "reported",
675                "product": "dig-node",
676                "version": "0.99.1",
677                "raw": "dig-node/0.99.1"
678            }),
679        ] {
680            let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
681            assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
682        }
683    }
684
685    /// Parsing a wire string and serializing the result produces the documented JSON, so the two
686    /// halves of the contract cannot drift from each other.
687    #[test]
688    fn parse_then_serialize_matches_the_documented_json() {
689        assert_eq!(
690            serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
691            json!({
692                "kind": "reported",
693                "product": "dig-node",
694                "version": "0.99.1",
695                "raw": "dig-node/0.99.1"
696            })
697        );
698        assert_eq!(
699            serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
700            json!({"kind": "unknown"})
701        );
702    }
703
704    // ---- Trait-absence probes (dig_ecosystem#2215) ----
705    //
706    // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather
707    // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl
708    // precedence: `Probe::<T>::has_it()` resolves to the inherent impl (returning `true`) only when
709    // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`).
710    //
711    // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a
712    // probe broken so that it always answers `false` would pass while proving nothing.
713
714    struct Probe<T>(core::marker::PhantomData<T>);
715
716    trait ProbeFallback {
717        fn is_ord() -> bool {
718            false
719        }
720    }
721    impl<T> ProbeFallback for Probe<T> {}
722
723    impl<T: Ord> Probe<T> {
724        fn is_ord() -> bool {
725            true
726        }
727    }
728
729    struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
730    trait PartialOrdFallback {
731        fn is_partial_ord() -> bool {
732            false
733        }
734    }
735    impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
736    impl<T: PartialOrd> PartialOrdProbe<T> {
737        fn is_partial_ord() -> bool {
738            true
739        }
740    }
741
742    /// A version comparison must be unreachable without first destructuring `Reported`, so that a
743    /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is
744    /// Unknown, would quietly become a verdict about most of the live network.
745    #[test]
746    fn peer_software_is_not_ordered() {
747        assert!(
748            Probe::<u32>::is_ord(),
749            "control: the probe must detect a type that IS Ord, or it proves nothing"
750        );
751        assert!(
752            !Probe::<PeerSoftware>::is_ord(),
753            "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
754        );
755    }
756
757    /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and
758    /// `Default` would make the two indistinguishable at the point of construction.
759    #[test]
760    fn peer_software_has_no_default() {
761        struct DefaultProbe<T>(core::marker::PhantomData<T>);
762        trait DefaultFallback {
763            fn is_default() -> bool {
764                false
765            }
766        }
767        impl<T> DefaultFallback for DefaultProbe<T> {}
768        impl<T: Default> DefaultProbe<T> {
769            fn is_default() -> bool {
770                true
771            }
772        }
773
774        assert!(
775            DefaultProbe::<String>::is_default(),
776            "control: the probe must detect a type that IS Default, or it proves nothing"
777        );
778        assert!(
779            !DefaultProbe::<PeerSoftware>::is_default(),
780            "PeerSoftware must not implement Default"
781        );
782    }
783
784    // ---- SoftwareVersionDetail (dig_ecosystem#2215) ----
785
786    /// Each mode renders a value the PARSER reads back at the intended level of detail.
787    ///
788    /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the
789    /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that
790    /// ignores the mode entirely pass.
791    #[test]
792    fn each_detail_mode_round_trips_to_the_intended_precision() {
793        let v = semver::Version::new(0, 99, 1);
794
795        let full = SoftwareVersionDetail::Full.render("dig-node", &v);
796        assert_eq!(full, "dig-node/0.99.1");
797        assert_eq!(
798            PeerSoftware::parse(&full),
799            PeerSoftware::parse("dig-node/0.99.1")
800        );
801
802        let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
803        assert_ne!(minor, full, "Minor must actually coarsen");
804        let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
805            panic!("a coarsened advertisement must still be READABLE, not Unknown");
806        };
807        assert_eq!(version.major, 0);
808        assert_eq!(version.minor, 99);
809        assert_eq!(version.patch, 0, "the patch level is what Minor hides");
810
811        let off = SoftwareVersionDetail::Off.render("dig-node", &v);
812        assert_eq!(off, "");
813        assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
814    }
815
816    /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`.
817    ///
818    /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that
819    /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which
820    /// is what `Off` is for. This test is the guard on that distinction.
821    #[test]
822    fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
823        let rendered =
824            SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
825        assert_eq!(rendered, "dig-node/1.4.0");
826        assert_ne!(
827            PeerSoftware::parse(&rendered),
828            PeerSoftware::Unknown,
829            "a coarsened build must remain readable; `product/1.4` would not be"
830        );
831    }
832
833    /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely
834    /// identifying than the patch number it accompanies, so leaving it in place would make `Minor`
835    /// coarsen nothing at all for exactly the builds that most want it.
836    #[test]
837    fn minor_mode_strips_prerelease_and_build_metadata() {
838        let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
839        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
840        assert_eq!(rendered, "dig-node/1.0.0");
841        assert!(
842            !rendered.contains("nightly"),
843            "the nightly identifier must not survive coarsening"
844        );
845        assert!(
846            !rendered.contains("abc123"),
847            "build metadata must not survive coarsening"
848        );
849    }
850
851    /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable
852    /// from a peer built before the field existed.
853    #[test]
854    fn off_mode_reveals_nothing_for_any_version() {
855        for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
856            let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
857            assert_eq!(
858                rendered, "",
859                "Off must reveal nothing, including the product name"
860            );
861        }
862    }
863
864    /// The default is the most informative setting: the diagnostic value is the reason the field
865    /// exists, and an operator who disagrees opts down explicitly.
866    #[test]
867    fn detail_defaults_to_full() {
868        assert_eq!(
869            SoftwareVersionDetail::default(),
870            SoftwareVersionDetail::Full
871        );
872    }
873
874    /// The wire tokens are the lowercase words an operator writes in a config file, and they are a
875    /// published contract once a config carries them.
876    #[test]
877    fn detail_uses_lowercase_wire_tokens() {
878        for (mode, token) in [
879            (SoftwareVersionDetail::Full, "\"full\""),
880            (SoftwareVersionDetail::Minor, "\"minor\""),
881            (SoftwareVersionDetail::Off, "\"off\""),
882        ] {
883            assert_eq!(serde_json::to_string(&mode).unwrap(), token);
884            assert_eq!(
885                serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
886                mode
887            );
888        }
889    }
890
891    // ---- Gate round 1 regressions (dig_ecosystem#2215) ----
892
893    /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can
894    /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)`
895    /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real
896    /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names
897    /// all three traits; this pins the weakest of them, which subsumes `Ord`.
898    #[test]
899    fn peer_software_is_not_partially_ordered_either() {
900        assert!(
901            PartialOrdProbe::<f64>::is_partial_ord(),
902            "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves              nothing about the gap between the two"
903        );
904        assert!(
905            PartialOrdProbe::<u32>::is_partial_ord(),
906            "control: a fully-ordered type must also be detected"
907        );
908        assert!(
909            !PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
910            "PeerSoftware must implement neither PartialOrd nor Ord"
911        );
912    }
913
914    /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.**
915    /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a
916    /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported*
917    /// version zero — the exact reading every one of those three prose statements forbids.
918    #[test]
919    fn version_zero_is_unknown_however_it_is_decorated() {
920        for raw in [
921            "dig-node/0.0.0",
922            "dig-node/0.0.0+build",
923            "dig-node/0.0.0-rc.1",
924            "x/0.0.0-0",
925            "dig-node/0.0.0-alpha+sha.abc123",
926        ] {
927            assert_eq!(
928                PeerSoftware::parse(raw),
929                PeerSoftware::Unknown,
930                "{raw:?} is version zero and must be Unknown"
931            );
932        }
933    }
934
935    /// A version that is merely CLOSE to zero is still a real build and must be reported — without
936    /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above.
937    #[test]
938    fn a_nonzero_version_near_zero_is_still_reported() {
939        for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
940            assert_ne!(
941                PeerSoftware::parse(raw),
942                PeerSoftware::Unknown,
943                "{raw:?} is a real build, not the sentinel"
944            );
945        }
946    }
947
948    /// **`render`'s stated invariant, tested over the class it is stated over.**
949    ///
950    /// The doc promises: every rendering is either the empty string or a value `parse` reads back
951    /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose
952    /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same
953    /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door.
954    #[test]
955    fn every_rendering_is_empty_or_readable() {
956        let versions = [
957            "0.0.1",
958            "0.0.7",
959            "0.0.99", // the class the 1.4.7 fixture cannot see
960            "0.1.0",
961            "0.99.1",
962            "1.0.0",
963            "1.4.7",
964            "10.20.30",
965            "1.0.0-nightly.20260805+sha.abc123",
966            "0.0.1-rc.1",
967        ];
968        for mode in [
969            SoftwareVersionDetail::Full,
970            SoftwareVersionDetail::Minor,
971            SoftwareVersionDetail::Off,
972        ] {
973            for v in versions {
974                let rendered = mode.render("dig-node", &v.parse().unwrap());
975                if rendered.is_empty() {
976                    continue;
977                }
978                assert_ne!(
979                    PeerSoftware::parse(&rendered),
980                    PeerSoftware::Unknown,
981                    "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a                      non-empty rendering must always be readable"
982                );
983            }
984        }
985    }
986
987    /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately.
988    ///
989    /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as
990    /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is
991    /// the empty string rather than the sentinel dressed up as a report. This differs from the
992    /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen;
993    /// here none exists.
994    #[test]
995    fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
996        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
997        assert_eq!(rendered, "");
998        assert_ne!(
999            rendered, "dig-node/0.0.0",
1000            "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
1001        );
1002    }
1003
1004    /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every
1005    /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This
1006    /// asserts that equivalence deliberately.
1007    ///
1008    /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting
1009    /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now
1010    /// the only record of what the peer actually sent. Do not "fix" it by deleting the field;
1011    /// replace this test with real assertions on the divergent inputs.
1012    #[test]
1013    fn raw_is_still_reconstructible_from_the_parsed_parts() {
1014        for advertised in [
1015            "dig-node/0.0.1",
1016            "dig-node/0.99.1",
1017            "dig-node/1.0.0-nightly.20260805",
1018            "dig-node/1.0.0+sha.abc123",
1019            "dig-node/1.0.0-rc.1+build.7",
1020            "acme/dig-node/1.2.3",
1021        ] {
1022            let PeerSoftware::Reported {
1023                product,
1024                version,
1025                raw,
1026            } = PeerSoftware::parse(advertised)
1027            else {
1028                panic!("{advertised:?} must be Reported");
1029            };
1030            assert_eq!(
1031                raw,
1032                format!("{product}/{version}"),
1033                "raw diverged from the parsed parts for {advertised:?} — `raw` is now                  load-bearing; see this test's doc comment before changing anything"
1034            );
1035        }
1036    }
1037}