diff --git a/Cargo.lock b/Cargo.lock index e54dfab0..39b309e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.208.0" +version = "0.209.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 9879ff8d..b65e9f89 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.208.0" +version = "0.209.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index 9618172a..5a070718 100644 --- a/SPEC.md +++ b/SPEC.md @@ -174,8 +174,8 @@ ARE `DIG_NODE_*`, full stop. | `DIG_NODE_ALLOW_REMOTE` | permit a non-loopback `DIG_NODE_HOST` bind | `false` | Truthy = `1`/`true`/`yes`/`on`; anything else (unset/blank/falsy/unrecognized) ⇒ the security-safe default **false**. When false, a non-loopback `DIG_NODE_HOST` is a fatal configuration error at startup (§3.2.1). Loopback overrides and the no-override default never require it. | | `DIG_RPC_UPSTREAM` | upstream DIG RPC base URL for passthrough + miss-proxy | *(unset — NO default upstream)* | Normalized (§3.3); highest precedence (§3.4). Unset ⇒ passthrough is OFF and an unimplemented method answers a local `-32601` (§5.4). A value naming THIS node is REFUSED (§3.4.1). | | `DIG_NODE_CACHE` | explicit on-disk `.dig` cache dir | *(unset)* | Blank/whitespace ⇒ unset. Unset ⇒ shared canonical default (§3.5). | -| `DIG_NODE_DIGLOCAL` | toggle for the bare `dig.local` listeners (`http://dig.local` on `127.0.0.2:80` AND, when a dig-cert leaf is present, `https://dig.local` on `127.0.0.2:443` — §4.1a) | `true` | Falsy = `0`/`false`/`no`/`off`; truthy = `1`/`true`/`yes`/`on`; case/whitespace-insensitive; unset or unrecognized ⇒ **default true**. | -| `DIG_NODE_PROFILE_SYNC` | operator kill switch for profile-body sync (opcodes 223/224/225, §22) | `true` | Falsy = `0`/`false`/`no`/`off`, case- and whitespace-insensitive; unset or unrecognized ⇒ **default true**. Off means the node neither fetches nor serves profile bodies; nothing else depends on it, so it is a clean degradation. | +| `DIG_NODE_DIGLOCAL` | toggle for the bare `dig.local` listeners (`http://dig.local` on `127.0.0.2:80` AND, when a dig-cert leaf is present, `https://dig.local` on `127.0.0.2:443` — §4.1a) | `true` | Off = `off`/`disabled`/`0`/`false`/`no`; on = `on`/`enabled`/`1`/`true`/`yes`; case/whitespace-insensitive. Unset or EMPTY ⇒ **default true**. An UNRECOGNIZED value ⇒ default true AND a warning naming the variable, the rejected value and the applied default (see **Capability-flag vocabulary and failure direction**). | +| `DIG_NODE_PROFILE_SYNC` | operator kill switch for profile-body sync (opcodes 223/224/225, §22) | `true` | Off = `off`/`disabled`/`0`/`false`/`no`, case- and whitespace-insensitive; unset, empty or unrecognized ⇒ **default true**. Off means the node neither fetches nor serves profile bodies; nothing else depends on it, so it is a clean degradation. | The default port is the UNCOMMON high port **`9778`** (not `80`/`8080`). Port 80 requires elevation on most OSes, and both `80` and `8080` are the collision-prone common-dev ports most likely already @@ -456,6 +456,38 @@ governs the `/health` `addr` field, the `status` output, the control-client's JS alias) the node MUST log a structured warning to stderr and continue serving localhost-only — it MUST NOT abort. Skipped entirely when `DIG_NODE_DIGLOCAL` is falsy. +### Capability-flag vocabulary and failure direction + +NORMATIVE. Complements **The shared off-token** above, which governs the isolation knobs; this governs the capability knobs. + +A **capability flag** is a `DIG_*` environment switch that enables or disables a node capability and +holds no list. `DIG_WALLET_ENABLE_CHAIN_SYNC`, `DIG_NODE_DIGLOCAL`, `DIG_HOLDINGS_INGEST`, +`DIG_NODE_STORE_MELT` and `DIG_NODE_PROFILE_SYNC` are capability flags. `DIG_BOOTSTRAP_PEERS`, `DIG_RELAY_URL` and +`DIG_PEER_NETWORK` are **isolation** flags and are governed by **The shared off-token** above instead. + +1. **One vocabulary.** Every capability flag MUST read the same off-tokens — `off`, `disabled`, `0`, + `false`, `no` — and the same on-tokens — `on`, `enabled`, `1`, `true`, `yes` — each trimmed and + compared case-insensitively. A flag that recognizes a token another flag rejects is non-conforming: + an operator who learns a word works on one switch will use it on the next. + +2. **An EMPTY value is ABSENT, not OFF.** A capability flag set to the empty string MUST take its + default. This differs deliberately from an isolation flag, where an empty value names the empty list + and MUST disable (see **The shared off-token**). A capability flag holds no list, and an empty value is what a shell + produces from an unset expansion. + +3. **Failure direction: fail in whichever direction cannot make a surface assert a falsehood.** An + unrecognized value MUST NOT be resolved in a direction that lets any surface state something untrue. + For a default-ON read path such as chain sync this means keeping the default: disabling it silently + stops the replica advancing, and a stale replica's zero balance is indistinguishable from an empty + wallet (§18.6). For an isolation flag the same principle requires the opposite resolution, because + a node that keeps dialling reports an isolation it does not have. + +4. **An unrecognized value MUST be disclosed.** The node MUST emit a warning naming the VARIABLE, the + REJECTED VALUE, and the DEFAULT it applied, and MUST state that the operator's setting had no + effect. A recognized value, including an absent or empty one, MUST NOT warn. Silence is what makes a + typo indistinguishable from a deliberate omission, and it is the residue that survives whichever + failure direction a flag takes. + The distinct loopback IP `.2` exists so the port-80 bind can never collide with an unrelated `localhost:80` service. The dig-installer writes the hosts entry `127.0.0.2 dig.local`; this listener is what makes the portless `http://dig.local` URL reach the node. No listener may @@ -4188,7 +4220,7 @@ Two cheaper signals MUST NOT be used, both having shipped and been found unsound a delete decision. **Operator kill switch.** Store-melt propagation MUST be disableable at runtime via -`DIG_NODE_STORE_MELT` (default ON; only an explicit `off`/`0`/`false`/`no` disables it), matching the +`DIG_NODE_STORE_MELT` (default ON; only an explicit off-token — `off`/`disabled`/`0`/`false`/`no` — disables it), matching the shape of `DIG_NODE_BACKFILL_ON_MISS`. This is the node's only path that irreversibly deletes content in response to chain state, and it propagates, so a fault is correlated across holders rather than isolated; an operator MUST be able to stop the deleting without downgrading the node. Disabling is diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index f2d69112..1189b014 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -115,6 +115,104 @@ pub use seams::key_mgmt::KeyManager; pub mod shared; pub mod subscription; +// ---- The operator's flag vocabulary, shared across crates (dig-node#459) --------------------- +// +// `peer::is_off_token` is the ONE reading of "the operator turned this off" for the three ISOLATION +// knobs (#282/#352). It is `pub(crate)`, so `dig-node-service`'s capability knobs could not reach it +// and each grew its own narrower copy — which is how `disabled` came to work on three switches and +// not on five. These two functions expose that vocabulary UP to the crate root rather than letting a +// second copy exist (CLAUDE.md §2.0: centralize rival implementations; Appendix B: expose it up). + +/// Is `value` the operator saying "turn this capability OFF"? +/// +/// The same tokens [`peer::is_off_token`] reads — `off`, `disabled`, `0`, `false`, `no`, trimmed and +/// case-insensitive — with **one deliberate subtraction: an EMPTY value is NOT an off.** +/// +/// # Why the empty rule is subtracted rather than inherited +/// +/// For an ISOLATION knob an empty value is a coherent answer: `DIG_BOOTSTRAP_PEERS=` means "no +/// bootstrap peers", and reading it as the compiled-in default made a node believed to be isolated +/// dial production infrastructure (dig-node#312). The variable holds a LIST, so "set to nothing" +/// names the empty list. +/// +/// A capability knob holds no list. `DIG_WALLET_ENABLE_CHAIN_SYNC=` is what a shell produces from +/// `export X="$UNSET_VAR"`, and reading that as OFF would stop the replica advancing — which +/// dig-node#416 records as indistinguishable from an empty wallet at the balance surface. Inheriting +/// the empty rule wholesale would therefore import a money lie through the vocabulary, having just +/// refused to import one through the failure direction. +/// +/// So: same tokens, different empty. Stated here because "adopt the shared vocabulary" reads like one +/// decision and is two. +#[must_use] +pub fn is_capability_off_token(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() && peer::is_off_token(trimmed) +} + +/// Is `value` the operator saying "turn this capability ON"? `1`, `true`, `yes`, `on`, `enabled`, +/// trimmed and case-insensitive. +/// +/// `enabled` is present as the mirror of `disabled`: an operator who learns one word works will try +/// its opposite, and a vocabulary that accepts only one direction of a pair is the same trap in +/// reverse. +#[must_use] +pub fn is_capability_on_token(value: &str) -> bool { + let v = value.trim(); + v.eq_ignore_ascii_case("1") + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") + || v.eq_ignore_ascii_case("enabled") +} + +/// What a capability flag's raw value means, when "apply the default" and "the operator typed +/// something we do not understand" must be told apart. +/// +/// The third variant is the point. Every one of these knobs previously collapsed `Unrecognised` into +/// the default and said nothing, so a typo and a deliberate omission produced identical behaviour and +/// identical silence — see [`describe_unrecognised_flag`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlagWord { + /// An off-token: the operator asked for this capability to stop. + Off, + /// An on-token: the operator asked for this capability to run. + On, + /// Unset, or set to an empty value — the operator expressed no preference. + Absent, + /// Set to something that is neither. The caller applies its default AND says so. + Unrecognised, +} + +/// Classify a capability flag's raw value. PURE — no process env, no logging — so a caller's policy +/// and its disclosure are testable separately from its wiring. +#[must_use] +pub fn classify_flag(raw: Option<&str>) -> FlagWord { + match raw { + None => FlagWord::Absent, + Some(v) if v.trim().is_empty() => FlagWord::Absent, + Some(v) if is_capability_off_token(v) => FlagWord::Off, + Some(v) if is_capability_on_token(v) => FlagWord::On, + Some(_) => FlagWord::Unrecognised, + } +} + +/// The line an operator sees when a flag's value was not understood. +/// +/// A `String` rather than a log call at the classification site, for the reason +/// `live_broadcast_disclosure` gives: the property under test is the TEXT. A test asserting only +/// "a warning was emitted" passes on a message that omits the value, the variable, or the default +/// actually applied — and a warning missing any of those three sends the operator looking in the +/// wrong place, which is the failure this whole ticket is about. +#[must_use] +pub fn describe_unrecognised_flag(var: &str, raw: &str, applied_default: bool) -> String { + let applied = if applied_default { "ON" } else { "OFF" }; + format!( + "{var} is set to {raw:?}, which is not a value this node understands. The default ({applied}) \ + is being used and your setting has had NO effect. Accepted: on/1/true/yes/enabled, or \ + off/0/false/no/disabled." + ) +} + // The one place the per-range verification contract of a `dig.fetchRange` frame is built (#1577). use seams::content::range_frame; // Serve-side observability vocabulary for the peer-facing read surface (#1595). diff --git a/crates/dig-node-core/src/seams/dig_peer/holdings.rs b/crates/dig-node-core/src/seams/dig_peer/holdings.rs index 5b3eea55..a64c3cb9 100644 --- a/crates/dig-node-core/src/seams/dig_peer/holdings.rs +++ b/crates/dig-node-core/src/seams/dig_peer/holdings.rs @@ -1067,10 +1067,11 @@ pub fn now_unix_secs() -> u64 { /// behaving exactly as it does today. The switch exists to let an operator SHED inbound work under a /// flood, so a typo must never silently disable discovery instead. fn ingest_enabled(raw: Option<&str>) -> bool { - !matches!( - raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" | "no" - ) + // The SHARED off-vocabulary (dig-node#459), not a fifth private copy: `disabled` now works here + // exactly as it does on `DIG_PEER_NETWORK`. An operator shedding load under a flood reaches for + // whichever word they last saw work, and a switch that ignores it looks like a switch that does + // not work. + !crate::is_capability_off_token(raw.unwrap_or_default()) } #[cfg(test)] diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index 54080cdd..800ac6c2 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -95,16 +95,16 @@ use crate::AnchoredRootResolver; /// having run — profiles simply stop syncing — so it is a clean degradation, not an outage. pub const PROFILE_SYNC_ENV: &str = "DIG_NODE_PROFILE_SYNC"; -/// Whether profile sync is enabled. Default ON; `0`/`false`/`off`/`no` (case-insensitive) disable it. +/// Whether profile sync is enabled. Default ON; an off-token (`off`/`disabled`/`0`/`false`/`no`, +/// trimmed and case-insensitive) disables it. +/// +/// Reads the SHARED capability vocabulary (dig-node#459). It was found while writing that ticket's +/// `SPEC.md` clause, which asserts that every capability flag reads one vocabulary — a sixth private +/// copy would have made the clause false in the commit that introduced it. The four knobs #459 names +/// were the four someone had listed, not the four that exist. #[must_use] pub fn profile_sync_enabled() -> bool { - match std::env::var(PROFILE_SYNC_ENV) { - Ok(v) => !matches!( - v.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" | "no" - ), - Err(_) => true, - } + !crate::is_capability_off_token(std::env::var(PROFILE_SYNC_ENV).unwrap_or_default().as_str()) } /// How long a recorded solicitation stays answerable. @@ -2043,7 +2043,7 @@ mod tests { let _guard = env_lock(); std::env::remove_var(PROFILE_SYNC_ENV); assert!(profile_sync_enabled(), "absent must mean ON"); - for off in ["0", "false", "OFF", "no"] { + for off in ["0", "false", "OFF", "no", "disabled", " Disabled "] { std::env::set_var(PROFILE_SYNC_ENV, off); assert!(!profile_sync_enabled(), "{off} must disable profile sync"); } diff --git a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs index d64216ef..cabefae0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs +++ b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs @@ -549,10 +549,11 @@ pub fn store_melt_enabled() -> bool { /// Pure core of [`store_melt_enabled`], so the policy is unit-tested without touching process-global /// env. Default ON; only an explicit falsy value disables it. fn resolve_store_melt_enabled(value: Option<&str>) -> bool { - !matches!( - value.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("off") | Some("0") | Some("false") | Some("no") - ) + // The SHARED off-vocabulary (dig-node#459). This knob stops the node's ONLY irreversible-delete + // path, so the cost of an off-token it does not recognise is content deleted by a node whose + // operator believed they had stopped it — the widest gap of the five, and the reason the + // vocabulary is centralized rather than restated. + !crate::is_capability_off_token(value.unwrap_or_default()) } /// The production [`MeltChain`] — [`confirm_melt_via_chain`] over the live coinset view, using the diff --git a/crates/dig-node-service/src/config.rs b/crates/dig-node-service/src/config.rs index 5ba4e023..b8e6ede3 100644 --- a/crates/dig-node-service/src/config.rs +++ b/crates/dig-node-service/src/config.rs @@ -355,16 +355,48 @@ impl Config { } } -/// Parse the `DIG_NODE_DIGLOCAL` toggle. Truthy (`1`/`true`/`yes`/`on`) ⇒ enable -/// the bare-dig.local listener; falsy (`0`/`false`/`no`/`off`) ⇒ disable; **unset -/// or unrecognised ⇒ the default `true`** (auto-attempt with graceful fallback). -/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable. +/// Parse the `DIG_NODE_DIGLOCAL` toggle. On-token ⇒ enable the bare-dig.local listener; off-token +/// ⇒ disable; **unset, empty, or unrecognised ⇒ the default `true`** (auto-attempt with graceful +/// fallback) — and an UNRECOGNISED value is WARNED about rather than silently swallowed (#459). +/// +/// Reads the shared vocabulary via [`dig_node_core::classify_flag`], so `disabled` and `enabled` +/// work here exactly as they do on the three isolation knobs. +/// +/// # Why an unrecognised value keeps the default instead of failing closed (#459) +/// +/// This flag binds `127.0.0.2:80`, `127.0.0.2:443` and `[::1]:443` — LOOPBACK only. It cannot reach +/// the network and cannot change what a remote party may do, so the isolation knobs' fail-closed +/// rule buys nothing here while a typo would cost the operator a local feature they asked for. +/// +/// The residue fail-open leaves is not the direction but the SILENCE, and that is what changed: the +/// node now says the value was not understood and names the default it applied. pub fn parse_dig_local_flag(raw: Option) -> bool { - match raw.as_deref().map(str::trim).map(str::to_ascii_lowercase) { - Some(ref v) if matches!(v.as_str(), "0" | "false" | "no" | "off") => false, - Some(ref v) if matches!(v.as_str(), "1" | "true" | "yes" | "on") => true, - // Unset, blank, or anything unrecognised → the default-on behaviour. - _ => true, + resolve_capability_flag("DIG_NODE_DIGLOCAL", raw.as_deref(), true) +} + +/// Apply a default-ON capability flag's policy and, when the value was not understood, SAY SO. +/// +/// One function for both knobs because they take the same answer for the same reason, and because +/// two copies of a disclosure rule is how the vocabulary diverged in the first place. +/// +/// The classification is pure ([`dig_node_core::classify_flag`]); only the disclosure is a side +/// effect, so a test can assert the decision and the emitted line separately. +fn resolve_capability_flag(var: &str, raw: Option<&str>, default: bool) -> bool { + match dig_node_core::classify_flag(raw) { + dig_node_core::FlagWord::Off => false, + dig_node_core::FlagWord::On => true, + dig_node_core::FlagWord::Absent => default, + dig_node_core::FlagWord::Unrecognised => { + tracing::warn!( + "{}", + dig_node_core::describe_unrecognised_flag( + var, + raw.unwrap_or_default().trim(), + default + ) + ); + default + } } } @@ -410,20 +442,31 @@ pub fn live_broadcast_disclosure() -> &'static str { To disable both, unset DIG_WALLET_ENABLE_LIVE_BROADCAST." } -/// Parse the `DIG_WALLET_ENABLE_CHAIN_SYNC` toggle (§18.6, #2501). Falsy -/// (`0`/`false`/`no`/`off`) ⇒ do NOT start the background chain-sync supervisor; **anything else -/// — including unset, blank, or unrecognised — ⇒ the default `true`**. Default-ON, unlike -/// [`parse_live_broadcast_flag`]: syncing reads the chain into the node's own replica and moves -/// no money, so the money-safe reasoning does not apply. Case/whitespace-insensitive. PURE so the -/// policy is unit-testable without process env. +/// Parse the `DIG_WALLET_ENABLE_CHAIN_SYNC` toggle (§18.6, #2501). Off-token ⇒ do NOT start the +/// background chain-sync supervisor; **unset, empty, or unrecognised ⇒ the default `true`**, with an +/// unrecognised value WARNED about rather than silently swallowed (#459). Default-ON, unlike +/// [`parse_live_broadcast_flag`]: syncing reads the chain into the node's own replica and moves no +/// money, so the money-safe reasoning does not apply. +/// +/// # Why this one does NOT fail closed, although it DOES reach the network (#459) +/// +/// #459 proposed the discriminator "can the flag's behaviour reach the network?", on the model of +/// #282/#352. This flag does — it dials chia peers and reads the chain — so that test says fail +/// closed. **Applied here it would produce a defect**, and the reason is worth keeping: +/// +/// Failing closed on an unrecognised value silently stops the replica advancing. dig-node#416 +/// records that a stale replica's zero balance is INDISTINGUISHABLE from an empty wallet at the +/// balance surface — so a typo would be converted into a surface asserting a falsehood about the +/// operator's money. Failing open converts the same typo into "the flag you set had no effect", +/// which is recoverable and, now, stated out loud. +/// +/// The generalisation that survives, and which the network-reach test was a proxy for: **fail in +/// whichever direction cannot make a surface assert a falsehood.** For an isolation knob that is +/// closed, because fail-open leaves a node dialling a network its operator asked it to leave. For a +/// default-ON read path it is open, because fail-closed manufactures a false zero. Same principle, +/// opposite outcome — which is why the proxy must not be applied mechanically. pub fn parse_chain_sync_flag(raw: Option) -> bool { - !matches!( - raw.as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("0" | "false" | "no" | "off") - ) + resolve_capability_flag("DIG_WALLET_ENABLE_CHAIN_SYNC", raw.as_deref(), true) } /// Parse the `DIG_NODE_HOST` override (#288): `Some(ip)` when the raw value is a @@ -1233,6 +1276,190 @@ mod tests { assert!(parse_dig_local_flag(Some("maybe".to_string()))); } + // ---- #459: failure direction and off-vocabulary, decided separately ----------------------- + // + // Every assertion below names the alternative it rules out. The ticket warns that the existing + // `peer_network_enabled` test passed under BOTH the old and new parsers because it listed only + // lowercase exact tokens and never a value the two disagree about — so a test here that only + // re-listed `on`/`off` would prove nothing about either decision. + + /// An in-memory sink for the disclosure assertions. + #[derive(Clone, Default)] + struct FlagLogCapture(std::sync::Arc>>); + + impl std::io::Write for FlagLogCapture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for FlagLogCapture { + type Writer = FlagLogCapture; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + /// Run `body` under a scoped capturing subscriber and return what it logged. + fn capture_flag_logs(body: impl FnOnce() -> T) -> (T, String) { + let buffer = FlagLogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(buffer.clone()) + .finish(); + let outcome = tracing::subscriber::with_default(subscriber, body); + let captured = buffer.0.lock().unwrap().clone(); + (outcome, String::from_utf8_lossy(&captured).into_owned()) + } + + /// **DECISION 1 (#459) — an unrecognised value keeps the default AND is said out loud.** + /// + /// This is the assertion that distinguishes the chosen answer from BOTH alternatives, which is + /// why the value and the log are asserted together rather than in two tests: + /// + /// | alternative | how it fails here | + /// |---|---| + /// | fail CLOSED on unrecognised | returns `false`; the value assertion fails | + /// | fail OPEN **silently** (shipped behaviour) | returns `true` but logs nothing; the disclosure assertion fails | + /// + /// `"fasle"` is a real typo of `false` — the case where the two candidate failure directions + /// give opposite answers — not a token neither implementation would accept. + #[test] + fn an_unrecognised_capability_flag_keeps_its_default_and_says_so() { + for (var, parse) in [ + ( + "DIG_WALLET_ENABLE_CHAIN_SYNC", + parse_chain_sync_flag as fn(Option) -> bool, + ), + ( + "DIG_NODE_DIGLOCAL", + parse_dig_local_flag as fn(Option) -> bool, + ), + ] { + let (enabled, logs) = capture_flag_logs(|| parse(Some("fasle".to_string()))); + + assert!( + enabled, + "{var}: a typo must not silently disable a default-ON capability — failing closed \ + here converts a typo into a stale replica, which dig-node#416 records as \ + indistinguishable from an empty wallet" + ); + assert!( + logs.contains(var), + "{var}: the disclosure must name the VARIABLE, or the operator cannot find it; \ + log was:\n{logs}" + ); + assert!( + logs.contains("fasle"), + "{var}: the disclosure must echo the REJECTED VALUE, or the operator cannot see \ + their typo; log was:\n{logs}" + ); + assert!( + logs.contains("NO effect"), + "{var}: the disclosure must say the setting did nothing — a line that merely \ + mentions the flag reads as confirmation that it was applied; log was:\n{logs}" + ); + } + } + + /// **The control for the test above.** A RECOGNISED value must produce no disclosure at all. + /// + /// Without this, warning unconditionally would satisfy every assertion above while burying the + /// real one in noise on every start-up — the standard way a disclosure stops being read. + #[test] + fn a_recognised_capability_flag_says_nothing() { + for raw in ["off", "on", "DISABLED", " enabled ", ""] { + let (_, logs) = capture_flag_logs(|| parse_chain_sync_flag(Some(raw.to_string()))); + assert!( + logs.is_empty(), + "{raw:?} is understood, so it must not warn; log was:\n{logs}" + ); + } + let (_, logs) = capture_flag_logs(|| parse_chain_sync_flag(None)); + assert!( + logs.is_empty(), + "an unset flag is not a mistake; log was:\n{logs}" + ); + } + + /// **DECISION 2 (#459) — the two flags adopt the shared off-TOKENS.** + /// + /// `disabled` and `enabled` are the whole content of this test: they are exactly the values the + /// old and new parsers disagree about. Under the shipped parsers `disabled` fell through to the + /// default and left the capability RUNNING — the same shape as `DIG_PEER_NETWORK=OFF` leaving + /// the peer network running, which is the defect #282/#352 exists to close. + /// + /// The lowercase exact tokens are re-listed only as a regression floor; on their own they would + /// pass under both implementations, which the ticket names as the trap. + #[test] + fn the_capability_flags_read_the_shared_off_vocabulary() { + for off in [ + "disabled", + "DISABLED", + " Disabled ", + "off", + "OFF", + "0", + "false", + "no", + ] { + assert!( + !parse_chain_sync_flag(Some(off.to_string())), + "{off:?} must stop chain sync" + ); + assert!( + !parse_dig_local_flag(Some(off.to_string())), + "{off:?} must disable dig.local" + ); + } + for on in ["enabled", "ENABLED", "on", "1", "true", "yes"] { + assert!(parse_chain_sync_flag(Some(on.to_string()))); + assert!(parse_dig_local_flag(Some(on.to_string()))); + } + } + + /// **DECISION 2, the SUBTRACTION — an empty value is NOT an off for a capability knob.** + /// + /// `peer::is_off_token` treats an explicitly-empty value as OFF, and that is correct for the + /// three isolation knobs: `DIG_BOOTSTRAP_PEERS=` names the empty LIST (dig-node#312). Adopting + /// the vocabulary wholesale would import that rule here, where the variable holds no list and an + /// empty value is what `export X="$UNSET_VAR"` produces — stopping chain sync, and arriving at + /// dig-node#416's false zero through the vocabulary having just been refused through the failure + /// direction. + /// + /// **Catches:** a future "simplification" that routes these knobs straight at `is_off_token`. + /// That change passes every other test in this file. + #[test] + fn an_empty_capability_flag_is_absent_not_off() { + assert!( + parse_chain_sync_flag(Some(String::new())), + "an empty value must not stop chain sync" + ); + assert!( + parse_chain_sync_flag(Some(" ".to_string())), + "whitespace is empty, and must not stop chain sync either" + ); + assert!(parse_dig_local_flag(Some(String::new()))); + + // Asserted at the shared helper too, because that is where the subtraction lives: the two + // vocabularies agree on every TOKEN and differ only here, deliberately. `is_off_token` + // (crate-private to dig-node-core) still answers `true` for an empty value, which is what + // keeps dig-node#312 intact for the isolation knobs. + assert!( + !dig_node_core::is_capability_off_token(""), + "the capability vocabulary must not inherit the isolation knobs' empty-is-off rule" + ); + assert!( + dig_node_core::is_capability_off_token("disabled"), + "it must still inherit every off TOKEN" + ); + } + #[test] fn parse_live_broadcast_flag_is_off_by_default_and_only_truthy_enables() { // Truthy enables real mainnet broadcast.