Skip to content

Commit 4d5ba3d

Browse files
fix: correctness bug sweep + hardening tests across crawlers, patch, and vex (#106)
* fix: correctness bug sweep + hardening tests across crawlers, patch, and vex Fixes a broad set of correctness, security, and atomicity bugs surfaced by a line-by-line review, each paired with regression tests: - crawlers: single-quote TOML parsing, case canonicalization, vendor/project gate ordering, PnP detection, NuGet legacy/local-mode gating, Maven skip- section boundaries, Python layout/metadata fallbacks - patch: path-escape guards (cargo/go redirect, rollback, sidecars), atomic writes for user manifests (go.mod, Cargo.toml, .cargo/config.toml, package.json, pyproject/requirements), bsdiff header validation, copy_tree symlink chmod, cow hardlink is_file guard, lock timeout overflow - vex: single-quote product detection, schema/verify hardening - api/client: fetch_binary auth error classification, token/slug validation - misc: purl subpath strip, manifest deterministic serialization, severity color ordering, cleanup_blobs orphan handling, pth_hook detection fixes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): green the test/clippy jobs broken by the sweep Three CI-blocking fixes surfaced by the failing checks (clippy + test on all OSes + coverage + test-release): - clippy: `map_or(true, ..)` -> `is_none_or(..)` in manifest/schema.rs (clippy 1.93 `unnecessary_map_or`, denied via `-D warnings`). - rollback_dispatch_branch_golang: the sweep added a project-local go redirect rollback backend. In local mode go rolls back by dropping the `replace` redirect and leaves the module cache pristine, so it never restores cache bytes (verified: local mode reports success without touching the file; global mode genuinely restores). The dispatch test's byte-restore contract only holds on the in-place/global path — the go analog of the cargo test's `vendor/` in-place layout — so drive that one fixture in `--global` mode. - output_helpers_e2e: the sweep's severity-colour-inversion fix flipped critical->bright-red(91)/high->red(31) and updated the in-crate unit tests, but this integration file still asserted the old swapped codes. cargo test is fail-fast, so it aborted on the golang failure before ever reaching this binary in CI; surfaced via a local `--no-fail-fast` run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): make manifest-unreadable list test cross-platform `manifest_path_through_regular_file_reports_unreadable_via_binary` nested the manifest under a regular file (`<file>/manifest.json`), assuming the OS rejects the read with a non-absence error. That holds on Unix (ENOTDIR) but NOT on Windows, where traversing through a file is `ERROR_PATH_NOT_FOUND` (NotFound) — legitimately classified as `manifest_not_found`, failing the assertion on windows-latest. Point the manifest path at a directory instead: reading it fails with a non-NotFound error on every platform (Unix `IsADirectory`, Windows `PermissionDenied`), so the "present-but-unreadable → manifest_unreadable" contract is exercised portably. Renamed accordingly. This was masked on the first push: cargo test is fail-fast and the Windows run aborted at this binary (sorts before the now-fixed golang/output tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): windows path separator in nested-workspace find test `test_find_recurses_into_nested_workspace` matched walked filesystem paths with `str::ends_with("packages/inner/package.json")`, which fails on Windows where `WalkDir` yields `\`-separated paths. Match on the `PathBuf` via `Path::ends_with`, which compares whole components and accepts `/` in the pattern on every platform (Windows treats both `/` and `\` as separators). This is the only walked-real-path assertion that used a forward-slash string literal; the CLI/manifest path assertions elsewhere operate on forward-slash-normalized manifest keys (echoed verbatim) and are unaffected. Surfaced by fail-fast: the Windows run aborted here (socket-patch-core --lib) only after the previously-fixed cli_parse_list binary passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b004f6a commit 4d5ba3d

79 files changed

Lines changed: 7832 additions & 331 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/socket-patch-cli/src/args.rs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,88 @@ mod tests {
578578
);
579579
}
580580

581+
/// `parse_supported_ecosystem` accepts every name this build compiles in
582+
/// and returns it verbatim.
583+
#[test]
584+
fn parse_supported_ecosystem_accepts_compiled_in_names() {
585+
for e in Ecosystem::all() {
586+
let name = e.cli_name();
587+
assert_eq!(
588+
parse_supported_ecosystem(name),
589+
Ok(name.to_string()),
590+
"{name:?} is compiled in and must be accepted",
591+
);
592+
}
593+
}
594+
595+
/// Unsupported / misspelled ecosystem names are rejected with a message
596+
/// that names the offending token and lists the supported set.
597+
#[test]
598+
fn parse_supported_ecosystem_rejects_unknown_names() {
599+
for bad in ["bogus", "NPM", "py-pi", ""] {
600+
let err = parse_supported_ecosystem(bad)
601+
.expect_err("unsupported ecosystem name must be rejected");
602+
assert!(err.contains(bad), "error should echo the bad token: {err:?}");
603+
assert!(
604+
err.contains("supported:"),
605+
"error should list the supported set: {err:?}",
606+
);
607+
}
608+
}
609+
610+
/// End-to-end through clap: `--ecosystems` splits on commas, validates each
611+
/// token, and rejects the whole parse if any token is unsupported.
612+
#[test]
613+
#[serial_test::serial]
614+
fn ecosystems_flag_splits_and_validates() {
615+
with_clean_socket_env(|| {
616+
let cli = TestCli::try_parse_from(["socket-patch", "--ecosystems", "npm,pypi"])
617+
.expect("comma-separated supported ecosystems must parse");
618+
assert_eq!(
619+
cli.common.ecosystems,
620+
Some(vec!["npm".to_string(), "pypi".to_string()]),
621+
);
622+
623+
// One bad token in the list aborts the whole parse.
624+
assert!(
625+
TestCli::try_parse_from(["socket-patch", "--ecosystems", "npm,bogus"]).is_err(),
626+
"an unsupported token must fail the parse",
627+
);
628+
});
629+
}
630+
631+
/// Precedence contract: a CLI value wins over the env var for a string flag.
632+
#[test]
633+
#[serial_test::serial]
634+
fn cli_arg_overrides_env_var() {
635+
with_clean_socket_env(|| {
636+
std::env::set_var("SOCKET_MANIFEST_PATH", "from-env.json");
637+
let cli =
638+
TestCli::try_parse_from(["socket-patch", "--manifest-path", "from-cli.json"])
639+
.unwrap();
640+
assert_eq!(cli.common.manifest_path, "from-cli.json");
641+
std::env::remove_var("SOCKET_MANIFEST_PATH");
642+
});
643+
}
644+
645+
/// Precedence contract: the env var is honored when no CLI value is given,
646+
/// and the clap-declared default applies when neither is set.
647+
#[test]
648+
#[serial_test::serial]
649+
fn env_var_used_then_default_applies() {
650+
with_clean_socket_env(|| {
651+
std::env::set_var("SOCKET_MANIFEST_PATH", "from-env.json");
652+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
653+
assert_eq!(cli.common.manifest_path, "from-env.json");
654+
std::env::remove_var("SOCKET_MANIFEST_PATH");
655+
656+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
657+
assert_eq!(cli.common.manifest_path, DEFAULT_PATCH_MANIFEST_PATH);
658+
assert_eq!(cli.common.download_mode, "diff");
659+
assert_eq!(cli.common.cwd, PathBuf::from("."));
660+
});
661+
}
662+
581663
/// `apply_env_toggles` mirrors `--debug` / `--no-telemetry` into the env
582664
/// vars core code reads directly, and is a no-op when the flags are off.
583665
/// `#[serial]` because it mutates process-global env state.

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,25 +1235,43 @@ async fn apply_patches_inner(
12351235
// hash-mismatch and were skipped above), so this
12361236
// applies a single variant for them; Maven's coexisting
12371237
// classifier jars each get patched.
1238+
} else {
1239+
// A variant that reached apply IS the installed
1240+
// distribution, so a failure here is a real apply
1241+
// failure — flag it even if a *sibling* variant of the
1242+
// same base succeeds (Maven's coexisting classifier
1243+
// jars, or any base where `--force` attempts every
1244+
// variant). Mirrors the npm branch below and the
1245+
// rollback loop, which mark `has_errors` on every failed
1246+
// result; without this a partial multi-variant failure
1247+
// would leave a `failed` event in the envelope while the
1248+
// command still reported `success` / exit 0.
1249+
has_errors = true;
1250+
if !args.common.silent && !args.common.json {
1251+
eprintln!(
1252+
"Failed to patch {}: {}",
1253+
variant_purl,
1254+
result.error.as_deref().unwrap_or("unknown error")
1255+
);
1256+
}
12381257
}
12391258
results.push(result);
12401259
}
12411260

12421261
if applied {
12431262
applied_base_purls.insert(base_purl.clone());
12441263
} else {
1264+
// Nothing applied for this base. `has_errors` was already set
1265+
// per-variant above when a variant was attempted-but-failed;
1266+
// set it here too for the no-variant-attempted case so both
1267+
// paths fail the command.
12451268
has_errors = true;
1246-
if !args.common.silent && !args.common.json {
1247-
if attempted {
1248-
// The installed variant was found but its patch could
1249-
// not be applied (e.g. a later file mismatched) — a
1250-
// genuine apply failure, not a missing package.
1251-
eprintln!(
1252-
"Failed to patch {base_purl}: the installed variant could not be patched"
1253-
);
1254-
} else {
1255-
eprintln!("Failed to patch {base_purl}: no matching variant found");
1256-
}
1269+
if !attempted && !args.common.silent && !args.common.json {
1270+
// No variant matched the installed distribution at all —
1271+
// the package on disk isn't any known release variant.
1272+
// (Attempted-but-failed variants already printed their own
1273+
// per-variant failure line above.)
1274+
eprintln!("Failed to patch {base_purl}: no matching variant found");
12571275
}
12581276
}
12591277
} else {

‎crates/socket-patch-cli/src/commands/get.rs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ pub(crate) fn max_vuln_severity(
103103
vulns
104104
.values()
105105
.max_by_key(|v| severity_rank(&v.severity))
106+
// `max_by_key` only yields `None` for an empty map; a non-empty
107+
// map of exclusively unrecognized severities (all rank 0) would
108+
// otherwise leak a garbage label like "" or "unknown". Drop it so
109+
// the documented "every entry unrecognized → None" contract holds
110+
// and `patch_event_metadata` omits `severity` rather than emitting
111+
// a meaningless value.
112+
.filter(|v| severity_rank(&v.severity) > 0)
106113
.map(|v| v.severity.clone())
107114
}
108115

@@ -1928,6 +1935,94 @@ mod tests {
19281935
assert_eq!(max_vuln_severity(&HashMap::new()), None);
19291936
}
19301937

1938+
#[test]
1939+
fn max_vuln_severity_returns_none_when_all_unrecognized() {
1940+
// Non-empty map but every severity is off-canon (rank 0). Per the
1941+
// doc contract this must be `None` — NOT `Some("")`/`Some("unknown")`.
1942+
// Regression guard: `max_by_key` alone returns the element for any
1943+
// non-empty map, leaking a garbage severity label.
1944+
let mut vulns = HashMap::new();
1945+
vulns.insert(
1946+
"GHSA-a".into(),
1947+
VulnerabilityResponse {
1948+
cves: Vec::new(),
1949+
summary: String::new(),
1950+
severity: "informational".into(),
1951+
description: String::new(),
1952+
},
1953+
);
1954+
vulns.insert(
1955+
"GHSA-b".into(),
1956+
VulnerabilityResponse {
1957+
cves: Vec::new(),
1958+
summary: String::new(),
1959+
severity: String::new(),
1960+
description: String::new(),
1961+
},
1962+
);
1963+
assert_eq!(max_vuln_severity(&vulns), None);
1964+
}
1965+
1966+
#[test]
1967+
fn max_vuln_severity_recognized_wins_over_unrecognized() {
1968+
// A single recognized severity alongside unrecognized ones must
1969+
// surface — the rank-0 filter only suppresses the all-unrecognized
1970+
// case, never a real label.
1971+
let mut vulns = HashMap::new();
1972+
vulns.insert(
1973+
"GHSA-junk".into(),
1974+
VulnerabilityResponse {
1975+
cves: Vec::new(),
1976+
summary: String::new(),
1977+
severity: "unknown".into(),
1978+
description: String::new(),
1979+
},
1980+
);
1981+
vulns.insert(
1982+
"GHSA-real".into(),
1983+
VulnerabilityResponse {
1984+
cves: Vec::new(),
1985+
summary: String::new(),
1986+
severity: "low".into(),
1987+
description: String::new(),
1988+
},
1989+
);
1990+
assert_eq!(max_vuln_severity(&vulns).as_deref(), Some("low"));
1991+
}
1992+
1993+
#[test]
1994+
fn patch_event_metadata_omits_severity_when_all_unrecognized() {
1995+
// The consumer-facing contract: a patch whose vulnerabilities all
1996+
// carry non-canonical severities must NOT emit a `severity` key
1997+
// (it would otherwise be `""`), while still listing the vulns.
1998+
let mut vulns = HashMap::new();
1999+
vulns.insert(
2000+
"GHSA-aaaa-bbbb-cccc".into(),
2001+
VulnerabilityResponse {
2002+
cves: vec!["CVE-2024-0001".into()],
2003+
summary: "Something".into(),
2004+
severity: "informational".into(),
2005+
description: String::new(),
2006+
},
2007+
);
2008+
let patch = PatchResponse {
2009+
uuid: String::new(),
2010+
purl: String::new(),
2011+
published_at: "ts".into(),
2012+
files: HashMap::new(),
2013+
vulnerabilities: vulns,
2014+
description: "desc".into(),
2015+
license: "MIT".into(),
2016+
tier: "free".into(),
2017+
};
2018+
let meta = patch_event_metadata(&patch);
2019+
assert!(meta.as_object().unwrap().get("severity").is_none());
2020+
// The vulnerability itself is still surfaced (with its raw label).
2021+
let vulns_out = meta["vulnerabilities"].as_array().unwrap();
2022+
assert_eq!(vulns_out.len(), 1);
2023+
assert_eq!(vulns_out[0]["severity"], "informational");
2024+
}
2025+
19312026
#[test]
19322027
fn patch_event_metadata_includes_all_keys() {
19332028
let mut vulns = HashMap::new();

‎crates/socket-patch-cli/src/commands/list.rs‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,14 @@ fn emit_error(args: &ListArgs, code: &str, message: String) {
9696
pub async fn run(args: ListArgs) -> i32 {
9797
let manifest_path = args.common.resolved_manifest_path();
9898

99-
if tokio::fs::metadata(&manifest_path).await.is_err() {
100-
emit_error(
101-
&args,
102-
"manifest_not_found",
103-
format!("Manifest not found at {}", manifest_path.display()),
104-
);
105-
return 1;
106-
}
107-
99+
// `read_manifest` is the single source of truth for the three error
100+
// states: `Ok(None)` (file absent), `Err(InvalidData)` (present but
101+
// unparseable), and any other `Err` (genuine I/O failure). We deliberately
102+
// do NOT stat the path first: a `metadata` pre-check is both redundant and
103+
// wrong — it reports *any* stat failure (e.g. an unreadable parent dir) as
104+
// `manifest_not_found`, masking real I/O errors that owe a
105+
// `manifest_unreadable`, and it opens a TOCTOU window where a file removed
106+
// between the stat and the read lands in the wrong error arm.
108107
match read_manifest(&manifest_path).await {
109108
Ok(Some(manifest)) => {
110109
// Sort by PURL so both the JSON envelope and the human-readable
@@ -170,11 +169,16 @@ pub async fn run(args: ListArgs) -> i32 {
170169
0
171170
}
172171
Ok(None) => {
173-
// Defensive: `read_manifest` only returns `Ok(None)` for a
174-
// missing file, which the metadata pre-check above already
175-
// turned into `manifest_not_found`. Kept so a future loader
176-
// change can't silently fall through without an envelope.
177-
emit_error(&args, "manifest_invalid", "Invalid manifest".to_string());
172+
// `read_manifest` returns `Ok(None)` only when the file does not
173+
// exist (its documented contract), so this is the missing-manifest
174+
// path — `manifest_not_found`, NOT `manifest_invalid` (which means
175+
// the file is present but corrupt). See CLI_CONTRACT.md error-code
176+
// table.
177+
emit_error(
178+
&args,
179+
"manifest_not_found",
180+
format!("Manifest not found at {}", manifest_path.display()),
181+
);
178182
1
179183
}
180184
Err(e) => {

‎crates/socket-patch-cli/src/commands/lock_cli.rs‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,14 @@ pub fn acquire_or_emit(
104104
match acquire(socket_dir, Duration::ZERO) {
105105
Ok(guard) => drop(guard),
106106
Err(LockError::Held) => {
107-
let msg = held_message(timeout);
107+
// The probe above is a *non-blocking* try-once
108+
// (`Duration::ZERO`), so report a zero wait. Threading
109+
// the caller's `timeout` here would claim a "(waited …)"
110+
// that never happened — the probe refuses a live holder
111+
// immediately, it does not wait out the budget first.
112+
// `break_probe_held_message` takes no timeout precisely so
113+
// the wrong value can't be passed back in.
114+
let msg = break_probe_held_message();
108115
emit(command, json, silent, dry_run, "lock_held", &msg, Some(socket_dir));
109116
return Err(1);
110117
}
@@ -192,6 +199,16 @@ pub fn record_lock_broken(env: &mut Envelope, socket_dir: &Path) {
192199
env.record(lock_broken_event(socket_dir));
193200
}
194201

202+
/// Contention message for the `--break-lock` pre-acquire probe. That
203+
/// probe is hard-wired to a non-blocking try-once (`Duration::ZERO`), so
204+
/// the message must never claim a wait, regardless of the caller's
205+
/// `--lock-timeout`. Kept timeout-free on purpose: the call site cannot
206+
/// thread the full budget back in and fabricate a "(waited …)" clause
207+
/// for time that was never spent.
208+
fn break_probe_held_message() -> String {
209+
held_message(Duration::ZERO)
210+
}
211+
195212
/// Human-readable description of a `lock_held` contention for the given
196213
/// wait budget. A zero budget means the historical non-blocking
197214
/// try-once, so we omit the "(waited …)" clause entirely.
@@ -492,6 +509,26 @@ mod tests {
492509
assert!(!msg.contains("waited"), "zero budget should not claim a wait: {msg}");
493510
}
494511

512+
/// Regression: the `--break-lock` pre-acquire probe is a non-blocking
513+
/// try-once, so its `lock_held` refusal must NEVER claim a wait — even
514+
/// when the caller passes a positive `--lock-timeout`. The earlier
515+
/// code threaded the full `timeout` into the probe's message, so a
516+
/// `--break-lock --lock-timeout 250ms` against a live holder reported
517+
/// `(waited 250ms)` despite refusing immediately. The probe message is
518+
/// now timeout-free by construction; this pins that it carries no wait
519+
/// clause.
520+
#[test]
521+
fn break_probe_held_message_never_claims_a_wait() {
522+
let msg = break_probe_held_message();
523+
assert!(
524+
!msg.contains("waited"),
525+
"break-lock probe refuses immediately and must not claim a wait: {msg}"
526+
);
527+
// It is still the same identity sentence the rest of the code
528+
// emits for contention, just without the trailing budget clause.
529+
assert_eq!(msg, held_message(Duration::ZERO));
530+
}
531+
495532
/// The `--json` failure envelope (previously emitted only via
496533
/// `println!`, so untested) has the stable error shape downstream
497534
/// consumers pattern-match on: top-level `status: "error"` and

‎crates/socket-patch-cli/src/commands/remove.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,18 @@ pub async fn run(args: RemoveArgs) -> i32 {
295295
// is non-zero so the `rolledBack` count is still reported
296296
// even when no blobs happened to be swept (e.g. the removed
297297
// patch's afterHash blobs are still referenced elsewhere).
298+
//
299+
// Pushed directly rather than via `env.record`: this is a
300+
// purl-less metadata carrier, not a removed manifest entry.
301+
// The per-purl events above are the authoritative
302+
// patch-removal count, so `summary.removed` must equal the
303+
// number of entries deleted (`removed.len()`) — letting this
304+
// carrier bump `removed` too would double-count, reporting
305+
// e.g. `removed: 2` for a single-patch removal that happened
306+
// to sweep an orphan blob. Consumers read the blob/rollback
307+
// totals from `details`, never from `summary.removed`.
298308
if blobs_removed > 0 || rollback_count > 0 {
299-
env.record(
309+
env.events.push(
300310
PatchEvent::artifact(PatchAction::Removed).with_details(serde_json::json!({
301311
"blobsRemoved": blobs_removed,
302312
"rolledBack": rollback_count,

0 commit comments

Comments
 (0)