Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.80"
version = "0.252.82"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
6 changes: 5 additions & 1 deletion crates/dig-node-service/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
208 changes: 208 additions & 0 deletions crates/dig-node-service/src/continuation_guard.rs
Original file line number Diff line number Diff line change
@@ -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<Offense>) {
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!("<control char at byte {}>", 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<String> = 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")
);
}
}
5 changes: 4 additions & 1 deletion crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6078,7 +6078,10 @@ mod tests {
assert_eq!(
actual,
contract.union(&overlay).copied().collect::<BTreeSet<_>>(),
"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),
Expand Down
2 changes: 2 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub mod config;
/// `<base>`/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,
Expand Down
6 changes: 5 additions & 1 deletion crates/dig-node-service/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
12 changes: 10 additions & 2 deletions crates/dig-node-service/src/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1740,15 +1740,23 @@ 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()
);

for name in &control_wallet_reads {
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
);
}
}
Expand Down
25 changes: 20 additions & 5 deletions crates/dig-node-service/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ pub fn request(pending: &Mutex<PendingPairings>, 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
),
);
}
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions crates/dig-node-service/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1688,9 +1688,14 @@ pub fn install(config: &Config, scope: ScopeChoice) -> io::Result<Outcome> {
}
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!(
Expand Down Expand Up @@ -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
);
}
}
Expand Down
Loading