From 46059f543246ad890c8a5d44984785ccf31ee174 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 00:47:01 -0700 Subject: [PATCH 1/2] chore(version): anchor 0.252.30 for the lost-continuation sweep Lane anchor for DIG-Network/dig-node#526. Bumps the workspace version so the branch exists on the remote before any implementation work, per the push-early rule -- a session that dies mid-task must not lose state. 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 f8cfd326..10f4d821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.252.13" +version = "0.252.30" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ff4bf3ee..528a3de6 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.252.13" +version = "0.252.30" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From fe5c12389f1a875165b56ae58278b4d9101b1802 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 05:35:17 -0700 Subject: [PATCH 2/2] fix(messages): salvage lost-continuation repairs + source-scanning guard (WIP) Uncommitted work from the dead lane on #526, committed as-is so it survives; compile + guard-coverage verification follow. Co-Authored-By: Claude --- crates/dig-node-service/src/cli.rs | 6 +- .../src/continuation_guard.rs | 208 ++++++++++++++++++ crates/dig-node-service/src/control.rs | 5 +- crates/dig-node-service/src/lib.rs | 2 + crates/dig-node-service/src/logging.rs | 6 +- crates/dig-node-service/src/meta.rs | 12 +- crates/dig-node-service/src/pairing.rs | 25 ++- crates/dig-node-service/src/service.rs | 19 +- 8 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 crates/dig-node-service/src/continuation_guard.rs diff --git a/crates/dig-node-service/src/cli.rs b/crates/dig-node-service/src/cli.rs index e0421baf..11507122 100644 --- a/crates/dig-node-service/src/cli.rs +++ b/crates/dig-node-service/src/cli.rs @@ -252,7 +252,11 @@ mod tests { assert_eq!( code.name(), *diga_name, - "exit {} is `{}` here and `{}` in the dig-app gateway -- a shared number must carry the SAME meaning on both command lines, or a caller branching on it is reading two different failures as one", + concat!( + "exit {} is `{}` here and `{}` in the dig-app gateway -- a shared number ", + "must carry the SAME meaning on both command lines, or a caller branching ", + "on it is reading two different failures as one" + ), code.code(), code.name(), diga_name diff --git a/crates/dig-node-service/src/continuation_guard.rs b/crates/dig-node-service/src/continuation_guard.rs new file mode 100644 index 00000000..67a8eb19 --- /dev/null +++ b/crates/dig-node-service/src/continuation_guard.rs @@ -0,0 +1,208 @@ +//! Test-only guard against the "lost string continuation" defect class (dig-node#526). +//! +//! A Rust string literal continued with a trailing `\` renders correctly. When that +//! backslash is lost -- `cargo fmt` rejoining a wrapped literal, or a mechanical regex +//! repair -- the literal keeps the SOURCE's leading indentation, so the emitted text +//! carries a 14-22 space run in the middle of a sentence. It compiles, every other test +//! stays green, and the mangled and correct forms are indistinguishable in a normal +//! diff. The only witness is a person reading the emitted text -- so this scanner reads +//! it instead, on every build. +#![cfg(test)] + +use std::path::Path; + +/// Files under `mirror/` are owned by dig-node#501 (a separate open PR repairs six +/// sites of this same class there). Scanning them here would either duplicate that +/// fix or fail this branch on work this PR does not own. Delete this exclusion once +/// #501 merges. +const EXCLUDED_DIRS: &[&str] = &["mirror"]; + +/// `service.rs`'s `sc qc` parser test pins a byte-identical copy of real `sc.exe` +/// output; its fixed-width `LABEL : value` columns are deliberate alignment the +/// parser depends on, not a lost continuation. +const EXCLUDED_LINE_RANGES: &[(&str, u32, u32)] = &[("service.rs", 2045, 2056)]; + +/// A handful of CLI summary builders format a banner with `\n` plus hand-aligned +/// label columns (network_info.rs, spend_audit_cli.rs, pair.rs, peer_ping.rs, +/// peers.rs). Once a line has committed to that idiom -- it contains a literal +/// `\n` escape anywhere -- every space run on it is column alignment, not a torn +/// sentence, so the whole line is exempt. +const CLI_COLUMN_FILES: &[&str] = &[ + "network_info.rs", + "spend_audit_cli.rs", + "pair.rs", + "peer_ping.rs", + "peers.rs", +]; + +/// A lost continuation always leaves 14-22 spaces (the source's own indentation); +/// ordinary column-alignment padding measured in this crate never exceeds 8. Ten +/// leaves margin on both sides: comfortably above every legitimate pad, comfortably +/// below the smallest real defect. +const MIN_DEFECT_RUN: usize = 10; + +/// One offending run found by the scan. +struct Offense { + file: String, + line: u32, + fragment: String, +} + +fn is_excluded_line(file_name: &str, line_no: u32) -> bool { + EXCLUDED_LINE_RANGES + .iter() + .any(|(f, start, end)| *f == file_name && line_no >= *start && line_no <= *end) +} + +fn is_excluded_dir(rel_path: &Path) -> bool { + rel_path + .components() + .any(|c| EXCLUDED_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())) +} + +/// Walks every `.rs` file under `src`, returning `(files_scanned, offenses)`. +fn scan_source_tree() -> (usize, Vec) { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files_scanned = 0usize; + let mut offenses = Vec::new(); + + let mut stack = vec![src_root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(&src_root).unwrap_or(&path); + if path.is_dir() { + if is_excluded_dir(rel) { + continue; + } + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + if is_excluded_dir(rel) { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + files_scanned += 1; + + for (idx, raw_line) in contents.lines().enumerate() { + let line_no = (idx + 1) as u32; + if is_excluded_line(&file_name, line_no) { + continue; + } + + // Leading indentation is source layout, not literal content -- ignore it. + let trimmed_start = raw_line.trim_start(); + if trimmed_start.is_empty() { + continue; + } + + // A comment line is never scanned, structurally -- rewording a comment + // (including this guard's own prose) must never dodge the check by + // reformatting it as non-comment text; it stays excluded because it + // starts with `//`, not because of what it says. + if trimmed_start.starts_with("//") { + continue; + } + + // Control characters (excluding the line's own trailing newline, which + // `.lines()` already stripped) are always a defect signature. + if let Some(pos) = trimmed_start.char_indices().find(|(_, c)| c.is_control()) { + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: format!("", pos.0), + }); + continue; + } + + // A line in one of the CLI banner files that carries a literal `\n` + // escape anywhere is deliberate column layout end to end -- see + // CLI_COLUMN_FILES above. + if CLI_COLUMN_FILES.contains(&file_name.as_str()) && trimmed_start.contains(r"\n") { + continue; + } + + // Find every run of 2+ spaces; only a run at or above MIN_DEFECT_RUN is + // a candidate, and only once it clears the trailing-comment check below. + let bytes = trimmed_start.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b' ' { + i += 1; + continue; + } + let run_start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - run_start; + if run_len < MIN_DEFECT_RUN { + continue; + } + + // A run immediately followed by `//` is aligning a TRAILING + // COMMENT to a fixed column (untrusted_text.rs's unicode-range + // table, state.rs's fixture annotations, server.rs's origin + // list) -- structurally never inside a string literal's body. + if trimmed_start[i..].starts_with("//") { + continue; + } + + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: trimmed_start.to_string(), + }); + break; + } + } + } + } + + (files_scanned, offenses) +} + +/// This crate has dozens of source files; a scan that reads zero (a wrong +/// `CARGO_MANIFEST_DIR`, a moved `src/`, a walk that silently matched nothing) is a +/// broken guard, not a passing one, and must FAIL rather than vacuously succeed. +const MIN_FILES_SCANNED: usize = 20; + +#[test] +fn no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence() { + let (files_scanned, offenses) = scan_source_tree(); + + assert!( + files_scanned > MIN_FILES_SCANNED, + "scanned {files_scanned} files, expected more than {MIN_FILES_SCANNED} -- a guard \ + that reads zero (or too few) files is not scanning the crate, and a scan that \ + reads nothing must fail rather than pass vacuously" + ); + + if !offenses.is_empty() { + let report: Vec = offenses + .iter() + .map(|o| format!(" {}:{} -> {:?}", o.file, o.line, o.fragment)) + .collect(); + panic!( + "found {} site(s) with a lost string continuation (a run of {}+ spaces mid-line, \ + outside a comment/fixture/CLI-column exemption):\n{}", + offenses.len(), + MIN_DEFECT_RUN, + report.join("\n") + ); + } +} diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index db9e94a6..7a8a0bdd 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -6078,7 +6078,10 @@ mod tests { assert_eq!( actual, contract.union(&overlay).copied().collect::>(), - "this node's master tier disagrees with dig-node-control-interface plus the declared local overlay" + concat!( + "this node's master tier disagrees with dig-node-control-interface plus the declared ", + "local overlay" + ) ); assert!( contract.is_subset(&actual), diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 0860ebb6..906ff93d 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -48,6 +48,8 @@ pub mod config; /// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the /// served-store CSP. The wiring lives in [`server`]. pub mod content; +#[cfg(test)] +mod continuation_guard; pub mod control; /// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every /// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, diff --git a/crates/dig-node-service/src/logging.rs b/crates/dig-node-service/src/logging.rs index f4420440..55753dad 100644 --- a/crates/dig-node-service/src/logging.rs +++ b/crates/dig-node-service/src/logging.rs @@ -258,7 +258,11 @@ mod tests { fn the_announcement_text_has_no_lost_string_continuation() { assert!( !FILE_LOGGING_DEGRADED.contains(" "), - "a run of consecutive spaces means a continuation lost its backslash: {FILE_LOGGING_DEGRADED:?}" + concat!( + "a run of consecutive spaces means a continuation lost its backslash: ", + "{FILE_LOGGING_DEGRADED:?}" + ), + FILE_LOGGING_DEGRADED = FILE_LOGGING_DEGRADED ); } diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 02606f5a..fa6735fd 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -1740,7 +1740,10 @@ mod tests { .collect(); assert!( control_wallet_reads.len() >= 2, - "expected the light-client chain reads in CONTROL_METHODS; found {} - this guard would otherwise pass vacuously", + concat!( + "expected the light-client chain reads in CONTROL_METHODS; found {} - this guard ", + "would otherwise pass vacuously" + ), control_wallet_reads.len() ); @@ -1748,7 +1751,12 @@ mod tests { for prefix in RETIRED_CUSTODY_PREFIXES { assert!( !name.starts_with(prefix), - "`{name}` is a light-client chain read and must stay discoverable, but the retired prefix `{prefix}` matches it" + concat!( + "`{name}` is a light-client chain read and must stay discoverable, but the ", + "retired prefix `{prefix}` matches it" + ), + name = name, + prefix = prefix ); } } diff --git a/crates/dig-node-service/src/pairing.rs b/crates/dig-node-service/src/pairing.rs index b77fb8d9..70f2269a 100644 --- a/crates/dig-node-service/src/pairing.rs +++ b/crates/dig-node-service/src/pairing.rs @@ -143,7 +143,12 @@ pub fn request(pending: &Mutex, id: Value, params: &Value) -> V id, ErrorCode::InvalidParams, format!( - "client_name must be at most {MAX_CLIENT_NAME} characters; this request is refused rather than shortened, because a name the node shortened is a name the node partly wrote" + concat!( + "client_name must be at most {MAX_CLIENT_NAME} characters; this request is ", + "refused rather than shortened, because a name the node shortened is a name the ", + "node partly wrote" + ), + MAX_CLIENT_NAME = MAX_CLIENT_NAME ), ); } @@ -488,13 +493,23 @@ mod tests { json!(ErrorCode::InvalidParams.name()), "an over-long name must be refused: {refused}" ); + let message = refused["error"]["message"].as_str().unwrap(); assert!( - refused["error"]["message"] - .as_str() - .unwrap() - .contains("refused rather than shortened"), + message.contains("refused rather than shortened"), "the refusal must say why it is a refusal: {refused}" ); + // dig-node#526: this is the ONE user-visible site of the lost-continuation + // class -- prove the fix through the JSON-RPC error path a real client + // receives, not against the source literal, and that the join left no + // stray multi-space run where the `\` continuation used to be. + assert!( + message.contains("node partly wrote"), + "the full refusal sentence must survive the join: {refused}" + ); + assert!( + !message.contains(" "), + "a run of consecutive spaces means a continuation lost its backslash: {message:?}" + ); } /// **Proves:** the accepted `client_name` is stored BYTE-VERBATIM. diff --git a/crates/dig-node-service/src/service.rs b/crates/dig-node-service/src/service.rs index 01158c7e..1a38e3c0 100644 --- a/crates/dig-node-service/src/service.rs +++ b/crates/dig-node-service/src/service.rs @@ -1688,9 +1688,14 @@ pub fn install(config: &Config, scope: ScopeChoice) -> io::Result { } for (home, why) in &sweep.failed { summary.push_str(&format!( - " - WARN could not remove the user-level registration belonging to {} ({why}); it may keep starting a second node on the same port. Have that user run: dig-node uninstall --scope user", - home.display() + concat!( +" + WARN could not remove the user-level registration belonging to {home} ({why}); it ", +"may keep starting a second node on the same port. Have that user run: dig-node ", +"uninstall --scope user" +), + home = home.display(), + why = why )); } summary.push_str(&format!( @@ -2627,7 +2632,13 @@ mod tests {", for (label, dirs) in [("unix", &unix), ("windows", &windows)] { assert!( !dirs.iter().any(|d| d == std::path::Path::new(bad)), - "{bad:?} is writable by a non-privileged user on a common install and MUST NOT be a privileged tool directory ({label}): {dirs:?}" + concat!( + "{bad:?} is writable by a non-privileged user on a common install and MUST NOT ", + "be a privileged tool directory ({label}): {dirs:?}" + ), + bad = bad, + label = label, + dirs = dirs ); } }