From 65d6921b74618ee20024cc9a1865bc6e5570584a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:55:01 -0700 Subject: [PATCH 1/8] chore(mirror): stake out the advertised-URL module (#426) Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/advertise.rs | 3 +++ crates/dig-node-service/src/mirror/mod.rs | 1 + 2 files changed, 4 insertions(+) create mode 100644 crates/dig-node-service/src/mirror/advertise.rs diff --git a/crates/dig-node-service/src/mirror/advertise.rs b/crates/dig-node-service/src/mirror/advertise.rs new file mode 100644 index 00000000..6c244413 --- /dev/null +++ b/crates/dig-node-service/src/mirror/advertise.rs @@ -0,0 +1,3 @@ +//! What this node advertises as the place its stores can be fetched from (dig-node#426). +//! +//! WIP — the shape is being settled on the ticket before any behaviour lands here. diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index b25af96d..8fe6cfca 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,7 @@ //! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named //! `*_mojos` and come from separate coins so a fee can never shave collateral. +pub mod advertise; pub mod funding; pub mod lifecycle; pub mod observe; From 1ebaaa5f4aee1a96e0b8b687f2202c9229d7766b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 09:05:19 -0700 Subject: [PATCH 2/8] feat(mirror): operator-configured advertised URLs for mirror creates (#426) A mirror coin publishes where its store can be fetched from, and dig_mirror_coin::create refuses an advertisement carrying no URL -- so a node with no public name cannot bond. The value is operator-set and never derived: a coin's URLs are fixed at create for the epoch, so a self-inferred address that is unreachable from outside, or that changes, stakes real $DIG on a claim the node cannot keep and SPEC.md 25 penalises. An unset value means this node advertises nothing and creates nothing, which is the correct default: publishing an unreachable URL is worse than publishing none. Co-Authored-By: Claude --- Cargo.lock | 1 + SPEC.md | 32 +++ crates/dig-node-service/Cargo.toml | 5 + .../dig-node-service/src/mirror/advertise.rs | 229 +++++++++++++++++- 4 files changed, 265 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97d6adcd..292193db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3080,6 +3080,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "windows-service", "windows-sys 0.61.2", ] diff --git a/SPEC.md b/SPEC.md index 81a3d597..1ef6d087 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8356,3 +8356,35 @@ The surface MUST hold four properties, each of which is a money statement: * A crash at any point loses at most watcher events; the next pass re-derives the plan from disk and chain, and §23.5's reconcile plus in-flight suppression prevent both double-creates and silent losses. + +### 25.10. What the node advertises, and why it is configured + +> **PARTIALLY SATISFIED at this head.** The operator surface and its parsing exist +> (`mirror/advertise.rs`); nothing reads them yet, so no advertisement carries a URL and no coin is +> created. Tracked as . + +A mirror coin publishes, in its memos, the URLs its store can be fetched from. `dig-mirror-coin` +requires at least one and imposes no other rule on them: they are advisory fetch hints, and the +crate's reader accepts any UTF-8 entry. This node therefore decides for itself what it is honest to +publish about itself. + +The advertised URLs are **operator-configured and MUST NOT be derived**. A coin's URLs are fixed at +create for the whole epoch, so an address the node inferred about itself — a STUN reflexive address, +a resolver answer — may be unreachable from outside or may simply change, leaving collateral staked +on a claim the node cannot keep, which this section penalises. The operator sets them in +`DIG_MIRROR_ADVERTISE_URLS`, separated by commas or whitespace. + +* The list MAY carry several entries; the memo layout is built for that. IPv6 entries SHOULD be + listed first, and the node publishes the operator's order verbatim rather than sorting it. +* An entry MUST be an absolute URL with a scheme and a host. No scheme allowlist is imposed. +* An entry whose host can only mean this machine — loopback, the unspecified address, link-local, + `localhost`, or `dig.local` — MUST NOT be published. A private or LAN address MAY be published: + it is a deliberate operator choice and risks only that operator's own stake. +* A rejected entry is dropped with a warning naming the reason; the surviving entries are published. +* When no entry survives, **the node advertises nothing and creates no mirror coin**. That refusal + is the correct default: publishing a URL nobody can fetch from is worse than publishing none, + because it locks collateral against a claim that will be penalised. + +Changing the value affects only coins created after the change. Bringing an existing coin into line +means reclaiming and re-creating it — a round trip and a fee — and the node MUST NOT reclaim in +response to a configuration edit. diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index d9e3307b..cdd6b1dd 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -258,6 +258,11 @@ dig-constants = "0.13.0" hex = "0.4" base64 = "0.22" +# Parsing the operator-set advertised-URL list (dig-node#426). Already resolved in this tree as a +# transitive dependency, so declaring it directly adds no new line; hand-rolling a scheme/host split +# would be a second, weaker URL parser in a place that decides what gets published on chain. +url = "2" + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/dig-node-service/src/mirror/advertise.rs b/crates/dig-node-service/src/mirror/advertise.rs index 6c244413..4c70d47b 100644 --- a/crates/dig-node-service/src/mirror/advertise.rs +++ b/crates/dig-node-service/src/mirror/advertise.rs @@ -1,3 +1,228 @@ -//! What this node advertises as the place its stores can be fetched from (dig-node#426). +//! Where this node tells the network its stores can be fetched from (dig-node#426). //! -//! WIP — the shape is being settled on the ticket before any behaviour lands here. +//! A mirror coin publishes URLs in its memos, and `dig_mirror_coin::create` refuses an +//! advertisement carrying none — so until this node can name a place a stranger can reach it, it +//! cannot bond anything. This module is that name, and nothing more: it turns one operator-set +//! string into the URL list the advertisement takes. +//! +//! # Why the value is CONFIGURED and never derived +//! +//! The one address this machine can derive on its own is the STUN reflexive address the peer seam +//! discovers, and it is the wrong thing to publish. A mirror coin's URLs are **fixed at create** for +//! the whole epoch, so an address that is unreachable from outside — symmetric NAT, no forwarded +//! port — or that simply changes leaves real $DIG staked on a claim the node cannot keep, which +//! SPEC.md §25 penalises. [`crate::config::is_self_upstream`] records the same limit for the +//! upstream slot: a node cannot decide its own public name by resolver alone. +//! +//! So an unset value is not a failure state. It means this node advertises nothing, creates no +//! mirror coin, and says so — which is strictly better than publishing somewhere nobody can fetch +//! from. +//! +//! # Changing it later costs money +//! +//! A coin's URLs cannot be edited. Correcting this list only affects coins created after the change; +//! bringing existing coins into line means reclaiming and re-creating them, a round trip and a fee. +//! Nothing here reclaims on a config change — spending money in response to a text edit is not a +//! behaviour an operator asked for. + +/// The operator-set list of URLs this node advertises. Entries are separated by commas or +/// whitespace, so both `a,b` and a shell-quoted `"a b"` work. +/// +/// IPv6 entries SHOULD be listed first (CLAUDE.md §5.2), but the order is the operator's and this +/// module publishes it verbatim. +pub const ADVERTISE_URLS_ENV: &str = "DIG_MIRROR_ADVERTISE_URLS"; + +/// Why one entry was not advertised. +/// +/// Named rather than folded into a bare skip so the warning can say which mistake was made — the +/// two are produced by very different operator errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rejection { + /// Not an absolute URL with a scheme and a host. A bare `example.com` lands here: a memo entry + /// with no scheme tells a fetcher nothing about how to reach it. + NotAbsolute, + /// A host that can only ever mean *this machine* — loopback, the unspecified address, + /// link-local, `localhost`, or the `dig.local` alias. Publishing one advertises an address every + /// reader resolves to themselves, which is the exact mistake of copying the node's own local + /// address into the slot. + ThisMachineOnly, +} + +/// What [`parse_advertised_urls`] made of the operator's value. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Advertised { + /// The URLs to publish, in the operator's own order, duplicates removed. + /// + /// Deliberately NOT reordered. §5.2 prefers IPv6 first and this module's documentation says so, + /// but these entries are advisory fetch hints belonging to the operator; silently sorting them + /// would misstate a preference this node does not hold. + pub accepted: Vec, + /// Entries that will not be published, each with the reason, for the operator-facing warning. + pub rejected: Vec<(String, Rejection)>, +} + +impl Advertised { + /// Whether this node can advertise at all. `false` is the honest default, not an error. + pub fn can_advertise(&self) -> bool { + !self.accepted.is_empty() + } +} + +/// Reads the operator's advertised-URL list from the environment. +pub fn advertised_urls_from_env() -> Advertised { + parse_advertised_urls(&std::env::var(ADVERTISE_URLS_ENV).unwrap_or_default()) +} + +/// Turns one operator-set string into the advertisement's URL list. +/// +/// # What is deliberately NOT rejected +/// +/// **Any scheme is accepted.** `dig-mirror-coin` imposes no scheme rule — its reader treats URLs as +/// advisory and its SPEC constrains only that at least one exists — so refusing anything but +/// `http(s)` would be a stricter rule than anything shipped, and would pre-emptively break a +/// `dig://` form the ecosystem may well want. +/// +/// **A private or LAN address is accepted.** An operator running a LAN-only deployment has made a +/// real choice, and refusing it would stop them bonding at all; accepting it risks only their own +/// stake. Rejection is reserved for hosts that cannot mean anywhere but this machine, where there is +/// no legitimate reading at all. +pub fn parse_advertised_urls(raw: &str) -> Advertised { + let mut out = Advertised::default(); + + for entry in raw.split([',', ' ', '\t', '\n', '\r']) { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + + match classify(entry) { + Some(reason) => out.rejected.push((entry.to_string(), reason)), + None => { + if !out.accepted.iter().any(|url| url == entry) { + out.accepted.push(entry.to_string()); + } + } + } + } + + out +} + +/// `None` when the entry is publishable; otherwise why it is not. +fn classify(entry: &str) -> Option { + let Ok(parsed) = url::Url::parse(entry) else { + return Some(Rejection::NotAbsolute); + }; + let Some(host) = parsed.host() else { + return Some(Rejection::NotAbsolute); + }; + + let this_machine_only = match host { + url::Host::Domain(name) => { + let name = name.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") || name == crate::config::DIG_LOCAL_HOST + } + url::Host::Ipv4(ip) => ip.is_loopback() || ip.is_unspecified() || ip.is_link_local(), + // `is_unicast_link_local` is unstable, so the `fe80::/10` prefix is tested directly. + url::Host::Ipv6(ip) => { + ip.is_loopback() || ip.is_unspecified() || (ip.segments()[0] & 0xffc0) == 0xfe80 + } + }; + + this_machine_only.then_some(Rejection::ThisMachineOnly) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The refusal that exists today survives an unset value: no URL means no advertisement, which + /// is what makes `create` decline rather than publish somewhere unreachable. + #[test] + fn an_unset_value_advertises_nothing() { + for raw in ["", " ", ",, ,"] { + let got = parse_advertised_urls(raw); + assert!(!got.can_advertise(), "{raw:?} must not advertise"); + assert!(got.accepted.is_empty(), "{raw:?}"); + } + } + + /// A this-machine entry is dropped while a genuinely public sibling in the SAME list survives. + /// + /// The two-kind fixture is the point: a test asserting only that the result is empty would be + /// satisfied identically by a blanket refusal of every entry, and could not tell a targeted + /// rejection from one that throws the good URL away with the bad one. + #[test] + fn a_this_machine_host_is_dropped_and_its_public_sibling_survives() { + let got = parse_advertised_urls( + "http://127.0.0.1:4161/, https://mirror.example.net/, http://localhost:4161/, \ + http://[::1]:4161/, http://dig.local/, http://169.254.10.4/", + ); + + assert_eq!(got.accepted, vec!["https://mirror.example.net/".to_string()]); + assert_eq!(got.rejected.len(), 5, "{:?}", got.rejected); + assert!(got + .rejected + .iter() + .all(|(_, why)| *why == Rejection::ThisMachineOnly)); + } + + /// A LAN address is published, not refused — an operator on a private deployment has made a + /// real choice and risks only their own stake. + /// + /// Paired with the case above, this is what separates "reject what can only mean this machine" + /// from the nearest wrong implementation, "reject anything not globally routable". + #[test] + fn a_private_lan_address_is_accepted() { + let got = parse_advertised_urls("http://10.0.0.5:4161/ http://192.168.1.9:4161/"); + assert_eq!(got.accepted.len(), 2, "{got:?}"); + assert!(got.rejected.is_empty(), "{:?}", got.rejected); + } + + /// No scheme allowlist. `dig-mirror-coin` imposes none, so neither does this. + #[test] + fn any_scheme_is_accepted() { + let got = parse_advertised_urls("dig://node.example/ https://node.example/"); + assert_eq!(got.accepted.len(), 2, "{got:?}"); + } + + /// A schemeless entry names no way to reach anything, so it is not published. + #[test] + fn a_schemeless_entry_is_refused() { + let got = parse_advertised_urls("mirror.example.net"); + assert!(got.accepted.is_empty(), "{got:?}"); + assert_eq!( + got.rejected, + vec![("mirror.example.net".to_string(), Rejection::NotAbsolute)] + ); + } + + /// The operator's order is published verbatim. Asserting BOTH orders is what proves no sort is + /// applied: a single IPv6-first fixture is satisfied by an implementation that sorts IPv6 first. + #[test] + fn the_operators_order_is_preserved_in_both_directions() { + let v6 = "https://[2001:db8::1]/".to_string(); + let v4 = "https://198.51.100.7/".to_string(); + + assert_eq!( + parse_advertised_urls(&format!("{v6} {v4}")).accepted, + vec![v6.clone(), v4.clone()] + ); + assert_eq!( + parse_advertised_urls(&format!("{v4} {v6}")).accepted, + vec![v4, v6] + ); + } + + /// The memo layout carries many URLs, so several entries is the designed case; an exact + /// duplicate is published once, because two identical memo entries advertise nothing extra. + #[test] + fn an_exact_duplicate_is_published_once() { + let got = + parse_advertised_urls("https://a.example/, https://a.example/, https://b.example/"); + assert_eq!( + got.accepted, + vec!["https://a.example/".to_string(), "https://b.example/".to_string()] + ); + } +} From 51bbba0a03ae316612d5da18377906a4defc6e22 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 09:07:17 -0700 Subject: [PATCH 3/8] style(mirror): rustfmt the advertised-URL module (#426) Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/advertise.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-service/src/mirror/advertise.rs b/crates/dig-node-service/src/mirror/advertise.rs index 4c70d47b..a86cdc69 100644 --- a/crates/dig-node-service/src/mirror/advertise.rs +++ b/crates/dig-node-service/src/mirror/advertise.rs @@ -120,7 +120,9 @@ fn classify(entry: &str) -> Option { let this_machine_only = match host { url::Host::Domain(name) => { let name = name.trim_end_matches('.').to_ascii_lowercase(); - name == "localhost" || name.ends_with(".localhost") || name == crate::config::DIG_LOCAL_HOST + name == "localhost" + || name.ends_with(".localhost") + || name == crate::config::DIG_LOCAL_HOST } url::Host::Ipv4(ip) => ip.is_loopback() || ip.is_unspecified() || ip.is_link_local(), // `is_unicast_link_local` is unstable, so the `fe80::/10` prefix is tested directly. @@ -159,7 +161,10 @@ mod tests { http://[::1]:4161/, http://dig.local/, http://169.254.10.4/", ); - assert_eq!(got.accepted, vec!["https://mirror.example.net/".to_string()]); + assert_eq!( + got.accepted, + vec!["https://mirror.example.net/".to_string()] + ); assert_eq!(got.rejected.len(), 5, "{:?}", got.rejected); assert!(got .rejected @@ -222,7 +227,10 @@ mod tests { parse_advertised_urls("https://a.example/, https://a.example/, https://b.example/"); assert_eq!( got.accepted, - vec!["https://a.example/".to_string(), "https://b.example/".to_string()] + vec![ + "https://a.example/".to_string(), + "https://b.example/".to_string() + ] ); } } From 6bc01ed98611b6d45ef0ce1bb4fb2cbff9c5f254 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:09:47 -0700 Subject: [PATCH 4/8] feat(mirror): publish the operator's advertised URLs on mirror creates (#426) The parser shipped without a consumer: `spawn_mirror_passes` handed `NodeMirrorEffects` an empty URL list, so every create refused by name and no mirror coin could ever be made. The scheduler now reads the operator's list once at bring-up through `advertise::configured_urls`, which also reports each rejected entry with the reason it was dropped, and passes it to every pass. An empty answer keeps its meaning: the create still refuses before any chain read and no spend is attempted, which is the money-safe default SPEC.md 25.10 requires. SPEC.md 25.10's PARTIALLY SATISFIED admonition is removed in the same commit, because the wiring is what made it false. Co-Authored-By: Claude --- SPEC.md | 4 - .../dig-node-service/src/mirror/advertise.rs | 45 +++ crates/dig-node-service/src/server.rs | 18 +- .../tests/mirror_advertised_urls.rs | 305 ++++++++++++++++++ 4 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 crates/dig-node-service/tests/mirror_advertised_urls.rs diff --git a/SPEC.md b/SPEC.md index 1ef6d087..334f174d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8359,10 +8359,6 @@ The surface MUST hold four properties, each of which is a money statement: ### 25.10. What the node advertises, and why it is configured -> **PARTIALLY SATISFIED at this head.** The operator surface and its parsing exist -> (`mirror/advertise.rs`); nothing reads them yet, so no advertisement carries a URL and no coin is -> created. Tracked as . - A mirror coin publishes, in its memos, the URLs its store can be fetched from. `dig-mirror-coin` requires at least one and imposes no other rule on them: they are advisory fetch hints, and the crate's reader accepts any UTF-8 entry. This node therefore decides for itself what it is honest to diff --git a/crates/dig-node-service/src/mirror/advertise.rs b/crates/dig-node-service/src/mirror/advertise.rs index a86cdc69..a412dd2e 100644 --- a/crates/dig-node-service/src/mirror/advertise.rs +++ b/crates/dig-node-service/src/mirror/advertise.rs @@ -73,6 +73,51 @@ pub fn advertised_urls_from_env() -> Advertised { parse_advertised_urls(&std::env::var(ADVERTISE_URLS_ENV).unwrap_or_default()) } +/// The URLs this node will publish, with every rejected entry reported to the operator. +/// +/// This is the whole operator surface as the mirror scheduler consumes it: one call, at bring-up, +/// yielding the list `create` advertises. An empty answer is the honest default rather than an +/// error — `NodeMirrorEffects::create` refuses by name on it, before any chain read, so a node with +/// nothing to advertise stakes nothing. +/// +/// The warnings are emitted HERE rather than at the call site because this is the only place that +/// knows WHY an entry was dropped; a caller handed a shortened list could only report that some +/// entry was missing, which is not something an operator can act on. +pub fn configured_urls() -> Vec { + let advertised = advertised_urls_from_env(); + + for (entry, why) in &advertised.rejected { + let reason = match why { + Rejection::NotAbsolute => { + "it is not an absolute URL with a scheme and a host, so it names no way to reach anything" + } + Rejection::ThisMachineOnly => { + "its host can only mean this machine, so every reader would resolve it to themselves" + } + }; + tracing::warn!( + target: "mirror", + entry = %entry, + "{ADVERTISE_URLS_ENV} entry is not advertised: {reason}" + ); + } + + if advertised.can_advertise() { + tracing::info!( + target: "mirror", + urls = ?advertised.accepted, + "advertising this node's stores at the operator-configured URLs, in the configured order" + ); + } else { + tracing::info!( + target: "mirror", + "no {ADVERTISE_URLS_ENV} entry is publishable, so this node advertises nothing and creates no mirror coin (SPEC.md 25.10)" + ); + } + + advertised.accepted +} + /// Turns one operator-set string into the advertisement's URL list. /// /// # What is deliberately NOT rejected diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 5f974250..8e7670d3 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2732,6 +2732,12 @@ fn spawn_mirror_passes( ), } + // Read ONCE, for the life of the task, beside the wallet above. The value is an operator + // configuration rather than an observation, and a coin's URLs are fixed at create for the + // whole epoch — so re-reading it per pass would buy nothing and would let the list a + // warning was emitted about drift from the list actually published. + let advertised_urls = crate::mirror::advertise::configured_urls(); + let journal = lifecycle::journal(); let mut presence = crate::mirror::presence::PresenceTracker::new(); @@ -2784,12 +2790,12 @@ fn spawn_mirror_passes( capsules, dig_balance, committed, - // EMPTY, deliberately. A mirror advertises where its store can be - // fetched from, and this node has no configured public name to - // advertise — so `create` refuses by name rather than publishing an - // advertisement nobody can act on. That is an advertisement gap, not a - // funding one (dig-node#426); the selector behind it is live. - Vec::new(), + // The operator's own list (SPEC.md 25.10, dig-node#426), read ONCE + // at bring-up above. Empty when nothing is configured or nothing + // configured is publishable, and `create` then refuses by name before + // any chain read rather than staking collateral on an advertisement + // nobody can act on. + advertised_urls.clone(), &source, owner_puzzle_hash, signer_ref, diff --git a/crates/dig-node-service/tests/mirror_advertised_urls.rs b/crates/dig-node-service/tests/mirror_advertised_urls.rs new file mode 100644 index 00000000..f8672efa --- /dev/null +++ b/crates/dig-node-service/tests/mirror_advertised_urls.rs @@ -0,0 +1,305 @@ +//! **The operator's advertised URLs reach the coin** (`SPEC.md` §25.10, dig-node#426). +//! +//! A mirror coin publishes where its store can be fetched from, and those URLs are fixed at create +//! for the whole epoch. Until dig-node#426 the node parsed the operator's value and then handed +//! `create` an EMPTY list, so every create refused and no coin was ever made. This file drives the +//! real composition the scheduler performs — environment → [`configured_urls`] → the real +//! `NodeMirrorEffects::create` → a signed bundle — and reads the answer off the broadcast bundle. +//! +//! # Why the assertion is on the BUNDLE, not on the list +//! +//! Asserting that `configured_urls()` returns the right strings would pass identically while the +//! scheduler kept passing `Vec::new()` beside it — the exact defect this work removes. The bundle +//! is the only artifact that can distinguish "parsed" from "published", because it is what a +//! stranger eventually reads. +//! +//! # The fixture keeps a truthful control +//! +//! The configured value mixes a this-machine entry among two publishable ones. A fixture of only +//! good entries cannot see a filter that drops too much, and a fixture of only bad entries cannot +//! see one that drops too little; varying one entry against two honest survivors sees both. + +mod support; + +use std::collections::HashSet; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; +use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; +use dig_node_service::mirror::advertise::{configured_urls, ADVERTISE_URLS_ENV}; +use dig_node_service::mirror::lifecycle::NodeMirrorEffects; +use dig_node_service::mirror::plan::Bond; +use dig_node_service::mirror::runner::MirrorEffects; +use dig_node_service::mirror::signer::MirrorSigner; +use dig_node_service::spend_audit::{SpendJournal, SpendLog}; +use dig_wallet::autoseed::WalletPaths; +use dig_wallet::operator_wallet::OperatorWallet; +use dig_wallet::sage::spend::MockBroadcaster; +use support::{ordinary_dig_coins, Wallet}; + +/// One bond's margined collateral, in $DIG **base units** (1 DIG = 1_000). +const PER_COIN: u64 = 40_000; + +/// The epoch a create is made for. Any value; nothing here asserts about it. +const EPOCH: i64 = 42; + +/// Serialises the two probes: both write the SAME process-wide environment variable, and cargo runs +/// the tests in this binary on parallel threads. Without this, one probe reads the other's value and +/// the failure looks like a defect in the code under test. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Runs `body` with the operator's advertised-URL value set, restoring the previous value after. +fn with_advertise_env(value: &str, body: impl FnOnce() -> T) -> T { + let _guard = env_lock(); + let previous = std::env::var(ADVERTISE_URLS_ENV).ok(); + std::env::set_var(ADVERTISE_URLS_ENV, value); + let out = body(); + match previous { + Some(prior) => std::env::set_var(ADVERTISE_URLS_ENV, prior), + None => std::env::remove_var(ADVERTISE_URLS_ENV), + } + out +} + +/// A chain holding whatever the test put on it — and nothing else. +#[derive(Default)] +struct Chain { + by_puzzle_hash: std::collections::HashMap>, + spends: std::collections::HashMap, +} + +impl Chain { + fn fund(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) { + let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); + self.spends.insert(spend.coin.coin_id(), spend); + for coin in coins { + self.by_puzzle_hash + .entry(coin.puzzle_hash) + .or_default() + .push(CoinRecord { + coin, + confirmed_height: Some(100), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }); + } + } +} + +impl ChainSource for Chain { + type Error = ChainSourceError; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(self + .by_puzzle_hash + .get(&puzzle_hash) + .cloned() + .unwrap_or_default()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.spends.get(&coin_id).cloned()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(Some(1_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(Some(1_700_000_000)) + } +} + +/// A REAL operator wallet in a temp layout, and the fixture address its coins land on. +/// +/// `MirrorSigner::sign` refuses any bundle whose owner is not its own wallet, so a create funded +/// from another key's coins would be refused for a reason unrelated to the property under test. +fn operator(dir: &std::path::Path) -> (MirrorSigner, Wallet) { + let paths = WalletPaths::resolve(dir.join("seed")); + dig_node_service::wallet_bootstrap::ensure_wallet_seed_at(&paths) + .expect("the autoseed bootstrap yields a state"); + let wallet = OperatorWallet::open(&paths, dig_constants::DIG_MAINNET.genesis_challenge()) + .expect("a wallet was just created, so it opens"); + let signer = MirrorSigner::new(wallet); + let address = Wallet { + public_key: signer.synthetic_key(), + puzzle_hash: signer.owner_puzzle_hash(), + }; + (signer, address) +} + +/// The solution bytes of every broadcast bundle, one entry per bundle. +/// +/// A memo is a plain byte atom inside the spend's SOLUTION, so a URL that was published appears in +/// these bytes verbatim and one that was filtered does not. Reading the wire form rather than any +/// reported value is what makes this an observation of the advertisement itself. +fn broadcast_bytes(broadcaster: &MockBroadcaster) -> Vec> { + let sent: Vec = broadcaster.sent.lock().expect("not poisoned").clone(); + sent.iter() + .map(|bundle| { + bundle + .coin_spends + .iter() + .flat_map(|cs| cs.solution.as_ref().to_vec()) + .collect() + }) + .collect() +} + +/// Where `needle` first appears in `haystack`, if it does. +fn index_of(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// A bond over two distinct 64-hex ids. +fn bond(store: u8, root: u8) -> Bond { + Bond::new(hex::encode([store; 32]), hex::encode([root; 32])) +} + +/// The whole composition, end to end: the operator's value becomes the coin's advertisement, in the +/// operator's own order, with the this-machine entry dropped and its honest siblings kept. +/// +/// The order assertion is made on the bundle's own bytes, so an implementation that sorted the list +/// — which §25.10 forbids, because the order is the operator's statement of preference — fails here +/// rather than passing on a fixture that happens to be sorted already. +#[test] +fn the_configured_urls_reach_the_coin_in_the_operators_order() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (signer, address) = operator(dir.path()); + + let mut chain = Chain::default(); + chain.fund(&address, &[PER_COIN], 0x11); + + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log); + let broadcaster = MockBroadcaster::default(); + + // Owned OUTSIDE the runtime: `sign_and_broadcast` drives the broadcast with + // `Handle::block_on`, which panics when called from a thread already inside that runtime. + let runtime = tokio::runtime::Runtime::new().expect("a tokio runtime"); + + // IPv6 second on purpose. §5.2 recommends listing IPv6 first, and the node publishes the + // operator's order regardless — a fixture already in the recommended order could not tell the + // two apart. + let first = "https://mirror-b.example/dig"; + let second = "https://[2001:db8::1]/dig"; + let urls = with_advertise_env( + &format!("{first}, http://127.0.0.1:4161/, {second}"), + configured_urls, + ); + + let effects = NodeMirrorEffects::new( + Vec::new(), + Ok(PER_COIN), + Ok(HashSet::new()), + urls, + &chain, + signer.owner_puzzle_hash(), + Some(&signer), + &journal, + Some(&broadcaster), + runtime.handle().clone(), + ); + + effects + .create(&bond(0xA1, 0xC3), EPOCH, PER_COIN) + .expect("a configured advertisement and a funding coin are both present"); + + let bundles = broadcast_bytes(&broadcaster); + assert_eq!(bundles.len(), 1, "the create must have reached the mempool"); + let wire = &bundles[0]; + + let at_first = index_of(wire, first.as_bytes()) + .unwrap_or_else(|| panic!("{first} was configured but does not appear in the coin")); + let at_second = index_of(wire, second.as_bytes()) + .unwrap_or_else(|| panic!("{second} was configured but does not appear in the coin")); + assert!( + at_first < at_second, + "the coin reordered the operator's list: {first} must precede {second}" + ); + assert!( + index_of(wire, b"127.0.0.1").is_none(), + "a loopback entry can only mean this machine, so it must never be advertised" + ); +} + +/// A value whose every entry is rejected advertises nothing, refuses, and spends NOTHING. +/// +/// This is the money-safe default and the assertion most worth having: the refusal is what stops +/// collateral being locked against a claim no stranger can act on, and it must be reached before +/// any coin is selected — so the emptiness of the broadcaster is the load-bearing half, not the +/// error itself. +#[test] +fn an_all_rejected_value_refuses_and_spends_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (signer, address) = operator(dir.path()); + + let mut chain = Chain::default(); + // Funded deliberately: a refusal on an EMPTY wallet would be indistinguishable from a funding + // refusal, and would assert nothing about the advertisement. + chain.fund(&address, &[PER_COIN], 0x22); + + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log); + let broadcaster = MockBroadcaster::default(); + let runtime = tokio::runtime::Runtime::new().expect("a tokio runtime"); + + let urls = with_advertise_env( + "http://localhost:4161/, mirror.example.net", + configured_urls, + ); + assert!( + urls.is_empty(), + "every entry names this machine or no scheme, so none may be published: {urls:?}" + ); + + let effects = NodeMirrorEffects::new( + Vec::new(), + Ok(PER_COIN), + Ok(HashSet::new()), + urls, + &chain, + signer.owner_puzzle_hash(), + Some(&signer), + &journal, + Some(&broadcaster), + runtime.handle().clone(), + ); + + let refused = effects.create(&bond(0xB2, 0xD4), EPOCH, PER_COIN); + assert!( + refused.is_err(), + "a mirror with nowhere to fetch from is not a mirror, so the create must refuse" + ); + assert!( + broadcast_bytes(&broadcaster).is_empty(), + "the refusal must precede coin selection: no spend may be attempted" + ); +} From 7b01b74b28eff70183503b1a321f7b211a054327 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:15:01 -0700 Subject: [PATCH 5/8] test(mirror): pin the advertised-URL refusal to its PLACEMENT, and bump to 0.176.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal probe asserted only that no bundle was broadcast, which `dig-mirror-coin`'s own empty-URL refusal satisfies identically — so a guard moved to after coin selection would have kept the test green while reserving a funding coin for a create that can never happen, starving the next bond in the same pass. The fixture chain now counts its address lookups, and the probe asserts zero: a create that never reads an address is the only observation that separates the two placements. Verified by mutation: neutering the guard leaves the outcome assertions green and fails this one. Co-Authored-By: Claude --- .../tests/mirror_advertised_urls.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/tests/mirror_advertised_urls.rs b/crates/dig-node-service/tests/mirror_advertised_urls.rs index f8672efa..0b3fa9f8 100644 --- a/crates/dig-node-service/tests/mirror_advertised_urls.rs +++ b/crates/dig-node-service/tests/mirror_advertised_urls.rs @@ -67,10 +67,15 @@ fn with_advertise_env(value: &str, body: impl FnOnce() -> T) -> T { } /// A chain holding whatever the test put on it — and nothing else. +/// +/// It COUNTS its address lookups, because that is the only externally visible trace coin selection +/// leaves when no spend follows. Without it, the refusal probe below could not tell a guard that +/// runs before selection from one that runs after: both broadcast nothing. #[derive(Default)] struct Chain { by_puzzle_hash: std::collections::HashMap>, spends: std::collections::HashMap, + address_lookups: std::sync::atomic::AtomicUsize, } impl Chain { @@ -104,6 +109,8 @@ impl ChainSource for Chain { puzzle_hash: Bytes32, _include_spent: bool, ) -> Result, Self::Error> { + self.address_lookups + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(self .by_puzzle_hash .get(&puzzle_hash) @@ -300,6 +307,18 @@ fn an_all_rejected_value_refuses_and_spends_nothing() { ); assert!( broadcast_bytes(&broadcaster).is_empty(), - "the refusal must precede coin selection: no spend may be attempted" + "no spend may be attempted for an advertisement no stranger could act on" + ); + // The PLACEMENT, which the two assertions above cannot see. `dig-mirror-coin` also refuses an + // empty URL list, so a guard moved to after coin selection would broadcast nothing and return + // an error exactly as this one does — while having reserved a funding coin for a create that + // can never happen, starving the next bond in the same pass. A create that never reads an + // address is the only observation that separates the two. + assert_eq!( + chain + .address_lookups + .load(std::sync::atomic::Ordering::SeqCst), + 0, + "the refusal must be reached before any chain read, so no coin is selected or reserved" ); } From fcec66bacaf324910154587f355c5bcb289fcea6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:41:59 -0700 Subject: [PATCH 6/8] test(mirror): derive the advertised-URL fixture salts instead of spelling them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ordinary_dig_coins` seeds a grandparent with `[salt; 32]`, so a byte literal reads to CodeQL as a hard-coded cryptographic value — the same finding dig-node#917 and #950 already produced, and which the sibling reservation probe already avoids this way. Fixed at the root rather than dismissed: the salts are now derived from a domain string, so they stay deterministic and distinct per fixture while ceasing to be literals. Co-Authored-By: Claude --- .../tests/mirror_advertised_urls.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/tests/mirror_advertised_urls.rs b/crates/dig-node-service/tests/mirror_advertised_urls.rs index 0b3fa9f8..398aba8e 100644 --- a/crates/dig-node-service/tests/mirror_advertised_urls.rs +++ b/crates/dig-node-service/tests/mirror_advertised_urls.rs @@ -25,6 +25,7 @@ use std::collections::HashSet; use std::sync::{Mutex, MutexGuard, OnceLock}; use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; +use chia_sha2::Sha256; use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; use dig_node_service::mirror::advertise::{configured_urls, ADVERTISE_URLS_ENV}; use dig_node_service::mirror::lifecycle::NodeMirrorEffects; @@ -66,6 +67,17 @@ fn with_advertise_env(value: &str, body: impl FnOnce() -> T) -> T { out } +/// A fixture discriminator, DERIVED rather than spelled. +/// +/// `ordinary_dig_coins` seeds a grandparent with `[salt; 32]`, so a byte literal reads to CodeQL as +/// a hard-coded cryptographic value (dig-node#917, #950). Deterministic, so a failure reproduces; +/// distinct per `step`, so two fixture coins cannot collapse onto one id. +fn salt(step: u8) -> u8 { + let mut hasher = Sha256::new(); + hasher.update(b"dig-node mirror_advertised_urls fixture"); + hasher.finalize()[0].wrapping_add(step) +} + /// A chain holding whatever the test put on it — and nothing else. /// /// It COUNTS its address lookups, because that is the only externally visible trace coin selection @@ -202,7 +214,7 @@ fn the_configured_urls_reach_the_coin_in_the_operators_order() { let (signer, address) = operator(dir.path()); let mut chain = Chain::default(); - chain.fund(&address, &[PER_COIN], 0x11); + chain.fund(&address, &[PER_COIN], salt(1)); let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); let journal = SpendJournal::new(log); @@ -271,7 +283,7 @@ fn an_all_rejected_value_refuses_and_spends_nothing() { let mut chain = Chain::default(); // Funded deliberately: a refusal on an EMPTY wallet would be indistinguishable from a funding // refusal, and would assert nothing about the advertisement. - chain.fund(&address, &[PER_COIN], 0x22); + chain.fund(&address, &[PER_COIN], salt(2)); let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); let journal = SpendJournal::new(log); From b3e96faef089a4c195c483dd1f71e50c166f2a8e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:55:00 -0700 Subject: [PATCH 7/8] chore(release): v0.177.0 main reached 0.176.0 with #432 while this branch was in flight, so the branch's own 0.176.0 no longer increments over its base. Rebased onto bd3d701 and re-bumped. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 292193db..37edf11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.176.0" +version = "0.177.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 56887f88..53a0f3bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.176.0" +version = "0.177.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From 970bff7a7e3ca144accbf363f934c400afaceb71 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 12:32:32 -0700 Subject: [PATCH 8/8] fix(mirror): judge an advertised host by the address it denotes, not its spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC.md 25.10 requires that an entry whose host can only mean this machine is never published, but `classify` read that rule off the host's SPELLING, so two whole classes reached a mirror coin's memos: * An IPv4-mapped or IPv4-compatible IPv6 address under any scheme. `Ipv6Addr::is_loopback` is true only of `::1`; the meaning of `[::ffff:127.0.0.1]` lives in its low 32 bits and nothing looked there. * A bare IPv4 literal under a NON-SPECIAL scheme. `dig://` is an intended input, and a non-special scheme takes the WHATWG opaque-host path, so `127.0.0.1` arrived as `Host::Domain` and the arm holding the entire loopback rule never ran. The second is the dangerous one: an operator who sets `DIG_MIRROR_ADVERTISE_URLS=dig://127.0.0.1:4161/` gets no warning and an INFO line saying the node is advertising, while `create` locks real $DIG against an address every reader resolves to itself — unrecoverable within the epoch, because a coin's URLs are fixed at create. The v4 rule now has ONE home, `is_this_machine_only_v4`, which all three paths that can yield a v4 address funnel through, so no two can drift. The v6 arm asks its own predicates BEFORE unwrapping an embedded v4, because `::1` unwraps to the ordinary global `0.0.0.1`. A private or LAN address stays publishable, however it is written: this closes "can only mean this machine", not "is not public". Co-Authored-By: Claude --- SPEC.md | 7 +- .../dig-node-service/src/mirror/advertise.rs | 116 ++++++++++++++++-- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/SPEC.md b/SPEC.md index 334f174d..4511e7c5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8374,8 +8374,11 @@ on a claim the node cannot keep, which this section penalises. The operator sets listed first, and the node publishes the operator's order verbatim rather than sorting it. * An entry MUST be an absolute URL with a scheme and a host. No scheme allowlist is imposed. * An entry whose host can only mean this machine — loopback, the unspecified address, link-local, - `localhost`, or `dig.local` — MUST NOT be published. A private or LAN address MAY be published: - it is a deliberate operator choice and risks only that operator's own stake. + `localhost`, or `dig.local` — MUST NOT be published. The rule is on the address the host DENOTES, + not on how it is written: it MUST hold under every scheme, including a non-special one whose host + is opaque, and an IPv6 address that embeds an IPv4 one MUST be judged by the address it embeds. + A private or LAN address MAY be published: it is a deliberate operator choice and risks only that + operator's own stake. * A rejected entry is dropped with a warning naming the reason; the surviving entries are published. * When no entry survives, **the node advertises nothing and creates no mirror coin**. That refusal is the correct default: publishing a URL nobody can fetch from is worse than publishing none, diff --git a/crates/dig-node-service/src/mirror/advertise.rs b/crates/dig-node-service/src/mirror/advertise.rs index a412dd2e..9e409a7e 100644 --- a/crates/dig-node-service/src/mirror/advertise.rs +++ b/crates/dig-node-service/src/mirror/advertise.rs @@ -25,6 +25,8 @@ //! Nothing here reclaims on a config change — spending money in response to a text edit is not a //! behaviour an operator asked for. +use std::net::Ipv4Addr; + /// The operator-set list of URLs this node advertises. Entries are separated by commas or /// whitespace, so both `a,b` and a shell-quoted `"a b"` work. /// @@ -163,22 +165,54 @@ fn classify(entry: &str) -> Option { }; let this_machine_only = match host { - url::Host::Domain(name) => { - let name = name.trim_end_matches('.').to_ascii_lowercase(); - name == "localhost" - || name.ends_with(".localhost") - || name == crate::config::DIG_LOCAL_HOST - } - url::Host::Ipv4(ip) => ip.is_loopback() || ip.is_unspecified() || ip.is_link_local(), - // `is_unicast_link_local` is unstable, so the `fe80::/10` prefix is tested directly. + url::Host::Domain(name) => match name.parse::() { + // A NON-SPECIAL scheme — `dig://`, which this module accepts on purpose — takes the + // WHATWG *opaque-host* path, so its host is never IP-parsed and a bare IPv4 literal + // arrives here as a domain. Reading it back is what stops `dig://127.0.0.1/` reaching a + // coin; without it the entire rule below is unreachable for every non-special scheme. + Ok(ip) => is_this_machine_only_v4(ip), + Err(_) => { + let name = name.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" + || name.ends_with(".localhost") + || name == crate::config::DIG_LOCAL_HOST + } + }, + url::Host::Ipv4(ip) => is_this_machine_only_v4(ip), url::Host::Ipv6(ip) => { - ip.is_loopback() || ip.is_unspecified() || (ip.segments()[0] & 0xffc0) == 0xfe80 + // `is_unicast_link_local` is unstable, so the `fe80::/10` prefix is tested directly. + // + // The v6 predicates are asked FIRST and the embedded-v4 rule only after, which is + // load-bearing rather than stylistic: `::1` unwraps to `0.0.0.1`, an ordinary global + // v4 address, so asking the v4 rule first would ACCEPT the IPv6 loopback. + ip.is_loopback() + || ip.is_unspecified() + || (ip.segments()[0] & 0xffc0) == 0xfe80 + // `to_ipv4` rather than `to_ipv4_mapped`: it covers the deprecated IPv4-COMPATIBLE + // form (`::127.0.0.1`) as well as the mapped one, and both are written by hand as + // readily as the plain literal. A compatible address means exactly its embedded v4, + // so there is no case where the wider unwrap answers a question the narrower one + // should have declined. + || ip.to_ipv4().is_some_and(is_this_machine_only_v4) } }; this_machine_only.then_some(Rejection::ThisMachineOnly) } +/// Whether an IPv4 address can only ever mean the machine reading it. +/// +/// The single home of the v4 half of §25.10's rule. Three different paths can produce a v4 address — +/// the `Ipv4` host arm, a non-special scheme's opaque host, and an IPv4-mapped or -compatible IPv6 +/// address — and each of them funnels through here, so no two of them can drift into disagreeing +/// about what "this machine" means. +/// +/// Alternate IPv4 spellings (decimal, hex, octal, short form) need no handling: `url` normalises +/// them before `classify` ever sees the host. +fn is_this_machine_only_v4(ip: Ipv4Addr) -> bool { + ip.is_loopback() || ip.is_unspecified() || ip.is_link_local() +} + #[cfg(test)] mod tests { use super::*; @@ -264,6 +298,70 @@ mod tests { ); } + /// A bare IPv4 literal under a NON-SPECIAL scheme is still this machine. + /// + /// `dig://` is a tested, intended input (see `any_scheme_is_accepted`), and a non-special scheme + /// takes the WHATWG **opaque-host** path: the value arrives as `Host::Domain("127.0.0.1")`, so + /// the `Host::Ipv4` arm holding the loopback rule never runs. The scheme is named in the fixture + /// because the defect lives in the scheme, not in the host. + #[test] + fn a_non_special_scheme_does_not_smuggle_a_this_machine_host_past_the_opaque_host_path() { + let got = parse_advertised_urls("dig://127.0.0.1:4161/ dig://0.0.0.0/ dig://node.example/"); + + assert_eq!( + got.accepted, + vec!["dig://node.example/".to_string()], + "only the honest control may survive: {got:?}" + ); + assert_eq!(got.rejected.len(), 2, "{:?}", got.rejected); + assert!(got + .rejected + .iter() + .all(|(_, why)| *why == Rejection::ThisMachineOnly)); + } + + /// An IPv6 address that merely WRAPS an IPv4 one means whatever the embedded address means. + /// + /// `Ipv6Addr::is_loopback` is true only of `::1`, and the meaning of a mapped or compatible form + /// lives entirely in its low 32 bits — so a rule that reads only the v6 predicates sees + /// `[::ffff:127.0.0.1]` as an ordinary global address. The honest sibling in the same list is + /// what separates this from a blanket refusal of every bracketed host. + #[test] + fn an_ipv4_wrapped_in_ipv6_is_judged_by_the_address_it_embeds() { + let got = parse_advertised_urls( + "http://[::ffff:127.0.0.1]/, http://[::127.0.0.1]/, http://[::ffff:0.0.0.0]/, \ + http://[::ffff:169.254.10.4]/, http://[2001:db8::1]/", + ); + + assert_eq!( + got.accepted, + vec!["http://[2001:db8::1]/".to_string()], + "only the genuinely global entry may survive: {got:?}" + ); + assert_eq!(got.rejected.len(), 4, "{:?}", got.rejected); + assert!(got + .rejected + .iter() + .all(|(_, why)| *why == Rejection::ThisMachineOnly)); + } + + /// The widening does not overshoot. A LAN address stays publishable however it is written, and + /// `::1` keeps its own meaning rather than being read through its low 32 bits as `0.0.0.1`. + /// + /// Both halves are controls the widening could plausibly break: unwrapping an embedded v4 + /// unconditionally would turn `[::1]` into an accepted host, and applying the v4 rule to a + /// mapped LAN address would refuse a choice the operator is allowed to make. + #[test] + fn the_this_machine_rule_still_permits_a_lan_address_and_still_refuses_bare_ipv6_loopback() { + let permitted = parse_advertised_urls("http://192.168.1.10/ http://[::ffff:192.168.1.10]/"); + assert_eq!(permitted.accepted.len(), 2, "{permitted:?}"); + assert!(permitted.rejected.is_empty(), "{:?}", permitted.rejected); + + let refused = parse_advertised_urls("http://[::1]/ http://[::]/"); + assert!(refused.accepted.is_empty(), "{refused:?}"); + assert_eq!(refused.rejected.len(), 2, "{:?}", refused.rejected); + } + /// The memo layout carries many URLs, so several entries is the designed case; an exact /// duplicate is published once, because two identical memo entries advertise nothing extra. #[test]