From 977bbe06a275a59996cbffa80cb97e3a95db7779 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:28:11 +0000 Subject: [PATCH 01/11] Start fix for #975, #539 Assisted-by: Claude Code:claude-opus-5-5 From d40080ef5b460881a59eec63f1dacef8ba43f321 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:34:01 +0000 Subject: [PATCH 02/11] Add failing tests for yarn PnP linker detection A Yarn 2 to Yarn 4 migration that switched nodeLinker away from pnp keeps a stale .pnp.js, and apply and vendor refuse the project as Plug'n'Play (#975). A lock-only checkout of a real PnP project has no loader yet, so vendor wires it and every later re-run refuses (#539). Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_safety_yarn_pnp.rs | 50 +++++++++++++ .../tests/in_process_vendor.rs | 73 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index 63f2a3bbd..f8a1388cc 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -1098,3 +1098,53 @@ fn pnp_project_still_refuses_when_an_npm_patch_is_in_scope() { "the refusal must still be a pre-apply bail" ); } + +/// #975: a Yarn 2 → Yarn 4 migration that switched `nodeLinker` to +/// `node-modules` (or `pnpm`) keeps the Yarn 2 `.pnp.js` around, which +/// yarn ignores. The configured linker decides, so apply patches the +/// installed `node_modules/` copy instead of refusing as Plug'n'Play. +#[test] +fn stale_pnp_loader_under_non_pnp_linker_applies() { + for linker in ["node-modules", "pnpm"] { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"migrated","version":"0.0.0","private":true}"#, + ) + .unwrap(); + std::fs::write(cwd.join(".pnp.js"), b"// stale Yarn 2 loader\n").unwrap(); + std::fs::write( + cwd.join(".yarnrc.yml"), + format!("nodeLinker: {linker}\ncompressionLevel: 0\n"), + ) + .unwrap(); + let index = stage_applicable_package(cwd); + + let (code, stdout, stderr) = run(cwd, &["apply", "--json"]); + assert_eq!( + code, 0, + "{linker}: a stale loader must not refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read(&index).unwrap(), + PATCHED_BYTES, + "{linker}: the node_modules copy is patched" + ); + } +} + +/// The configured linker still refuses a live PnP project: `nodeLinker: +/// pnp` with a loader present is refused exactly as before. +#[test] +fn pnp_loader_under_explicit_pnp_linker_still_refuses() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + std::fs::write(dir.path().join(".yarnrc.yml"), "nodeLinker: \"pnp\"\n").unwrap(); + let index = stage_applicable_package(dir.path()); + let (code, stdout, _) = run(dir.path(), &["apply", "--json"]); + assert_eq!(code, 1, "{stdout}"); + let env = parse_json_envelope(&stdout); + assert_eq!(envelope_error_code(&env), Some("yarn_pnp_unsupported")); + assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 3c51f2992..36eb10e12 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1078,6 +1078,79 @@ async fn berry_mixed_line_endings_fail_closed_with_code() { } } +/// #975: a Yarn 2 → Yarn 4 migration that switched `nodeLinker` away from +/// `pnp` keeps the old `.pnp.js` (yarn 4 never deletes it). Yarn ignores +/// it, so vendor must too: the configured linker decides, not the file. +#[tokio::test] +async fn berry_stale_pnp_loader_under_non_pnp_linker_vendors() { + for linker in ["node-modules", "pnpm"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + stage_berry_project(root, BERRY_WIN_PKG, &berry_win_lock()); + std::fs::write( + root.join(".yarnrc.yml"), + format!("nodeLinker: {linker}\nenableGlobalCache: false\n"), + ) + .unwrap(); + std::fs::write(root.join(".pnp.js"), "// stale Yarn 2 loader\n").unwrap(); + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "{linker}: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{linker}: {env:#}"); + assert!( + std::fs::read_to_string(root.join("yarn.lock")) + .unwrap() + .contains(".socket/vendor/npm/"), + "{linker}: wired" + ); + } +} + +/// #539: a lock-only checkout of a Plug'n'Play project (`nodeLinker: pnp`, +/// or no `nodeLinker` at all, berry's default) has no loader file yet. The +/// configured linker makes it PnP all the same, so vendor refuses up front, +/// as it does once `yarn install` writes `.pnp.cjs`, instead of wiring a +/// project every later re-run refuses. Nothing is written. +#[tokio::test] +async fn berry_lock_only_pnp_project_refused_up_front() { + for (label, yarnrc) in [ + ("explicit pnp", Some("nodeLinker: pnp\nenableGlobalCache: false\n")), + ("default linker", Some("enableGlobalCache: false\n")), + ("no yarnrc", None), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = berry_win_lock(); + stage_berry_project(root, BERRY_WIN_PKG, &lock); + match yarnrc { + Some(rc) => std::fs::write(root.join(".yarnrc.yml"), rc).unwrap(), + None => std::fs::remove_file(root.join(".yarnrc.yml")).unwrap(), + } + for run in ["lock-only", "after install"] { + if run == "after install" { + std::fs::write(root.join(".pnp.cjs"), "/* pnp */\n").unwrap(); + } + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 1, "{label} {run}: {env:#}"); + let failed = find_event(&env, "failed", Some("vendor_yarn_berry_unsupported")); + assert!( + failed.to_string().contains("Plug'n'Play"), + "{label} {run}: {failed}" + ); + assert_eq!( + std::fs::read_to_string(root.join("package.json")).unwrap(), + BERRY_WIN_PKG, + "{label} {run}" + ); + assert_eq!( + std::fs::read_to_string(root.join("yarn.lock")).unwrap(), + lock, + "{label} {run}" + ); + assert!(!root.join(".socket/vendor").exists(), "{label} {run}"); + } + } +} + /// Mount the hosted-mode API (batch discovery, per-package search, a granted /// reference carrying the yarn-berry-zip checksum, the patch view) for the /// berry takeover legs. Returns the hosted tarball URL. From 773f089c9e9136b8be0796ed8b0ae98aa2df5403 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:44:41 +0000 Subject: [PATCH 03/11] Decide yarn PnP from the configured nodeLinker Every Plug'n'Play check looked only for a .pnp.* loader file. A Yarn 2 to Yarn 4 migration that switched nodeLinker to node-modules or pnpm keeps the old .pnp.js, which yarn ignores, so apply and vendor refused a project whose packages are in node_modules and hosted scans warned that nothing was scanned (#975). Read yarn's effective nodeLinker (YARN_NODE_LINKER, else the nearest .yarnrc.yml that sets it) and ignore a loader the linker disowns. Forward vendoring also refuses a berry project configured for PnP (nodeLinker: pnp, or unset, berry's default) before its loader exists, so a lock-only checkout gets the same answer as an installed one instead of being wired once and refused on every re-run (#539). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/list.rs | 15 +- crates/socket-patch-cli/src/commands/mod.rs | 22 +- .../src/commands/scan/discovery.rs | 62 ++- .../src/commands/scan/hosted.rs | 54 +- .../src/commands/scan/policy.rs | 68 ++- .../src/commands/scan/rollout.rs | 20 +- .../src/commands/scan/rollout_args.rs | 2 - .../socket-patch-cli/src/commands/vendor.rs | 5 +- .../tests/apply/apply_network.rs | 10 +- .../apply/in_process_gem_config_warning.rs | 4 +- .../tests/cli/covgap_output.rs | 10 +- .../tests/cli/interactive_prompts_e2e.rs | 5 +- .../tests/cli_config_fallback.rs | 7 +- .../socket-patch-cli/tests/cli_get_silent.rs | 5 +- .../socket-patch-cli/tests/cli_parse_list.rs | 11 +- .../tests/cli_parse_rollback.rs | 6 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 29 +- .../coverage_fix_apply_silent_mute_exit.rs | 4 +- .../tests/covgap_commands_scan_hosted.rs | 42 +- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 5 +- crates/socket-patch-cli/tests/e2e_cargo.rs | 6 +- crates/socket-patch-cli/tests/e2e_gem.rs | 6 +- crates/socket-patch-cli/tests/e2e_maven.rs | 3 +- crates/socket-patch-cli/tests/e2e_npm.rs | 6 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 6 +- crates/socket-patch-cli/tests/e2e_pypi.rs | 6 +- .../tests/e2e_redirect_gem_build.rs | 5 +- .../tests/e2e_redirect_yarn_berry_build.rs | 6 +- .../tests/e2e_safety_cargo_build.rs | 6 +- .../socket-patch-cli/tests/e2e_safety_pnpm.rs | 18 +- .../tests/e2e_socket_yml_policy.rs | 471 ++++++++++++++---- .../tests/e2e_vex_lockfile/common_selftest.rs | 6 +- .../tests/e2e_yarn4_pnpm_linker_build.rs | 16 +- .../tests/e2e_yarn4_workspaces_build.rs | 16 +- .../tests/get/get_edge_cases_e2e.rs | 12 +- .../tests/get/global_packages_e2e.rs | 5 +- .../tests/help_text_hygiene.rs | 31 +- .../tests/hosted_memory_engine.rs | 3 +- .../tests/hosted_memory_parity.rs | 183 +++++-- .../tests/hosted_memory_rollout.rs | 42 +- .../tests/in_process_get_hosted_ecosystems.rs | 12 +- .../tests/in_process_redirect.rs | 7 +- .../tests/in_process_redirect/vlt.rs | 10 +- .../tests/in_process_redirect_pdm.rs | 30 +- .../tests/in_process_redirect_pipenv.rs | 73 ++- .../tests/in_process_redirect_pnpm.rs | 11 +- .../tests/in_process_rollback_hosted.rs | 5 +- .../tests/in_process_vendor.rs | 5 +- .../tests/repair_vendor_flavors_e2e/vlt.rs | 5 +- .../rollback/rollback_duality_invariants.rs | 3 +- .../tests/scan/covgap_ecosystem_dispatch.rs | 8 +- .../tests/scan/scan_invariants.rs | 20 +- .../tests/scan/scan_paths_e2e.rs | 12 +- .../tests/update/covgap_commands_update.rs | 8 +- .../tests/yarn_berry_common/mod.rs | 4 +- crates/socket-patch-core/src/api/ranking.rs | 17 +- .../src/crawlers/pkg_managers.rs | 127 ++++- .../src/crawlers/python_crawler.rs | 10 +- .../src/formats/cargo/mod.rs | 12 +- .../src/formats/composer/mod.rs | 3 - .../src/formats/gem/gemfile.rs | 10 +- .../src/formats/gem/hosted.rs | 1 - .../socket-patch-core/src/formats/gem/mod.rs | 2 - crates/socket-patch-core/src/formats/mod.rs | 8 +- .../socket-patch-core/src/formats/pnpm/mod.rs | 76 ++- .../socket-patch-core/src/formats/registry.rs | 18 +- .../socket-patch-core/src/formats/yarn/mod.rs | 5 +- .../socket-patch-core/src/hosted/guidance.rs | 10 +- .../src/hosted/memory/discover.rs | 4 +- .../src/hosted/memory/limits.rs | 12 +- .../src/hosted/memory/mod.rs | 89 ++-- .../src/hosted/memory/roots.rs | 22 +- .../src/hosted/memory/select.rs | 14 +- .../src/hosted/memory/types.rs | 4 +- crates/socket-patch-core/src/ledgers.rs | 1 - crates/socket-patch-core/src/lib.rs | 1 - .../socket-patch-core/src/manifest/records.rs | 5 +- .../redirect/cargo_lock_equivalence_tests.rs | 4 +- .../redirect/golang_equivalence_tests.rs | 6 +- .../src/patch/redirect/mod.rs | 209 +++++--- .../src/patch/redirect/npmrc.rs | 3 - .../src/patch/redirect/pdm.rs | 21 +- .../src/patch/redirect/pipenv.rs | 45 +- .../src/patch/redirect/poetry.rs | 22 +- .../src/patch/redirect/state.rs | 2 - .../src/patch/redirect/upstream/bun_lockb.rs | 5 +- .../src/patch/redirect/upstream/cargo.rs | 39 +- .../src/patch/redirect/upstream/gem.rs | 23 +- .../src/patch/redirect/upstream/golang.rs | 9 +- .../src/patch/redirect/upstream/mod.rs | 8 +- .../src/patch/redirect/upstream/pypi_locks.rs | 11 +- crates/socket-patch-core/src/policy/mod.rs | 7 +- crates/socket-patch-core/src/policy/report.rs | 4 +- .../src/policy/socket_yml.rs | 27 +- crates/socket-patch-core/src/policy/tests.rs | 29 +- crates/socket-patch-core/src/rollout/stage.rs | 11 +- crates/socket-patch-core/src/telemetry.rs | 4 +- .../socket-patch-core/src/update/download.rs | 13 +- .../socket-patch-core/src/update/release.rs | 39 +- .../src/utils/group_commit.rs | 21 +- .../src/utils/line_endings.rs | 1 - crates/socket-patch-core/src/utils/process.rs | 15 +- .../src/utils/python_script.rs | 7 +- .../socket-patch-core/src/vendor/bun_lockb.rs | 9 +- .../src/vendor/cargo_lock.rs | 4 +- crates/socket-patch-core/src/vendor/gem.rs | 14 +- .../src/vendor/lock_inventory/view.rs | 37 +- .../src/vendor/lock_inventory/wired.rs | 2 +- .../src/vendor/npm_flavor.rs | 148 ++++-- .../socket-patch-core/src/vendor/prestage.rs | 5 +- .../src/vendor/toml_surgery.rs | 3 +- .../src/vex/discover/cargo.rs | 29 +- .../src/vex/discover/maven.rs | 8 +- .../src/vex/discover/nuget.rs | 2 +- .../tests/covgap_api_blob_fetcher.rs | 5 +- .../tests/covgap_crawlers_composer_crawler.rs | 6 +- .../tests/hosted_inventory.rs | 10 +- .../socket-patch-core/tests/poetry_hosted.rs | 81 ++- .../tests/telemetry_helpers_e2e.rs | 6 +- .../tests/upstream_restore_golden.rs | 423 ++++++++++++---- crates/socket-patch-core/tests/uv_hosted.rs | 4 +- crates/socket-patch-node/src/lib.rs | 6 +- 122 files changed, 2456 insertions(+), 834 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 8fc77fab1..44c719038 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -431,7 +431,10 @@ pub async fn run(args: ListArgs) -> i32 { detail: detail.clone(), }); } else if !args.common.silent { - eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail)); + eprintln!( + "Warning: {}", + crate::commands::rollback::capitalize_first(detail) + ); } } let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent); @@ -773,12 +776,18 @@ mod tests { let listings = HostedListing::from_pins( &[ pin("pkg:npm/minimist@1.2.2", &record.uuid), - pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"), + pin( + "pkg:npm/other@1.0.0", + "33333333-3333-4333-8333-333333333333", + ), ], Some(&legacy), ); assert_eq!(listings[0].record, record); - assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333"); + assert_eq!( + listings[1].record.uuid, + "33333333-3333-4333-8333-333333333333" + ); assert!(listings[1].record.vulnerabilities.is_empty()); assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]); } diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ea45e6915..34ae4b1a3 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,7 +1,7 @@ pub mod apply; pub(crate) mod bun_preflight; -pub(crate) mod context; pub(crate) mod composer_hints; +pub(crate) mod context; pub(crate) mod fetch_stage; pub mod get; pub mod hosted_bundle; @@ -9,11 +9,11 @@ pub mod list; pub(crate) mod lock_cli; pub mod remove; pub mod repair; -pub(crate) mod vendored_backend; pub mod rollback; pub mod scan; pub mod update; pub mod vendor; +pub(crate) mod vendored_backend; pub mod vex; pub(crate) mod vex_consumed; pub(crate) mod vex_sources; @@ -141,9 +141,11 @@ pub(crate) async fn hosted_state_from_lockfiles( common: &crate::args::GlobalArgs, root: &Path, ) -> socket_patch_core::patch::redirect::RedirectState { - hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all( - &discover_wiring(common, root).await, - )) + hosted_state_from_pins( + &socket_patch_core::patch::redirect::upstream::HostedPin::all( + &discover_wiring(common, root).await, + ), + ) } /// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl @@ -153,10 +155,8 @@ pub(crate) fn hosted_state_from_pins( ) -> socket_patch_core::patch::redirect::RedirectState { let mut state = socket_patch_core::patch::redirect::RedirectState::new(); for pin in pins { - state - .records - .entry(pin.purl.clone()) - .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord { + state.records.entry(pin.purl.clone()).or_insert_with(|| { + socket_patch_core::manifest::schema::PatchRecord { uuid: pin.uuid.clone(), exported_at: String::new(), files: Default::default(), @@ -164,7 +164,8 @@ pub(crate) fn hosted_state_from_pins( description: String::new(), license: String::new(), tier: String::new(), - }); + } + }); } state } @@ -191,4 +192,3 @@ pub(crate) fn vendor_state_lenient( } } } - diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index f8e6b467c..79ca66737 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -168,29 +168,32 @@ pub(crate) async fn vendored_ledger_supplement( } // `(ledger key, base purl, entry)`; the artifact fallback has no // entries to probe, so it never reports unwired keys. - let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> = - match state { - Ok(state) => state - .entries - .iter() - .map(|(key, entry)| { - ( - key.clone(), - strip_purl_qualifiers(&entry.base_purl).to_string(), - Some(entry), - ) - }) - .collect(), - // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above): - // recover the vendored set from the committed artifacts, or - // `scan --prune` (whose ledger exemption also degrades to empty) - // would delete still-vendored packages' manifest entries and blobs. - Err(_) => vendored_purls_from_artifacts(common) - .await - .into_iter() - .map(|base| (base.clone(), base, None)) - .collect(), - }; + let candidates: Vec<( + String, + String, + Option<&socket_patch_core::vendor::VendorEntry>, + )> = match state { + Ok(state) => state + .entries + .iter() + .map(|(key, entry)| { + ( + key.clone(), + strip_purl_qualifiers(&entry.base_purl).to_string(), + Some(entry), + ) + }) + .collect(), + // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above): + // recover the vendored set from the committed artifacts, or + // `scan --prune` (whose ledger exemption also degrades to empty) + // would delete still-vendored packages' manifest entries and blobs. + Err(_) => vendored_purls_from_artifacts(common) + .await + .into_iter() + .map(|base| (base.clone(), base, None)) + .collect(), + }; // Composer by release identity: a ledger `@3.0.2.0` is the crawled // `@3.0.2`, not a second package to supplement. let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned()); @@ -1045,7 +1048,9 @@ mod tests { ..GlobalArgs::default() }; let state = socket_patch_core::vendor::load_state(root).await; - vendored_ledger_supplement(&args, crawled, &state).await.packages + vendored_ledger_supplement(&args, crawled, &state) + .await + .packages } /// A ledger entry vendored as `@3.0.2.0` is the crawled composer @@ -1080,7 +1085,9 @@ mod tests { out.iter().map(|p| &p.purl).collect::>() ); - let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages; + let out = vendored_ledger_supplement(&args, &[], &Ok(state)) + .await + .packages; assert_eq!( out.iter().map(|p| p.purl.as_str()).collect::>(), vec!["pkg:composer/psr/log@3.0.2.0"] @@ -1183,7 +1190,10 @@ mod tests { let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await; let out = vendored_ledger_supplement(&args, &[], &state).await; assert_eq!( - out.packages.iter().map(|p| p.purl.as_str()).collect::>(), + out.packages + .iter() + .map(|p| p.purl.as_str()) + .collect::>(), vec!["pkg:npm/left-pad@1.3.0"], "lock={lock:?}" ); diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e6e48a140..6e2b0bea8 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -982,7 +982,8 @@ pub(crate) async fn run_redirect_selected( socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok() }) }; - let rewrite_options = || RewriteOptions { + let rewrite_options = || { + RewriteOptions { dry_run: common.dry_run, targets_pipenv_lock, pipenv_major, @@ -994,6 +995,7 @@ pub(crate) async fn run_redirect_selected( npm_allow_remote_config: !common.no_npm_allow_remote_config, npm_outer: &npm_outer, blocking: true, + } }; // The rollout gate plans again without its deferred rows: keep what // the second pass needs. @@ -2431,13 +2433,19 @@ fn join_names(names: &[String], max: usize) -> String { /// artifacts, then verify with `vex`. After a vendored→hosted takeover /// (`vendored_removed`) the commit also has to carry the deleted vendored /// ledger entries and artifacts. -fn format_next_steps(files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool) -> Vec { +fn format_next_steps( + files: &[String], + edits: &[socket_patch_core::patch::redirect::FileEdit], + vendored_removed: bool, +) -> Vec { if files.is_empty() && !vendored_removed { return Vec::new(); } let mut commit: Vec = Vec::new(); if vendored_removed { - commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string()); + commit.push( + ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string(), + ); } commit.extend(files.iter().cloned()); let npm = files @@ -4624,19 +4632,43 @@ mod tests { use super::npm_allow_remote_one_line; let hosts = ["patch.socket.dev"]; let cases = [ - (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"), - (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"), - (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"), - (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"), - (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"), - (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"), + ( + npm_allow_remote_configured_detail(&hosts, true, false), + "Note: set", + ), + ( + npm_allow_remote_configured_detail(&hosts, false, false), + "Note: set", + ), + ( + npm_allow_remote_configured_detail(&hosts, true, true), + "Note: would set", + ), + ( + npm_allow_remote_already_detail(&hosts), + "Note: .npmrc already", + ), + ( + npm_allow_remote_user_set_detail(&hosts, "none"), + "Warning: npm >=12", + ), + ( + npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), + "Warning: npm >=12", + ), (npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"), - (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"), + ( + npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), + "Warning: npm >=12", + ), ]; for (detail, start) in cases { let line = npm_allow_remote_one_line(&detail); assert!(line.starts_with(start), "{line}"); - assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}"); + assert!( + !line.contains('\n') && line.ends_with("(details: --verbose)."), + "{line}" + ); } } } diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 98b1ecc45..9e53b7a55 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -11,9 +11,9 @@ use socket_patch_core::api::ranking::cmp_search_results; use socket_patch_core::api::types::PatchSearchResult; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::policy::{ - canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name, - DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy, - PATCHES_DISABLED, + canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked, + sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError, + PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED, }; use socket_patch_core::utils::purl::normalize_purl; @@ -42,12 +42,18 @@ pub(crate) struct InvocationPolicy { /// Load the policy for `args` (4.5): `--global` scans have no repo and read /// no file; everything else reads the repo root's socket.yml. pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result { - let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?; + let overrides = args + .socket_yml + .overrides() + .map_err(PolicyLoadError::Usage)?; let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); if args.common.is_global() { - let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides) - .map_err(PolicyLoadError::Policy)? - .0; + let policy = SelectionPolicy::load( + &socket_patch_core::policy::MemoryPolicyFs::default(), + &overrides, + ) + .map_err(PolicyLoadError::Policy)? + .0; return Ok(InvocationPolicy { policy, repo_root: cwd, @@ -56,8 +62,8 @@ pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result Self { + pub(crate) fn for_root( + invocation: &InvocationPolicy, + root_dir: &Path, + explicit: bool, + global: bool, + ) -> Self { let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf()); let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default(); let root_verdict = if global { @@ -171,7 +182,9 @@ impl ScanPolicy { severity: None, }); } - let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed); + let announce_warnings = !invocation + .warned + .swap(true, std::sync::atomic::Ordering::Relaxed); Self { policy: invocation.policy.clone(), warnings, @@ -224,7 +237,10 @@ impl ScanPolicy { /// exclude stays in the query (so `upgradeAvailable` can be reported) /// but joins the retained set, which never reaches a writer. pub(crate) fn admit_crawled(&self, purl: &str) -> bool { - let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl)); + let verdict = self + .root_verdict + .clone() + .and_then(|()| self.policy.admits_purl(purl)); let reason = match verdict { Ok(()) => return true, Err(reason) => reason, @@ -334,7 +350,8 @@ impl ScanPolicy { // (not when a lower-ranked admitted patch simply wins). let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0])); if let Err(reason) = top_withheld { - let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0); + let upgrade_withheld = + chosen.is_some() && chosen == recorded_at && recorded_at != Some(0); if chosen.is_none() || upgrade_withheld { report.filtered.push(FilteredEntry { purl: Some(canon(&purl)), @@ -522,17 +539,20 @@ pub(crate) fn policy_bypass_warnings( let verdict = if !policy.enabled() { Err(FilterReason::Disabled) } else { - root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| { - // The floor only hides a package when none of its patches pass. - match group - .iter() - .map(|p| policy.admits_severity(patch_severity_order(p))) - .find(Result::is_ok) - { - Some(ok) => ok, - None => policy.admits_severity(patch_severity_order(group[0])), - } - }) + root_verdict + .clone() + .and_then(|()| policy.admits_purl(purl)) + .and_then(|()| { + // The floor only hides a package when none of its patches pass. + match group + .iter() + .map(|p| policy.admits_severity(patch_severity_order(p))) + .find(Result::is_ok) + { + Some(ok) => ok, + None => policy.admits_severity(patch_severity_order(group[0])), + } + }) }; if let Err(reason) = verdict { out.push(( diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index 82ef99e17..fe7470a83 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -4,8 +4,10 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan}; pub(crate) use socket_patch_core::rollout::stage::*; +use socket_patch_core::rollout::{ + canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan, +}; use super::discovery::UpdateInfo; @@ -208,11 +210,11 @@ pub(crate) fn human_lines( mod tests { use super::*; use socket_patch_core::api::types::PatchSearchResult; - use socket_patch_core::manifest::schema::PatchManifest; - use std::path::Path; use socket_patch_core::api::types::VulnerabilityResponse; + use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::manifest::schema::PatchRecord; use std::collections::HashMap; + use std::path::Path; fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult { PatchSearchResult { @@ -357,13 +359,21 @@ mod tests { let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]); let recorded = RecordedIndex::new(Some(&stored), &[]); let offers = offers_from_results( - &[offer("pkg:composer/psr/log@v3.0.2", "new", "2026-02-01T00:00:00Z", &["high"])], + &[offer( + "pkg:composer/psr/log@v3.0.2", + "new", + "2026-02-01T00:00:00Z", + &["high"], + )], false, ); let rows = classify(&offers, &recorded, ""); let plan = socket_patch_core::rollout::plan_rollout( rows.into_iter().map(|row| row.candidate).collect(), - &MaxNew { value: Some(0), source: MaxNewSource::Flag }, + &MaxNew { + value: Some(0), + source: MaxNewSource::Flag, + }, false, &BTreeSet::new(), ); diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs index e4d251e98..f83636045 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs @@ -1,7 +1,6 @@ //! `scan --max-new-patches` (see the rollout guide, //! `docs/configuration.md#gradual-rollout`). - use clap::Args; pub(crate) use socket_patch_core::rollout::stage::RolloutCarry; use socket_patch_core::rollout::{resolve_max_new, MaxNew}; @@ -77,7 +76,6 @@ impl RolloutArgs { } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0289bf946..e9eedafe3 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -283,10 +283,7 @@ pub(crate) async fn dispatch_revert_one_opts( /// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose /// probe covers the requirements flavor only) have no in-use probe yet, /// and a missing/unreadable lockfile proves nothing. -pub(crate) async fn dispatch_in_use_one( - entry: &VendorEntry, - project_root: &Path, -) -> Option { +pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { match entry.ecosystem.as_str() { "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, // Cargo probes the lock entry's shape: detached + `[patch]` pointing diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 837057e18..7284a5e2f 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -940,7 +940,10 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { "a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}" ); let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap(); - assert_eq!(content, before, "the file must not be patched from the legacy archive"); + assert_eq!( + content, before, + "the file must not be patched from the legacy archive" + ); let requests = mock.received_requests().await.unwrap_or_default(); let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"); @@ -1043,10 +1046,7 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { v["summary"]["applied"], 1, "the drifted nested copy must be warn-overwritten.\nstdout={v:#}" ); - assert_eq!( - v["summary"]["failed"], 0, - "no copy may fail.\nstdout={v:#}" - ); + assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}"); // The nested copy's blob was fetched on demand… let requests = mock.received_requests().await.unwrap(); diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs index 6e849f90c..5bc4eacd7 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs @@ -201,7 +201,9 @@ fn apply_stderr_warning_gates_on_silent() { "non-silent stderr must carry the {CODE} warning; got:\n{stderr}" ); assert_eq!( - stderr.matches("Warning: bundler app config BUNDLE_PATH").count(), + stderr + .matches("Warning: bundler app config BUNDLE_PATH") + .count(), 1, "exactly ONE warning line (not one per discovery call); got:\n{stderr}" ); diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs index 65cf0b67f..1f5e1c860 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_output.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs @@ -168,9 +168,8 @@ fn run_in_pty_inner( .expect("spawn socket-patch in PTY"); drop(pair.slave); - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); // Watchdog: detached kill after `timeout`; a no-op if the child exits // naturally first. @@ -261,7 +260,10 @@ fn remove_interactive_bare_enter_proceeds_with_default_yes() { "\n", Duration::from_secs(15), ); - assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}"); + assert_eq!( + code, 0, + "remove with bare Enter must succeed; got: {output}" + ); // The interactive confirm MUST have run — otherwise this test passes // vacuously against a regression that drops the TTY gate and // auto-proceeds. Match the distinctive prompt verbatim (the loose diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs index 6f744bfe4..a7387e225 100644 --- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs @@ -112,9 +112,8 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // closed. The previous design used a chunked read+mpsc loop // because it interleaved with a try_wait poll; the simplified // design serializes wait → drop master → read_to_end joins. - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); // Watchdog: detach a thread that kills the child after `timeout`. // The cloned ChildKiller is independent of the main `child` diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index df19585db..530a53c5f 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -59,8 +59,7 @@ fn scan_cmd(project: &Path, data_dir: &Path) -> Command { let mut cmd = Command::new(BINARY); // Human mode: core's proxy advisory (the oracle below) is muted under // `--json`/`--silent`. - cmd.args(["scan", "-e", "npm", "--cwd"]) - .arg(project); + cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") { @@ -298,7 +297,9 @@ async fn corrupt_config_warns_and_keeps_json_stdout_clean() { json_cmd.arg("--json"); let json_out = run(json_cmd); assert!( - json_out.stderr.contains("could not parse socket-cli config"), + json_out + .stderr + .contains("could not parse socket-cli config"), "the parse warning must reach stderr under --json too; got:\n{}", json_out.stderr ); diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs index 4e43c353d..72f454a6a 100644 --- a/crates/socket-patch-cli/tests/cli_get_silent.rs +++ b/crates/socket-patch-cli/tests/cli_get_silent.rs @@ -25,10 +25,7 @@ fn run_get(cwd: &Path, args: &[&str]) -> (i32, String) { for var in GLOBAL_ARG_ENV_VARS { cmd.env_remove(var); } - for var in [ - "SOCKET_SAVE_ONLY", - "SOCKET_ALL_RELEASES", - ] { + for var in ["SOCKET_SAVE_ONLY", "SOCKET_ALL_RELEASES"] { cmd.env_remove(var); } cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 8c997686f..9a850490d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -370,7 +370,11 @@ fn missing_manifest_under_valid_cwd_is_not_an_error_via_binary() { let out = run_list_binary(tmp.path(), &["--json"]); let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) .expect("stdout must be valid JSON envelope"); - assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list"); + assert_eq!( + out.status.code(), + Some(0), + "missing manifest is an empty list" + ); assert_eq!(v["status"], "success", "envelope: {v}"); assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}"); } @@ -1313,7 +1317,10 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_the_envelope_via_binary assert_eq!(v["status"], "success", "envelope={v}"); let warnings = v["warnings"].as_array().expect("warnings[] present"); assert_eq!(warnings.len(), 1, "envelope={v}"); - assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}"); + assert_eq!( + warnings[0]["code"], "redirect_ledger_corrupt", + "envelope={v}" + ); assert!( out.stderr.is_empty(), "--json must keep stderr clean: {}", diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index c8b77af5e..f1590292e 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -366,7 +366,11 @@ fn bare_bool_does_not_consume_next_token() { /// relied on the rejection get a test-visible flip instead of a silent one. #[test] fn multiple_targets_parse_in_order() { - let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]); + let args = parse_rollback(&[ + "pkg:npm/foo@1", + "packages/api/**", + "b0630680-4da6-45f9-bba8-b888e0ffd58c", + ]); assert_eq!( args.targets, vec![ diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index ab81fa6eb..eff55ee79 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -898,7 +898,11 @@ fn max_new_patches_takes_a_count_or_none() { ("NONE", None), ] { let args = parse_scan(&["--max-new-patches", raw]); - assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}"); + assert_eq!( + args.rollout.max_new_patches, + Some(MaxNewPatches(want)), + "{raw}" + ); } } @@ -989,20 +993,33 @@ fn min_severity_flag_and_env() { assert_eq!(parse_scan(&[]).socket_yml.min_severity, None); assert_eq!(overrides(&[], &[]).unwrap().min_severity, None); assert_eq!( - overrides(&["--min-severity", "High"], &[]).unwrap().min_severity, + overrides(&["--min-severity", "High"], &[]) + .unwrap() + .min_severity, Some((Some(1), OverrideSource::Flag)) ); assert_eq!( - overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity, + overrides( + &["--min-severity", "none"], + &[("SOCKET_MIN_SEVERITY", "critical")] + ) + .unwrap() + .min_severity, Some((None, OverrideSource::Flag)) ); assert_eq!( - overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity, + overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]) + .unwrap() + .min_severity, Some((Some(2), OverrideSource::Env)) ); - assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None); + assert_eq!( + overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]) + .unwrap() + .min_severity, + None + ); assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err()); assert!(try_parse_scan(&["--min-severity", "severe"]).is_err()); assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass); } - diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs index 444dd2a3b..049d8356b 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs @@ -149,7 +149,9 @@ fn apply_silent_online_download_failure_keeps_error_output() { ); let chatter = stderr_chatter(&stderr); assert!( - chatter.iter().any(|l| l.contains("could not be downloaded")), + chatter + .iter() + .any(|l| l.contains("could not be downloaded")), "--silent must keep the download-failure error (errors only, \ never nothing); stderr was: {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 54ddf7441..235030104 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -566,8 +566,7 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { "the human skipped line must name purl + reason; stderr=\n{stderr}" ); assert!( - stderr.contains("Warning: ") - && stderr.contains("could not be reverted"), + stderr.contains("Warning: ") && stderr.contains("could not be reverted"), "the takeover pre-warning must reach human stderr; stderr=\n{stderr}" ); } @@ -814,7 +813,10 @@ async fn zero_grant_wet_run_ignores_a_malformed_pre_v5_ledger() { let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); let assert_ignored = |code: i32, doc: &Value, label: &str| { - assert_eq!(code, 0, "{label}: a pre-v5 ledger is never an error: {doc:#}"); + assert_eq!( + code, 0, + "{label}: a pre-v5 ledger is never an error: {doc:#}" + ); assert_eq!(doc["status"], "success", "{label}: {doc:#}"); assert!( !doc.to_string().contains("redirect-state.json"), @@ -1017,7 +1019,10 @@ async fn hosted_human_empty_discovery_ignores_a_malformed_pre_v5_ledger() { for extra in [&[][..], &["--silent"][..]] { let (code, stdout, stderr) = scan_hosted(root, &server.uri(), extra, &[]); - assert_eq!(code, 0, "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}"); + assert_eq!( + code, 0, + "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}" + ); if extra.is_empty() { assert!( stdout.contains("No patches available for installed packages."), @@ -1404,16 +1409,22 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { ], &env, ); - assert_eq!(code, 1, "a binary bun.lockb pin is refused: {stdout}\n{stderr}"); + assert_eq!( + code, 1, + "a binary bun.lockb pin is refused: {stdout}\n{stderr}" + ); let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{e}: {stdout}")); assert_eq!(doc["status"], "partial_failure", "{doc:#}"); - let failed = doc["hosted"]["failed"].as_array().unwrap_or_else(|| panic!("{doc:#}")); + let failed = doc["hosted"]["failed"] + .as_array() + .unwrap_or_else(|| panic!("{doc:#}")); assert_eq!(failed.len(), 1, "{doc:#}"); assert_eq!(failed[0]["purl"], purl, "{doc:#}"); let error = failed[0]["error"].as_str().unwrap_or_default(); assert!( - error.starts_with(&format!("cannot restore {purl} to its upstream registry entry: ")) - && error.contains("bun.lockb") + error.starts_with(&format!( + "cannot restore {purl} to its upstream registry entry: " + )) && error.contains("bun.lockb") && error.contains("git checkout"), "{error}" ); @@ -1819,7 +1830,9 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { "the unreadable workspace file must be left byte-identical" ); assert!( - !tmp.path().join(".socket/vendor/redirect-state.json").exists(), + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), "v5 hosted mode writes no redirect ledger" ); } @@ -1931,9 +1944,8 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &psu, &[]); assert_eq!(code, 0, "human overlap run exits 0; stderr=\n{stderr}"); assert!( - stderr.contains( - "Warning: Hosted wiring superseded the vendored ledger for:" - ) && stderr.contains(XPURL), + stderr.contains("Warning: Hosted wiring superseded the vendored ledger for:") + && stderr.contains(XPURL), "the supersedes warning must reach human stderr; stderr=\n{stderr}" ); } @@ -1981,8 +1993,7 @@ async fn human_dry_run_prints_would_rewrite_pnpm_guidance_and_vex_skip() { "the requested-but-skipped VEX must be announced; stderr=\n{stderr}" ); assert!( - stderr.contains("Warning: ") - && stderr.contains("trustLockfile"), + stderr.contains("Warning: ") && stderr.contains("trustLockfile"), "the pnpm trust guidance must reach human stderr; stderr=\n{stderr}" ); assert!( @@ -2429,7 +2440,8 @@ async fn human_pnpm_rerun_prints_only_the_reminder_and_heal_restores_guidance() let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - engine_stdout(&stdout).starts_with("Switched 1 package to hosted patches; rewrote 2 files.\n"), + engine_stdout(&stdout) + .starts_with("Switched 1 package to hosted patches; rewrote 2 files.\n"), "{stdout}" ); // Everything from the pnpm warning on (the lines above it are the diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 413bb91a6..60f369c1e 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -1156,7 +1156,10 @@ async fn workspace_text_migration_heals_on_rerun() { } let output = command(&fixture.reader, &checkout) .args(["install", "--frozen-lockfile", "--ignore-scripts"]) - .env("BUN_INSTALL_CACHE_DIR", fixture.temp.path().join("text-cache")) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join("text-cache"), + ) .env("BUN_INSTALL", fixture.temp.path().join("text-home")) .output() .unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index 3978aff96..73c6acaef 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -204,8 +204,7 @@ async fn scan_discovers_fake_registry_crates() { "Expected human scan to report exactly 'Found 2 packages (2 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported no packages despite a populated registry:\n{combined}" ); @@ -262,8 +261,7 @@ async fn scan_discovers_vendor_crates() { "Expected human scan to report exactly 'Found 1 package (1 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported no packages despite a populated vendor dir:\n{combined}" ); diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index db8af0af8..f6f189113 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -583,7 +583,11 @@ fn test_gem_dry_run() { let gem_dir = find_gem_dir(cwd); // Download without applying. - assert_run_ok(cwd, &["get", GEM_UUID, "--mode", "agent", "--no-apply"], "get --no-apply"); + assert_run_ok( + cwd, + &["get", GEM_UUID, "--mode", "agent", "--no-apply"], + "get --no-apply", + ); // Read manifest to get file list and expected hashes. let manifest_path = cwd.join(".socket/manifest.json"); diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 22d7e940d..00937ae6c 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -177,8 +177,7 @@ async fn scan_discovers_maven_artifacts() { // Must NOT have hit the empty-crawl path — that line *also* contains // the word "packages". assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported zero packages — Maven discovery did not run:\n{combined}" ); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 89f40f9a5..7486a85c9 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -286,7 +286,11 @@ fn test_npm_dry_run() { assert_eq!(git_sha256_file(&index_js), BEFORE_HASH); // Download the patch *without* applying. - assert_run_ok(cwd, &["get", NPM_UUID, "--mode", "agent", "--no-apply"], "get --no-apply"); + assert_run_ok( + cwd, + &["get", NPM_UUID, "--mode", "agent", "--no-apply"], + "get --no-apply", + ); // File should still be original. assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index ce4cc4998..f8ec8eb1e 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -227,7 +227,8 @@ async fn scan_discovers_global_cache_packages() { // "packages" substring check would also match). assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") && !combined.contains("No global packages found"), + && !combined.contains("No packages found") + && !combined.contains("No global packages found"), "scan failed to discover the fake global cache:\n{combined}" ); // Exactly the two packages we planted (Newtonsoft.Json, System.Text.Json), @@ -285,7 +286,8 @@ async fn scan_discovers_legacy_packages() { ); assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") && !combined.contains("No global packages found"), + && !combined.contains("No packages found") + && !combined.contains("No global packages found"), "scan failed to discover the legacy packages/ layout:\n{combined}" ); // Exactly the single legacy package we planted (Newtonsoft.Json.13.0.3), diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 4531d1173..d84c6db20 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -426,7 +426,11 @@ fn test_pypi_dry_run() { let original_hash = git_sha256_file(&messages_py); // Download without applying. - assert_run_ok(cwd, &["get", PYPI_UUID, "--mode", "agent", "--no-apply"], "get --no-apply"); + assert_run_ok( + cwd, + &["get", PYPI_UUID, "--mode", "agent", "--no-apply"], + "get --no-apply", + ); // File should be unchanged. assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index bd737a2ca..9da70ac65 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -1856,7 +1856,10 @@ async fn gem_hosted_custom_git_source_is_refused_and_still_installs() { Driver::ScanVexCustomGitSource, ) .await; - assert!(fx.is_none(), "the custom git_source driver asserts in place"); + assert!( + fx.is_none(), + "the custom git_source driver asserts in place" + ); } /// #340: a `gem` declaration that continues on the next line must not be diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index c1ae3226c..4c96ef1b3 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -592,9 +592,9 @@ async fn berry_hosted_project_with( let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap(); let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap(); assert!( - root_pkg["resolutions"] - .as_object() - .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) + root_pkg["resolutions"].as_object().is_some_and(|r| r + .iter() + .any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) && v.as_str() == Some(hosted_url.as_str()))), "package.json must route {DEP} to the hosted tarball: {root_pkg}" ); diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 62e9ef05d..29e90c39d 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -419,7 +419,11 @@ fn manifestless_agent_patch_is_not_attested(consumer: &Path, cargo_home: &Path) "description": "d" } }); - std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); let out = run_vex(&bin, consumer, &run); assert_eq!(out.code, Some(0), "manifest-backed vex:\n{out}"); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs index 7af958619..783e7337a 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs @@ -293,7 +293,11 @@ fn apply_in_a_does_not_mutate_b_or_store() { }; // -- get + apply in proj_a only ---------------------------------- - assert_run_ok(&fx.proj_a, &["get", NPM_UUID, "--mode", "agent"], "socket-patch get"); + assert_run_ok( + &fx.proj_a, + &["get", NPM_UUID, "--mode", "agent"], + "socket-patch get", + ); // proj_a is patched. assert_eq!( @@ -397,7 +401,11 @@ fn pnpm_install_in_b_does_not_revert_a() { store_id }; - assert_run_ok(&fx.proj_a, &["get", NPM_UUID, "--mode", "agent"], "socket-patch get"); + assert_run_ok( + &fx.proj_a, + &["get", NPM_UUID, "--mode", "agent"], + "socket-patch get", + ); assert_eq!(git_sha256_file(&index_a), AFTER_HASH); // Re-run pnpm install in proj_b with frozen lockfile — this @@ -475,7 +483,11 @@ fn apply_in_pnpm_project_emits_layout_note() { let root = tempfile::tempdir().unwrap(); let fx = setup_two_pnpm_projects(root.path()); - let (_stdout, stderr) = assert_run_ok(&fx.proj_a, &["get", NPM_UUID, "--mode", "agent"], "socket-patch get"); + let (_stdout, stderr) = assert_run_ok( + &fx.proj_a, + &["get", NPM_UUID, "--mode", "agent"], + "socket-patch get", + ); // The exact phrasing is a stable contract. A bare `contains("pnpm")` // is worthless here — every pnpm store path printed on stderr diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 50018d6a9..9a02495b9 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -61,7 +61,11 @@ impl Patch { "low" => 3, _ => 4, }; - self.severities.iter().copied().min_by_key(|s| rank(s)).unwrap_or("unknown") + self.severities + .iter() + .copied() + .min_by_key(|s| rank(s)) + .unwrap_or("unknown") } } @@ -200,7 +204,9 @@ async fn mount_api(server: &MockServer, patches: Vec) { .await; let detail_map = by_purl.clone(); Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(move |req: &Request| { let raw = req.url.path().rsplit('/').next().unwrap(); let purl = percent_decode(raw); @@ -216,11 +222,15 @@ async fn mount_api(server: &MockServer, patches: Vec) { }) }) .collect(); - ResponseTemplate::new(200).set_body_json(json!({"patches": list, "canAccessPaidPatches": false})) + ResponseTemplate::new(200) + .set_body_json(json!({"patches": list, "canAccessPaidPatches": false})) }) .mount(server) .await; - let by_uuid: BTreeMap = patches.iter().map(|p| (p.uuid.to_string(), p.clone())).collect(); + let by_uuid: BTreeMap = patches + .iter() + .map(|p| (p.uuid.to_string(), p.clone())) + .collect(); let refs = by_uuid.clone(); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/package"))) @@ -277,8 +287,10 @@ fn write_npm_root(dir: &Path, deps: &[&str]) { let dep_map: BTreeMap<&str, &str> = deps.iter().map(|d| (*d, "1.0.0")).collect(); std::fs::write( dir.join("package.json"), - serde_json::to_string_pretty(&json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map})) - .unwrap(), + serde_json::to_string_pretty( + &json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map}), + ) + .unwrap(), ) .unwrap(); let mut packages = serde_json::Map::new(); @@ -289,7 +301,11 @@ fn write_npm_root(dir: &Path, deps: &[&str]) { for name in deps { let pkg = dir.join("node_modules").join(name); std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write(pkg.join("package.json"), format!(r#"{{ "name": "{name}", "version": "1.0.0" }}"#)).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "1.0.0" }}"#), + ) + .unwrap(); std::fs::write(pkg.join("index.js"), orig_index(name)).unwrap(); packages.insert( format!("node_modules/{name}"), @@ -304,12 +320,20 @@ fn write_npm_root(dir: &Path, deps: &[&str]) { "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": packages }); - std::fs::write(dir.join("package-lock.json"), serde_json::to_string_pretty(&lock).unwrap() + "\n").unwrap(); + std::fs::write( + dir.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap() + "\n", + ) + .unwrap(); } fn write_gem(dir: &Path, name: &str, version: &str) { - std::fs::create_dir_all(dir.join("vendor/bundle/ruby/3.0.0/gems").join(format!("{name}-{version}")).join("lib")) - .unwrap(); + std::fs::create_dir_all( + dir.join("vendor/bundle/ruby/3.0.0/gems") + .join(format!("{name}-{version}")) + .join("lib"), + ) + .unwrap(); } /// The monorepo: `services/web` (alpha, beta, left-pad + a gem), @@ -349,7 +373,11 @@ impl Repo { if entry.file_type().unwrap().is_dir() { walk(&path, root, out); } else { - let rel = path.strip_prefix(root).unwrap().to_string_lossy().into_owned(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(); out.insert(rel, std::fs::read(&path).unwrap()); } } @@ -369,7 +397,8 @@ fn run_cli(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, Str cmd.env_remove(key); } } - cmd.env_remove("GIT_CEILING_DIRECTORIES").env_remove("VIRTUAL_ENV"); + cmd.env_remove("GIT_CEILING_DIRECTORIES") + .env_remove("VIRTUAL_ENV"); cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); // The fixture's hosted pins name this origin; it makes them recorded. cmd.env("SOCKET_PATCH_SERVER_URL", "http://patch.test"); @@ -383,7 +412,8 @@ fn run_cli(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, Str ] { cmd.env(var, &absent); } - cmd.env("NPM_CONFIG_ALLOW_REMOTE", "").env("npm_config_allow_remote", ""); + cmd.env("NPM_CONFIG_ALLOW_REMOTE", "") + .env("npm_config_allow_remote", ""); for (k, v) in env { cmd.env(k, v); } @@ -418,8 +448,9 @@ fn scan_json(cwd: &Path, api: &str, extra: &[&str], env: &[(&str, &str)]) -> (i3 let mut args = vec!["--json"]; args.extend_from_slice(extra); let (code, stdout, stderr) = scan(cwd, api, &args, env); - let doc: Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e})\nstdout=\n{stdout}\nstderr=\n{stderr}")); + let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be JSON ({e})\nstdout=\n{stdout}\nstderr=\n{stderr}") + }); (code, doc) } @@ -428,7 +459,12 @@ fn filtered(doc: &Value) -> Vec<(Option, String)> { .as_array() .unwrap() .iter() - .map(|f| (f["purl"].as_str().map(str::to_string), f["reason"].as_str().unwrap().to_string())) + .map(|f| { + ( + f["purl"].as_str().map(str::to_string), + f["reason"].as_str().unwrap().to_string(), + ) + }) .collect() } @@ -444,7 +480,11 @@ fn filtered_reason<'a>(doc: &'a Value, purl: &str) -> &'a Value { fn warning_codes(doc: &Value) -> Vec { doc["warnings"] .as_array() - .map(|w| w.iter().filter_map(|e| e["code"].as_str().map(str::to_string)).collect()) + .map(|w| { + w.iter() + .filter_map(|e| e["code"].as_str().map(str::to_string)) + .collect() + }) .unwrap_or_default() } @@ -464,16 +504,28 @@ async fn hosted_filters_by_ecosystem_package_and_severity() { assert_eq!(code, 0, "{doc:#}"); let lock = repo.lock("services/web"); - assert!(lock.contains(&P_ALPHA.hosted_url()), "alpha is patched:\n{lock}"); - assert!(!lock.contains(P_BETA.uuid), "beta is below the floor:\n{lock}"); - assert!(!lock.contains(P_LEFTPAD.uuid), "left-pad is ignored:\n{lock}"); + assert!( + lock.contains(&P_ALPHA.hosted_url()), + "alpha is patched:\n{lock}" + ); + assert!( + !lock.contains(P_BETA.uuid), + "beta is below the floor:\n{lock}" + ); + assert!( + !lock.contains(P_LEFTPAD.uuid), + "left-pad is ignored:\n{lock}" + ); let policy = &doc["policy"]; assert_eq!(policy["source"], "file"); assert_eq!(policy["path"], "socket.yml"); assert_eq!(policy["sha256"].as_str().unwrap().len(), 64); assert_eq!(policy["enabled"], true); - assert_eq!(policy["minSeverity"], json!({"value": "high", "source": "file"})); + assert_eq!( + policy["minSeverity"], + json!({"value": "high", "source": "file"}) + ); let beta = filtered_reason(&doc, "pkg:npm/beta@1.0.0"); assert_eq!(beta["reason"], "policy_severity"); assert_eq!(beta["detail"], "low < high"); @@ -481,8 +533,15 @@ async fn hosted_filters_by_ecosystem_package_and_severity() { assert_eq!(beta["project"], "services/web"); let left_pad = filtered_reason(&doc, "pkg:npm/left-pad@1.0.0"); assert_eq!(left_pad["reason"], "policy_package_ignored"); - assert_eq!(left_pad["uuid"], Value::Null, "filtered before any patch lookup"); - assert_eq!(left_pad["detail"], "pkg:npm/left-pad (patches.ignorePackages)"); + assert_eq!( + left_pad["uuid"], + Value::Null, + "filtered before any patch lookup" + ); + assert_eq!( + left_pad["detail"], + "pkg:npm/left-pad (patches.ignorePackages)" + ); let rack = filtered_reason(&doc, "pkg:gem/rack@1.0.0"); assert_eq!(rack["reason"], "policy_ecosystem"); assert_eq!(policy["counts"]["filtered"], 3); @@ -492,7 +551,10 @@ async fn hosted_filters_by_ecosystem_package_and_severity() { for r in &reqs { if r.url.path().ends_with("/patches/batch") { let body = String::from_utf8_lossy(&r.body); - assert!(!body.contains("left-pad") && !body.contains("rack"), "{body}"); + assert!( + !body.contains("left-pad") && !body.contains("rack"), + "{body}" + ); } } assert_eq!(doc["redirect"]["redirected"], 1, "{:#}", doc["redirect"]); @@ -503,13 +565,27 @@ async fn hosted_filters_by_ecosystem_package_and_severity() { async fn hosted_dry_run_makes_the_same_decisions_and_writes_nothing() { let server = MockServer::start().await; mount_api(&server, catalog()).await; - let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n")); + let repo = Repo::new(Some( + "version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n", + )); let before = repo.snapshot(); - let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[]); + let (code, doc) = scan_json( + &repo.dir("services/web"), + &server.uri(), + &["--dry-run"], + &[], + ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.snapshot(), before, "a dry run changes no bytes"); - assert_eq!(doc["redirect"]["redirected"], 2, "alpha and left-pad: {:#}", doc["redirect"]); - assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); + assert_eq!( + doc["redirect"]["redirected"], 2, + "alpha and left-pad: {:#}", + doc["redirect"] + ); + assert_eq!( + filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], + "policy_severity" + ); } #[tokio::test] @@ -517,14 +593,24 @@ async fn hosted_dry_run_makes_the_same_decisions_and_writes_nothing() { async fn path_globs_apply_default_ignores_and_ignore_paths_human() { let server = MockServer::start().await; mount_api(&server, catalog()).await; - let repo = Repo::new(Some("version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n")); + let repo = Repo::new(Some( + "version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n", + )); let legacy = repo.lock("services/legacy"); let test_lock = repo.lock("services/test"); let (code, stdout, stderr) = scan(&repo.root, &server.uri(), &["services/*"], &[]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!(repo.lock("services/web").contains(&P_ALPHA.hosted_url())); - assert_eq!(repo.lock("services/legacy"), legacy, "ignored by patches.ignorePaths"); - assert_eq!(repo.lock("services/test"), test_lock, "a discovered test/ root is a built-in ignore"); + assert_eq!( + repo.lock("services/legacy"), + legacy, + "ignored by patches.ignorePaths" + ); + assert_eq!( + repo.lock("services/test"), + test_lock, + "a discovered test/ root is a built-in ignore" + ); assert!(stdout.contains("Policy (socket.yml)"), "{stdout}"); // Named literally, the test/ root is explicit: defaults do not apply. @@ -538,7 +624,9 @@ async fn path_globs_apply_default_ignores_and_ignore_paths_human() { async fn include_paths_limit_roots() { let server = MockServer::start().await; mount_api(&server, catalog()).await; - let repo = Repo::new(Some("version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n")); + let repo = Repo::new(Some( + "version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n", + )); let web = repo.lock("services/web"); let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); @@ -571,7 +659,10 @@ async fn invalid_file_fails_closed_before_any_request_or_write() { assert!(message.contains("--no-socket-yml"), "{message}"); assert!(doc.get("policy").is_none()); assert_eq!(repo.snapshot(), before); - assert!(server.received_requests().await.unwrap().is_empty(), "no request before the policy loads"); + assert!( + server.received_requests().await.unwrap().is_empty(), + "no request before the policy loads" + ); // Human output names the code on stderr, same exit code. let (code, _, stderr) = scan(&repo.dir("services/web"), &server.uri(), &[], &[]); @@ -579,10 +670,20 @@ async fn invalid_file_fails_closed_before_any_request_or_write() { assert!(stderr.contains("socket_yml_invalid"), "{stderr}"); // --no-socket-yml (and its env var) skips the file. - let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--no-socket-yml", "--dry-run"], &[]); + let (code, doc) = scan_json( + &repo.dir("services/web"), + &server.uri(), + &["--no-socket-yml", "--dry-run"], + &[], + ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(doc["policy"]["source"], "bypassed"); - let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[("SOCKET_NO_SOCKET_YML", "1")]); + let (code, doc) = scan_json( + &repo.dir("services/web"), + &server.uri(), + &["--dry-run"], + &[("SOCKET_NO_SOCKET_YML", "1")], + ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(doc["policy"]["source"], "bypassed"); } @@ -593,7 +694,11 @@ async fn both_files_disagreeing_is_ambiguous() { let server = MockServer::start().await; mount_api(&server, catalog()).await; let repo = Repo::new(Some("version: 2\npatches:\n maxNewPatches: 1\n")); - std::fs::write(repo.root.join("socket.yaml"), "version: 2\npatches:\n maxNewPatches: 2\n").unwrap(); + std::fs::write( + repo.root.join("socket.yaml"), + "version: 2\npatches:\n maxNewPatches: 2\n", + ) + .unwrap(); let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); assert_eq!(code, 1); assert_eq!(doc["errorCode"], "socket_yml_ambiguous"); @@ -606,26 +711,66 @@ async fn severity_flag_and_env_override_the_file() { mount_api(&server, catalog()).await; let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n")); let web = repo.dir("services/web"); - let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "none"], &[]); + let (code, doc) = scan_json( + &web, + &server.uri(), + &["--dry-run", "--min-severity", "none"], + &[], + ); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["policy"]["minSeverity"], json!({"value": null, "source": "flag"})); - assert_eq!(doc["redirect"]["redirected"], 3, "beta too once the floor is lifted"); + assert_eq!( + doc["policy"]["minSeverity"], + json!({"value": null, "source": "flag"}) + ); + assert_eq!( + doc["redirect"]["redirected"], 3, + "beta too once the floor is lifted" + ); - let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "critical")]); + let (code, doc) = scan_json( + &web, + &server.uri(), + &["--dry-run"], + &[("SOCKET_MIN_SEVERITY", "critical")], + ); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["policy"]["minSeverity"], json!({"value": "critical", "source": "env"})); + assert_eq!( + doc["policy"]["minSeverity"], + json!({"value": "critical", "source": "env"}) + ); assert_eq!(doc["redirect"]["redirected"], 1); // The flag beats the env; an empty env value is unset. - let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "moderate"], &[("SOCKET_MIN_SEVERITY", "critical")]); - assert_eq!(doc["policy"]["minSeverity"], json!({"value": "medium", "source": "flag"})); - let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "")]); - assert_eq!(doc["policy"]["minSeverity"], json!({"value": "high", "source": "file"})); + let (_, doc) = scan_json( + &web, + &server.uri(), + &["--dry-run", "--min-severity", "moderate"], + &[("SOCKET_MIN_SEVERITY", "critical")], + ); + assert_eq!( + doc["policy"]["minSeverity"], + json!({"value": "medium", "source": "flag"}) + ); + let (_, doc) = scan_json( + &web, + &server.uri(), + &["--dry-run"], + &[("SOCKET_MIN_SEVERITY", "")], + ); + assert_eq!( + doc["policy"]["minSeverity"], + json!({"value": "high", "source": "file"}) + ); // Malformed values are usage errors. let (code, _, stderr) = scan(&web, &server.uri(), &["--min-severity", "severe"], &[]); assert_eq!(code, 2, "{stderr}"); - let (code, _, stderr) = scan(&web, &server.uri(), &[], &[("SOCKET_MIN_SEVERITY", "severe")]); + let (code, _, stderr) = scan( + &web, + &server.uri(), + &[], + &[("SOCKET_MIN_SEVERITY", "severe")], + ); assert_eq!(code, 2, "{stderr}"); assert!(stderr.contains("SOCKET_MIN_SEVERITY"), "{stderr}"); } @@ -643,12 +788,20 @@ async fn narrowing_after_a_hosted_patch_leaves_the_pin_byte_identical() { assert!(pinned.contains(&P_ALPHA.hosted_url())); // A newer merged patch appears, and the repo now ignores alpha. - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [alpha]\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n ignorePackages: [alpha]\n", + ) + .unwrap(); server.reset().await; mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(repo.lock("services/web"), pinned, "retained: not upgraded, not removed"); + assert_eq!( + repo.lock("services/web"), + pinned, + "retained: not upgraded, not removed" + ); let retained = &doc["policy"]["retained"][0]; assert_eq!(retained["purl"], "pkg:npm/alpha@1.0.0"); assert_eq!(retained["recordedUuid"], P_ALPHA.uuid); @@ -666,7 +819,11 @@ async fn narrowing_after_a_hosted_patch_leaves_the_pin_byte_identical() { let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.lock("services/web"), pinned, "{yml}"); - assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{yml}: {:#}", doc["policy"]); + assert_eq!( + doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", + "{yml}: {:#}", + doc["policy"] + ); } } @@ -681,9 +838,15 @@ async fn enabled_false_reports_and_writes_nothing() { assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.snapshot(), before); assert_eq!(doc["policy"]["enabled"], false); - assert!(warning_codes(&doc).contains(&"patches_disabled".to_string()), "{doc:#}"); + assert!( + warning_codes(&doc).contains(&"patches_disabled".to_string()), + "{doc:#}" + ); let reasons: Vec = filtered(&doc).into_iter().map(|(_, r)| r).collect(); - assert!(!reasons.is_empty() && reasons.iter().all(|r| r == "policy_disabled"), "{reasons:?}"); + assert!( + !reasons.is_empty() && reasons.iter().all(|r| r == "policy_disabled"), + "{reasons:?}" + ); assert_eq!(doc["redirect"]["redirected"], 0); } @@ -692,7 +855,9 @@ async fn enabled_false_reports_and_writes_nothing() { async fn report_only_json_fails_when_every_detail_query_fails() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(500)) .with_priority(1) .mount(&server) @@ -705,7 +870,10 @@ async fn report_only_json_fails_when_every_detail_query_fails() { assert_eq!(code, 1, "{doc:#}"); assert_eq!(doc["status"], "error", "{doc:#}"); assert!( - doc["error"].as_str().unwrap_or_default().contains("patch-detail queries failed"), + doc["error"] + .as_str() + .unwrap_or_default() + .contains("patch-detail queries failed"), "{doc:#}" ); assert_eq!(repo.snapshot(), before); @@ -726,7 +894,11 @@ async fn recorded_merge_below_the_floor_is_kept_until_a_more_severe_patch_is_ava "the only available patch is pinned:\n{pinned}" ); - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n minSeverity: high\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n minSeverity: high\n", + ) + .unwrap(); let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); assert_eq!( @@ -778,11 +950,17 @@ async fn floor_with_nothing_admitted_reports_the_withheld_patch() { let (code, stdout, stderr) = scan(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{stdout}\n{stderr}"); assert_eq!(repo.lock("services/web"), lock); - assert!(stdout.contains("Policy (socket.yml): 1 skipped by filters"), "{stdout}"); + assert!( + stdout.contains("Policy (socket.yml): 1 skipped by filters"), + "{stdout}" + ); // Only critical/high are named without --verbose. assert!(!stdout.contains("skipped beta"), "{stdout}"); let (_, stdout, _) = scan(&web, &server.uri(), &["--verbose"], &[]); - assert!(stdout.contains("skipped pkg:npm/beta@1.0.0 (low): low < critical"), "{stdout}"); + assert!( + stdout.contains("skipped pkg:npm/beta@1.0.0 (low): low < critical"), + "{stdout}" + ); } #[tokio::test] @@ -793,9 +971,17 @@ async fn path_outside_the_repo_is_a_usage_error() { let repo = Repo::new(None); let outside = repo.root.parent().unwrap().join("elsewhere"); write_npm_root(&outside, &["alpha"]); - let (code, _, stderr) = scan(&repo.dir("services"), &server.uri(), &["web", "../../elsewhere"], &[]); + let (code, _, stderr) = scan( + &repo.dir("services"), + &server.uri(), + &["web", "../../elsewhere"], + &[], + ); assert_eq!(code, 2, "{stderr}"); - assert!(stderr.contains("is outside") && stderr.contains("run one scan per repository"), "{stderr}"); + assert!( + stderr.contains("is outside") && stderr.contains("run one scan per repository"), + "{stderr}" + ); } #[tokio::test] @@ -803,7 +989,9 @@ async fn path_outside_the_repo_is_a_usage_error() { async fn project_ignore_paths_is_honored_without_a_patches_block() { let server = MockServer::start().await; mount_api(&server, catalog()).await; - let repo = Repo::new(Some("version: 2\nprojectIgnorePaths:\n - \"services/legacy/**\"\n")); + let repo = Repo::new(Some( + "version: 2\nprojectIgnorePaths:\n - \"services/legacy/**\"\n", + )); let legacy = repo.lock("services/legacy"); let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); @@ -813,10 +1001,22 @@ async fn project_ignore_paths_is_honored_without_a_patches_block() { assert_eq!(entry["detail"], "services/legacy/** (projectIgnorePaths)"); // A malformed projectIgnorePaths without a patches block only warns. - std::fs::write(repo.root.join("socket.yml"), "version: 2\nprojectIgnorePaths: {a: 1}\n").unwrap(); - let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &["--dry-run"], &[]); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\nprojectIgnorePaths: {a: 1}\n", + ) + .unwrap(); + let (code, doc) = scan_json( + &repo.dir("services/legacy"), + &server.uri(), + &["--dry-run"], + &[], + ); assert_eq!(code, 0, "{doc:#}"); - assert!(warning_codes(&doc).contains(&"socket_yml_ignored_value".to_string()), "{doc:#}"); + assert!( + warning_codes(&doc).contains(&"socket_yml_ignored_value".to_string()), + "{doc:#}" + ); } // --------------------------------------------------------------------------- @@ -845,14 +1045,18 @@ async fn agent_mode_applies_only_admitted_patches() { let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); assert_eq!(code, 0, "{doc:#}"); let manifest: Value = - serde_json::from_str(&std::fs::read_to_string(web.join(".socket/manifest.json")).unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(web.join(".socket/manifest.json")).unwrap()) + .unwrap(); let keys: Vec<&String> = manifest["patches"].as_object().unwrap().keys().collect(); assert_eq!(keys, ["pkg:npm/alpha@1.0.0"]); assert_eq!( std::fs::read_to_string(web.join("node_modules/alpha/index.js")).unwrap(), patched_index("alpha") ); - assert_eq!(std::fs::read_to_string(web.join("node_modules/beta/index.js")).unwrap(), orig_index("beta")); + assert_eq!( + std::fs::read_to_string(web.join("node_modules/beta/index.js")).unwrap(), + orig_index("beta") + ); } #[tokio::test] @@ -867,13 +1071,23 @@ async fn agent_mode_retains_a_recorded_patch_the_policy_now_excludes() { let manifest_before = std::fs::read(web.join(".socket/manifest.json")).unwrap(); let installed_before = std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(); - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n ecosystems: [pypi]\n", + ) + .unwrap(); server.reset().await; mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); - assert_eq!(std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(), installed_before); + assert_eq!( + std::fs::read(web.join(".socket/manifest.json")).unwrap(), + manifest_before + ); + assert_eq!( + std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(), + installed_before + ); assert_eq!(doc["policy"]["retained"][0]["reason"], "policy_ecosystem"); assert_eq!(doc["policy"]["retained"][0]["upgradeAvailable"], true); } @@ -889,7 +1103,12 @@ async fn vendored_dry_run_previews_only_admitted_patches() { mount_api(&server, catalog()).await; let repo = Repo::new(Some("version: 2\npatches:\n packages: [\"pkg:npm/beta\", \"pkg:npm/left-pad\"]\n minSeverity: medium\n")); let before = repo.snapshot(); - let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--mode", "vendored", "--dry-run"], &[]); + let (code, doc) = scan_json( + &repo.dir("services/web"), + &server.uri(), + &["--mode", "vendored", "--dry-run"], + &[], + ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.snapshot(), before); let previewed: Vec<&str> = doc["vendor"]["patches"] @@ -899,8 +1118,14 @@ async fn vendored_dry_run_previews_only_admitted_patches() { .filter_map(|p| p["purl"].as_str()) .collect(); assert_eq!(previewed, ["pkg:npm/left-pad@1.0.0"], "{doc:#}"); - assert_eq!(filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], "policy_package_not_listed"); - assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); + assert_eq!( + filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], + "policy_package_not_listed" + ); + assert_eq!( + filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], + "policy_severity" + ); } // --------------------------------------------------------------------------- @@ -932,9 +1157,16 @@ async fn get_bypasses_the_policy_with_a_warning() { let (code, stdout, stderr) = run_cli(&web, &args, &[]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); let doc: Value = serde_json::from_str(&stdout).unwrap(); - let warnings: Vec<&str> = doc["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let warnings: Vec<&str> = doc["warnings"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); assert!( - warnings.iter().any(|w| w.starts_with("(policy_bypassed)") && w.contains("alpha")), + warnings + .iter() + .any(|w| w.starts_with("(policy_bypassed)") && w.contains("alpha")), "{doc:#}" ); @@ -960,30 +1192,65 @@ async fn agent_mode_honors_path_filters_and_keeps_the_prune_universe() { // The root is excluded by path: nothing selected, and a --sync (agent // + prune) still judges the full crawl, so no entry is pruned. - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n", + ) + .unwrap(); let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!( + std::fs::read(web.join(".socket/manifest.json")).unwrap(), + manifest_before + ); assert_eq!(doc["policy"]["filtered"][0]["purl"], Value::Null); - assert_eq!(doc["policy"]["filtered"][0]["reason"], "policy_path_not_included"); - assert_eq!(doc["policy"]["counts"]["retained"], 2, "{:#}", doc["policy"]); - assert_eq!(doc["gc"]["removed"].as_array().map_or(0, Vec::len), 0, "{:#}", doc["gc"]); + assert_eq!( + doc["policy"]["filtered"][0]["reason"], + "policy_path_not_included" + ); + assert_eq!( + doc["policy"]["counts"]["retained"], 2, + "{:#}", + doc["policy"] + ); + assert_eq!( + doc["gc"]["removed"].as_array().map_or(0, Vec::len), + 0, + "{:#}", + doc["gc"] + ); // A narrower ecosystem list under --sync prunes nothing either. - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n ecosystems: [pypi]\n", + ) + .unwrap(); let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!( + std::fs::read(web.join(".socket/manifest.json")).unwrap(), + manifest_before + ); // patches.enabled: false skips the GC entirely. std::fs::remove_dir_all(web.join("node_modules/beta")).unwrap(); - let pkg_lock = repo.lock("services/web").replace("\"node_modules/beta\"", "\"node_modules/gone\""); + let pkg_lock = repo + .lock("services/web") + .replace("\"node_modules/beta\"", "\"node_modules/gone\""); std::fs::write(web.join("package-lock.json"), pkg_lock).unwrap(); - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n enabled: false\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n enabled: false\n", + ) + .unwrap(); let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); assert_eq!(code, 0, "{doc:#}"); assert!(doc.get("gc").is_none(), "{doc:#}"); - assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!( + std::fs::read(web.join(".socket/manifest.json")).unwrap(), + manifest_before + ); } #[tokio::test] @@ -997,29 +1264,53 @@ async fn narrowing_after_vendoring_leaves_the_vendored_package_byte_identical() let before = compute_git_sha256_from_bytes(orig_index("alpha").as_bytes()); let after = compute_git_sha256_from_bytes(patched_index("alpha").as_bytes()); std::fs::create_dir_all(web.join(".socket/blobs")).unwrap(); - std::fs::write(web.join(".socket/blobs").join(&after), patched_index("alpha")).unwrap(); + std::fs::write( + web.join(".socket/blobs").join(&after), + patched_index("alpha"), + ) + .unwrap(); let manifest = json!({"patches": {P_ALPHA.purl(): { "uuid": P_ALPHA.uuid, "exportedAt": "2026-01-01T00:00:00Z", "files": {"package/index.js": {"beforeHash": before, "afterHash": after}}, "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free" }}}); - std::fs::write(web.join(".socket/manifest.json"), serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + std::fs::write( + web.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); let fixture = prebuilt_common::Server::project(&web); let (code, stdout, stderr) = run_cli( &web, &["vendor", "--json", "--cwd", web.to_str().unwrap()], - &[("SOCKET_VENDOR_URL", &fixture.uri), ("SOCKET_PATCH_SERVER_URL", &fixture.uri)], + &[ + ("SOCKET_VENDOR_URL", &fixture.uri), + ("SOCKET_PATCH_SERVER_URL", &fixture.uri), + ], ); assert_eq!(code, 0, "vendor fixture: {stdout}\n{stderr}"); - assert!(repo.lock("services/web").contains(".socket/vendor/"), "vendored lock"); + assert!( + repo.lock("services/web").contains(".socket/vendor/"), + "vendored lock" + ); let snapshot = repo.snapshot(); - std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [\"pkg:npm/alpha\"]\n").unwrap(); + std::fs::write( + repo.root.join("socket.yml"), + "version: 2\npatches:\n ignorePackages: [\"pkg:npm/alpha\"]\n", + ) + .unwrap(); let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "vendored"], &[]); assert_eq!(code, 0, "{doc:#}"); let mut after_scan = repo.snapshot(); after_scan.remove("socket.yml"); - assert_eq!(after_scan, snapshot, "the vendored package, its lock wiring and ledger stay byte-identical"); - assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{:#}", doc["policy"]); + assert_eq!( + after_scan, snapshot, + "the vendored package, its lock wiring and ledger stay byte-identical" + ); + assert_eq!( + doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", + "{:#}", + doc["policy"] + ); } - diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs index 83a8de749..0dd0998af 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs @@ -152,7 +152,11 @@ fn committed_pre_v5_ledger_lets_a_hosted_pin_attest_offline() { ); } api.assert_no_requests(); - assert_eq!(std::fs::read(&ledger).unwrap(), before, "vex never rewrites it"); + assert_eq!( + std::fs::read(&ledger).unwrap(), + before, + "vex never rewrites it" + ); let other = "0b0b0b0b-0b0b-4b0b-8b0b-0b0b0b0b0b0b"; let mut stale = left_pad_view(); diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs index ce5d1144b..ddadbb45d 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs @@ -571,9 +571,9 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap(); let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap(); assert!( - root_pkg["resolutions"] - .as_object() - .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) + root_pkg["resolutions"].as_object().is_some_and(|r| r + .iter() + .any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) && v.as_str() == Some(hosted_url.as_str()))), "package.json must route {DEP} to the hosted tarball: {root_pkg}" ); @@ -596,7 +596,10 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap(); let before: serde_json::Value = serde_json::from_slice(&pkg_before).unwrap(); after.as_object_mut().unwrap().shift_remove("resolutions"); - assert_eq!(after, before, "the hosted pin only adds `resolutions` to package.json"); + assert_eq!( + after, before, + "the hosted pin only adds `resolutions` to package.json" + ); } eprintln!("HOSTED REWIRE OK"); @@ -625,7 +628,10 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes eprintln!("FRESH INSTALL + YARN NODE RESOLUTION OK"); // MANIFEST-LESS VEX over the hosted wiring (see `yarn_berry_common`). - let registry_state = [("yarn.lock", registry_lock), ("package.json", pkg_before.clone())]; + let registry_state = [ + ("yarn.lock", registry_lock), + ("package.json", pkg_before.clone()), + ]; let yarn = |cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env); let api_url = server.uri(); diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs index d2aec0078..2794a4781 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs @@ -566,9 +566,9 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap(); let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap(); assert!( - root_pkg["resolutions"] - .as_object() - .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) + root_pkg["resolutions"].as_object().is_some_and(|r| r + .iter() + .any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:")) && v.as_str() == Some(hosted_url.as_str()))), "package.json must route {DEP} to the hosted tarball: {root_pkg}" ); @@ -596,7 +596,10 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap(); let before: serde_json::Value = serde_json::from_slice(&root_pkg_before).unwrap(); after.as_object_mut().unwrap().shift_remove("resolutions"); - assert_eq!(after, before, "the hosted pin only adds `resolutions` to the root package.json"); + assert_eq!( + after, before, + "the hosted pin only adds `resolutions` to the root package.json" + ); } assert_eq!( std::fs::read(proj.join("packages/app/package.json")).unwrap(), @@ -630,7 +633,10 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { eprintln!("FRESH INSTALL + MEMBER RESOLUTION OK"); // MANIFEST-LESS VEX over the hosted wiring (see `yarn_berry_common`). - let registry_state = [("yarn.lock", registry_lock), ("package.json", root_pkg_before.clone())]; + let registry_state = [ + ("yarn.lock", registry_lock), + ("package.json", root_pkg_before.clone()), + ]; let yarn = |cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env); let api_url = server.uri(); diff --git a/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs index e32b4ea97..6cdd44ef1 100644 --- a/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs @@ -469,8 +469,16 @@ fn get_help_lists_all_identifier_flags() { ); } // Help text is for users: no implementation notes from the source. - for leak in ["value_parser", "parse_bool_flag", "No env binding", "locally- installed"] { - assert!(!stdout.contains(leak), "get --help leaks {leak:?}: {stdout}"); + for leak in [ + "value_parser", + "parse_bool_flag", + "No env binding", + "locally- installed", + ] { + assert!( + !stdout.contains(leak), + "get --help leaks {leak:?}: {stdout}" + ); } } diff --git a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs index 25bdadd21..a86958fe8 100644 --- a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs @@ -211,7 +211,10 @@ fn assert_rollback_noop(stdout: &str) { r["skipped"], "package_not_installed", "a no-op rollback may carry only not-installed markers; envelope={v}" ); - assert!(r["path"].is_null(), "marker path must be null; envelope={v}"); + assert!( + r["path"].is_null(), + "marker path must be null; envelope={v}" + ); assert!( r.get("success").is_none() && r.get("error").is_none(), "markers carry no success/error keys; envelope={v}" diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 9b3280e8a..467ed46c5 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -61,7 +61,11 @@ fn every_help_page_has_no_developer_notes() { names.extend(cmd.get_subcommands().map(|s| s.get_name().to_string())); let mut failures = Vec::new(); for name in &names { - let path: Vec<&str> = if name.is_empty() { vec![] } else { vec![name.as_str()] }; + let path: Vec<&str> = if name.is_empty() { + vec![] + } else { + vec![name.as_str()] + }; let text = long_help(&path); let found = leaks(&text); if !found.is_empty() { @@ -147,7 +151,9 @@ fn vex_product_list_renders_one_item_per_line() { fn root_command_list_uses_the_verb_form() { let text = long_help(&[]); assert!( - text.contains("Undo patches: restore original files and unwind hosted or vendored lockfile wiring"), + text.contains( + "Undo patches: restore original files and unwind hosted or vendored lockfile wiring" + ), "{text}" ); assert!(!text.contains("Rollback patches"), "{text}"); @@ -258,11 +264,24 @@ fn short_help_lists_about_eight_options_and_long_help_lists_all() { .filter(|l| l.starts_with('-') && !l.starts_with("-h,") && !l.starts_with("-V,")) .count() }; - assert!(count(&short) <= 9, "{name} -h lists {} options:\n{short}", count(&short)); - assert!(count(&long) > count(&short), "{name} --help must list more than -h"); - assert!(short.contains("--json") && short.contains("--cwd"), "{name}"); + assert!( + count(&short) <= 9, + "{name} -h lists {} options:\n{short}", + count(&short) + ); + assert!( + count(&long) > count(&short), + "{name} --help must list more than -h" + ); + assert!( + short.contains("--json") && short.contains("--cwd"), + "{name}" + ); } let scan = cmd.find_subcommand_mut("scan").expect("scan"); let long = scan.render_long_help().to_string(); - assert!(!long.contains("--apply") && !long.contains("--vendor "), "{long}"); + assert!( + !long.contains("--apply") && !long.contains("--vendor "), + "{long}" + ); } diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index a340abb23..54826f34d 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -984,7 +984,8 @@ async fn a_vlt_project_is_withheld_as_offline() { .and_then(|w| w["detail"].as_str()) .expect("the preflight warning is reported"); assert!( - detail.contains("/patch/npm//") && detail.contains(": offline; nothing was written"), + detail.contains("/patch/npm//") + && detail.contains(": offline; nothing was written"), "the offline refusal quotes the redacted URL" ); assert!(output.changed_files.is_empty()); diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index b297eaf79..0af392eb4 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -754,7 +754,11 @@ fn policy_repo(socket_yml: &str) -> (Vec, BTreeMap>) { let mut patches = patches_from_overrides(&npm.join("overrides.json"), None); patches.extend(patches_from_overrides(&cargo.join("overrides.json"), None)); let mut repo: BTreeMap> = BTreeMap::new(); - for (root, dir) in [("apps/web", &npm), ("apps/legacy", &npm), ("services/api", &cargo)] { + for (root, dir) in [ + ("apps/web", &npm), + ("apps/legacy", &npm), + ("services/api", &cargo), + ] { for (rel, bytes) in fixture_files(&dir.join("input")) { repo.insert(format!("{root}/{rel}"), bytes); } @@ -773,7 +777,9 @@ fn two_phase( socket_patch_cli::hosted_memory::PathSelection, socket_patch_cli::hosted_memory::HostedScanInput, ) { - use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; + use socket_patch_cli::hosted_memory::{ + select_paths, PolicyFileInput, SelectOptions, TreeEntryInput, + }; let entries: Vec = files .iter() .map(|(p, bytes)| TreeEntryInput { @@ -814,15 +820,23 @@ fn two_phase( (selection, input) } -fn policy_input(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanInput { +fn policy_input( + files: &BTreeMap>, +) -> socket_patch_cli::hosted_memory::HostedScanInput { let (selection, input) = two_phase(files, options(false)); - assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert!( + selection.policy_error.is_none(), + "{:?}", + selection.policy_error + ); input } /// Session options as selection of `files` would hand them over, without /// going through selection (for inputs a host may get wrong). -fn policy_options(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanOptions { +fn policy_options( + files: &BTreeMap>, +) -> socket_patch_cli::hosted_memory::HostedScanOptions { let (selection, _) = two_phase(files, options(false)); let mut opts = options(false); opts.policy_paths = Some(selection.policy_paths); @@ -854,25 +868,44 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { let server = MockServer::start().await; mount_api(&server, &patches).await; let (selection, input) = two_phase(&repo, options(false)); - assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert!( + selection.policy_error.is_none(), + "{:?}", + selection.policy_error + ); let memory = run_engine(&server, input).await; assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); - assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); + assert_eq!( + roots, + vec!["apps/web", "services/api"], + "the ignored root is not processed" + ); // Selection reports the root it excluded; nothing of it is streamed. assert!(selection .ignored_sample .iter() .any(|i| i.path == "apps/legacy/package-lock.json" && i.reason == "policy_path_excluded")); - assert!(!selection.fetch_text.iter().chain(&selection.present_only).any(|p| p.starts_with("apps/legacy/"))); + assert!(!selection + .fetch_text + .iter() + .chain(&selection.present_only) + .any(|p| p.starts_with("apps/legacy/"))); let memory_policy = memory.policy.clone().expect("policy block"); assert_eq!(memory_policy["source"], "file"); let mut disk_filtered = std::collections::BTreeSet::new(); for root in ["apps/web", "apps/legacy", "services/api"] { let disk = run_disk_in(&server, &repo, root, false); - assert_eq!(disk.envelope["status"], "success", "{root}: {}", disk.stderr); - assert_eq!(disk.envelope["policy"]["sha256"], memory_policy["sha256"], "{root}"); + assert_eq!( + disk.envelope["status"], "success", + "{root}: {}", + disk.stderr + ); + assert_eq!( + disk.envelope["policy"]["sha256"], memory_policy["sha256"], + "{root}" + ); disk_filtered.extend(filtered_set(&disk.envelope["policy"])); if let Some(project) = memory.projects.iter().find(|p| p.root == root) { assert_eq!(project.redirect, disk.envelope["redirect"], "{root}"); @@ -883,13 +916,24 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { .collect(); assert_eq!(memory_changed, disk.changed, "{root}"); } else { - assert!(disk.changed.is_empty(), "{root}: an ignored root changes nothing"); + assert!( + disk.changed.is_empty(), + "{root}: an ignored root changes nothing" + ); } } let mut memory_filtered = filtered_set(&memory_policy); - memory_filtered.insert(("apps/legacy".to_string(), None, "policy_path_excluded".to_string())); + memory_filtered.insert(( + "apps/legacy".to_string(), + None, + "policy_path_excluded".to_string(), + )); assert_eq!(memory_filtered, disk_filtered); - assert!(disk_filtered.contains(&("apps/legacy".to_string(), None, "policy_path_excluded".to_string()))); + assert!(disk_filtered.contains(&( + "apps/legacy".to_string(), + None, + "policy_path_excluded".to_string() + ))); assert!(disk_filtered.contains(&( "services/api".to_string(), Some("pkg:cargo/serde@1.0.190".to_string()), @@ -903,9 +947,17 @@ async fn parity_socket_yml_severity_floor() { let server = MockServer::start().await; mount_api(&server, &patches).await; let memory = run_engine(&server, policy_input(&repo)).await; - let web = memory.projects.iter().find(|p| p.root == "apps/web").unwrap(); + let web = memory + .projects + .iter() + .find(|p| p.root == "apps/web") + .unwrap(); assert!(web.redirected.is_empty(), "{:#}", web.redirect); - assert!(web.skipped.iter().any(|s| s.reason == "policy_severity"), "{:?}", web.skipped); + assert!( + web.skipped.iter().any(|s| s.reason == "policy_severity"), + "{:?}", + web.skipped + ); assert!(engine_changed(&memory).is_empty()); let disk = run_disk_in(&server, &repo, "apps/web", false); assert!(disk.changed.is_empty()); @@ -931,8 +983,15 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { assert_eq!(err.code, "socket_yml_invalid"); assert!(out.projects.is_empty() && out.changed_files.is_empty() && out.policy.is_none()); // Streamed present-without-content. - let out = run_engine(&server, build_input(&withheld, &["socket.yml"], opts.clone())).await; - assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + let out = run_engine( + &server, + build_input(&withheld, &["socket.yml"], opts.clone()), + ) + .await; + assert_eq!( + out.policy_error.expect("policyError").code, + "socket_yml_invalid" + ); // Content other than what selection read. let mut changed = repo.clone(); changed.insert("socket.yml".to_string(), b"version: 2\n".to_vec()); @@ -943,7 +1002,10 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let mut no_sha = opts.clone(); no_sha.policy_sha256 = None; let out = run_engine(&server, build_input(&repo, &[], no_sha)).await; - assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + assert_eq!( + out.policy_error.expect("policyError").code, + "socket_yml_invalid" + ); // Invalid content: selection refuses it before anything is fetched. let (_, bad) = policy_repo("version: 2\npatches:\n apiUrl: https://evil.example\n"); let (selection, _) = two_phase(&bad, options(false)); @@ -958,7 +1020,10 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let mut half = opts.clone(); half.no_socket_yml = Some(true); let out = run_engine(&server, build_input(&repo, &[], half)).await; - assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + assert_eq!( + out.policy_error.expect("policyError").code, + "socket_yml_invalid" + ); // noSocketYml skips it on both sides. let mut bypass = options(false); bypass.no_socket_yml = Some(true); @@ -979,7 +1044,10 @@ async fn memory_min_severity_option_beats_the_file() { let (_, input) = two_phase(&repo, opts); let out = run_engine(&server, input).await; let policy = out.policy.unwrap(); - assert_eq!(policy["minSeverity"], serde_json::json!({"value": null, "source": "flag"})); + assert_eq!( + policy["minSeverity"], + serde_json::json!({"value": null, "source": "flag"}) + ); assert!(out.projects.iter().any(|p| !p.redirected.is_empty())); let mut bad = options(false); bad.min_severity = Some("severe".to_string()); @@ -988,7 +1056,9 @@ async fn memory_min_severity_option_beats_the_file() { #[test] fn selection_applies_the_path_policy_and_fails_closed() { - use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; + use socket_patch_cli::hosted_memory::{ + select_paths, PolicyFileInput, SelectOptions, TreeEntryInput, + }; let blob = |path: &str, mode: &str| TreeEntryInput { path: path.to_string(), mode: mode.to_string(), @@ -1014,19 +1084,34 @@ fn selection_applies_the_path_policy_and_fails_closed() { }; let yml = "version: 2\npatches:\n ignorePaths: [\"/apps/old/\"]\n"; let selection = select_paths(&entries, &with(vec![text("socket.yml", yml)])); - assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert!( + selection.policy_error.is_none(), + "{:?}", + selection.policy_error + ); assert_eq!(selection.policy_paths, vec!["socket.yml"]); assert_eq!(selection.policy_sha256.as_ref().map(String::len), Some(64)); assert!(selection.fetch_text.contains(&"socket.yml".to_string())); assert_eq!(selection.roots, vec!["apps/web"]); // Excluded roots (file list and built-in ignores, any case) are // reported and never streamed. - for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { + for path in [ + "apps/old/yarn.lock", + "apps/web/tests/app/package-lock.json", + "Fixtures/x/yarn.lock", + ] { assert!( - selection.ignored_sample.iter().any(|i| i.path == path && i.reason == "policy_path_excluded"), + selection + .ignored_sample + .iter() + .any(|i| i.path == path && i.reason == "policy_path_excluded"), "{path}: {selection:?}" ); - assert!(!selection.fetch_text.contains(&path.to_string()) && !selection.present_only.contains(&path.to_string()), "{path}"); + assert!( + !selection.fetch_text.contains(&path.to_string()) + && !selection.present_only.contains(&path.to_string()), + "{path}" + ); } // Named roots are explicit: the built-in ignores do not apply. let named = select_paths( @@ -1044,9 +1129,16 @@ fn selection_applies_the_path_policy_and_fails_closed() { text: None, missing: Some(true), }; - for files in [vec![], vec![missing], vec![text("socket.yml", "version: 2\npatches:\n apiUrl: x\n")]] { + for files in [ + vec![], + vec![missing], + vec![text("socket.yml", "version: 2\npatches:\n apiUrl: x\n")], + ] { let out = select_paths(&entries, &with(files)); - assert_eq!(out.policy_error.as_ref().map(|e| e.code.as_str()), Some("socket_yml_invalid")); + assert_eq!( + out.policy_error.as_ref().map(|e| e.code.as_str()), + Some("socket_yml_invalid") + ); assert!(out.roots.is_empty() && out.fetch_text.is_empty(), "{out:?}"); assert_eq!(out.policy_paths, vec!["socket.yml"]); } @@ -1054,8 +1146,14 @@ fn selection_applies_the_path_policy_and_fails_closed() { assert!(out.policy_error.is_some()); // A symlinked policy file is never read. entries.push(blob("socket.yaml", "120000")); - let out = select_paths(&entries, &with(vec![text("socket.yml", yml), text("socket.yaml", yml)])); - assert_eq!(out.policy_error.map(|e| e.code), Some("socket_yml_invalid".to_string())); + let out = select_paths( + &entries, + &with(vec![text("socket.yml", yml), text("socket.yaml", yml)]), + ); + assert_eq!( + out.policy_error.map(|e| e.code), + Some("socket_yml_invalid".to_string()) + ); // noSocketYml: only the built-in ignores; the file need not be passed. let out = select_paths( &entries, @@ -1086,8 +1184,13 @@ async fn memory_negation_reincludes_a_default_ignored_root() { ); let (selection, input) = two_phase(&repo, options(false)); assert_eq!(selection.roots, vec!["e2e/tests"]); - assert!(selection.fetch_text.contains(&"e2e/tests/package-lock.json".to_string())); - assert!(!selection.fetch_text.iter().any(|p| p.starts_with("x/")), "{selection:?}"); + assert!(selection + .fetch_text + .contains(&"e2e/tests/package-lock.json".to_string())); + assert!( + !selection.fetch_text.iter().any(|p| p.starts_with("x/")), + "{selection:?}" + ); assert!(selection .ignored_sample .iter() @@ -1095,19 +1198,31 @@ async fn memory_negation_reincludes_a_default_ignored_root() { let memory = run_engine(&server, input).await; let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); - assert!(!memory.projects[0].redirected.is_empty(), "{:#}", memory.projects[0].redirect); + assert!( + !memory.projects[0].redirected.is_empty(), + "{:#}", + memory.projects[0].redirect + ); // Given every root anyway, the session applies the same filter itself. let direct = run_engine(&server, build_input(&repo, &[], policy_options(&repo))).await; let roots: Vec<&str> = direct.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); let entry = &direct.policy.as_ref().unwrap()["filtered"][0]; - assert_eq!((entry["project"].as_str(), entry["detail"].as_str()), (Some("x/tests"), Some("tests/ (built-in default)"))); + assert_eq!( + (entry["project"].as_str(), entry["detail"].as_str()), + (Some("x/tests"), Some("tests/ (built-in default)")) + ); // Disk patches the same root the same way. let disk = run_disk_in(&server, &repo, "e2e/tests", false); assert_eq!(disk.envelope["status"], "success", "{}", disk.stderr); assert_eq!(memory.projects[0].redirect, disk.envelope["redirect"]); let memory_changed = engine_changed(&memory); - assert_eq!(memory_changed, disk.changed, "{}", describe(&memory_changed)); + assert_eq!( + memory_changed, + disk.changed, + "{}", + describe(&memory_changed) + ); } diff --git a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs index ccaf92cc4..3c104abf4 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs @@ -237,7 +237,9 @@ async fn memory_selected( files: &BTreeMap>, mut o: HostedScanOptions, ) -> HostedScanOutput { - use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; + use socket_patch_cli::hosted_memory::{ + select_paths, PolicyFileInput, SelectOptions, TreeEntryInput, + }; let entries: Vec = files .iter() .map(|(p, bytes)| TreeEntryInput { @@ -265,7 +267,11 @@ async fn memory_selected( ..SelectOptions::default() }, ); - assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert!( + selection.policy_error.is_none(), + "{:?}", + selection.policy_error + ); let fetched: BTreeMap> = selection .fetch_text .iter() @@ -424,7 +430,11 @@ async fn socket_yml_policy_and_cap_converge_on_disk_and_in_memory() { b"version: 2\npatches:\n includePaths: [\"/apps/\"]\n minSeverity: high\n maxNewPatches: 2\n" .to_vec(), ); - lock(&mut files, "apps/one", &["mem-a", "mem-b", "mem-c", "mem-d", "mem-e"]); + lock( + &mut files, + "apps/one", + &["mem-a", "mem-b", "mem-c", "mem-d", "mem-e"], + ); lock(&mut files, "apps/two", &["mem-b", "mem-c", "mem-d"]); lock(&mut files, "legacy", &["mem-b", "mem-e"]); let dirs = ["apps/one", "apps/two", "legacy"]; @@ -435,8 +445,16 @@ async fn socket_yml_policy_and_cap_converge_on_disk_and_in_memory() { }; let expected: [Vec>; 3] = [ vec![vec!["mem-e", "mem-b"], vec!["mem-b"], vec![]], - vec![vec!["mem-e", "mem-b", "mem-c"], vec!["mem-b", "mem-c"], vec![]], - vec![vec!["mem-e", "mem-b", "mem-c"], vec!["mem-b", "mem-c"], vec![]], + vec![ + vec!["mem-e", "mem-b", "mem-c"], + vec!["mem-b", "mem-c"], + vec![], + ], + vec![ + vec!["mem-e", "mem-b", "mem-c"], + vec!["mem-b", "mem-c"], + vec![], + ], ]; let mut mem_files = files.clone(); @@ -449,7 +467,10 @@ async fn socket_yml_policy_and_cap_converge_on_disk_and_in_memory() { "run {}", run + 1 ); - assert_eq!(mem.policy.as_ref().map(|p| p["source"].clone()), Some(json!("file"))); + assert_eq!( + mem.policy.as_ref().map(|p| p["source"].clone()), + Some(json!("file")) + ); mem_files = apply(&mem_files, &mem); assert_eq!(&pins(&mem_files), want, "memory run {}", run + 1); @@ -469,7 +490,14 @@ async fn socket_yml_policy_and_cap_converge_on_disk_and_in_memory() { let (code, stdout, changed) = run_disk_args( &server, &disk_files, - &["--no-socket-yml", "--max-new-patches", "1", "apps/one", "apps/two", "legacy"], + &[ + "--no-socket-yml", + "--max-new-patches", + "1", + "apps/one", + "apps/two", + "legacy", + ], ); assert_eq!(code, 0, "{stdout}"); disk_files.extend(changed); diff --git a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs index db2aab79f..6ce32e653 100644 --- a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs @@ -519,7 +519,11 @@ fn maven_hosted_get_state_attests_without_manifest( &[(purl, vlt_hosted_common::legacy_record_from_view(&view))], ); let out = run_vex(&binary(), project, &offline); - assert_eq!(out.code, Some(0), "a pre-v5 ledger record serves offline: {out}"); + assert_eq!( + out.code, + Some(0), + "a pre-v5 ledger record serves offline: {out}" + ); assert_attested(out.doc(), purl, uuid, Marker::Redirected, &vulns); quiet.assert_no_requests(); @@ -800,7 +804,11 @@ fn nuget_hosted_manifestless_vex(root: &Path, uuid: &str, purl: &str) { &[(purl, vlt_hosted_common::legacy_record_from_view(&view))], ); let out = run(VexRun::offline()); - assert_eq!(out.code, Some(0), "a pre-v5 ledger record serves offline: {out}"); + assert_eq!( + out.code, + Some(0), + "a pre-v5 ledger record serves offline: {out}" + ); assert_attested(out.doc(), purl, uuid, Marker::Redirected, vulns); std::fs::write( diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 066983c7a..a3e2a6c77 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -851,9 +851,10 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto "{label}: rollback restores the pristine CRLF lock (upstream checksum \ re-derived from the registry tarball)" ); - let pkg: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(tmp.path().join("package.json")).unwrap()) - .unwrap(); + let pkg: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + ) + .unwrap(); assert!( pkg.get("resolutions").is_none(), "{label}: rollback drops the resolutions pin: {pkg}" diff --git a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs index 61ec4bd3f..a78d10282 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs @@ -308,11 +308,15 @@ async fn scan_redirect_vlt_artifact_fetch_error() { let detail = warning_detail(&doc, UNVERIFIABLE); let redacted = url.replace(&format!("/{TOKEN}/"), "//"); assert!( - detail.starts_with(&format!("vlt would fail to verify {redacted}: fetch error ")) - && detail.ends_with(&format!("; nothing was written for {PURL}")), + detail.starts_with(&format!( + "vlt would fail to verify {redacted}: fetch error " + )) && detail.ends_with(&format!("; nothing was written for {PURL}")), "the fetch-error refusal quotes the redacted URL" ); - assert!(!detail.contains(TOKEN), "the grant token never reaches the warning"); + assert!( + !detail.contains(TOKEN), + "the grant token never reaches the warning" + ); } async fn redirect_chain(hops: usize) -> (Value, tempfile::TempDir) { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index c7adfe131..f74c5c90c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -187,7 +187,9 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -368,9 +370,12 @@ fn legacy_record(view: &serde_json::Value) -> serde_json::Value { .remove("publishedAt") .unwrap_or_else(|| serde_json::json!("2024-01-01T00:00:00Z")); obj.insert("exportedAt".to_string(), exported); - obj.entry("description").or_insert_with(|| serde_json::json!("x")); - obj.entry("license").or_insert_with(|| serde_json::json!("MIT")); - obj.entry("tier").or_insert_with(|| serde_json::json!("free")); + obj.entry("description") + .or_insert_with(|| serde_json::json!("x")); + obj.entry("license") + .or_insert_with(|| serde_json::json!("MIT")); + obj.entry("tier") + .or_insert_with(|| serde_json::json!("free")); record } @@ -441,7 +446,11 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); + assert_eq!( + read(&lock_path), + redirected, + "re-scan must not touch the lock" + ); // 3. The committed state, manifest-less, attests (and only while wired). assert_manifestless_vex(tmp.path(), LOCK); @@ -494,12 +503,19 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { .iter() .filter(|r| r.url.path().ends_with(&format!("/patches/view/{UUID}"))) .count(); - assert_eq!(views, 1, "the pdm redirect must be confirmed despite the hatch backend"); + assert_eq!( + views, 1, + "the pdm redirect must be confirmed despite the hatch backend" + ); assert_manifestless_vex(tmp.path(), LOCK); let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback must succeed"); - assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock"); + assert_eq!( + read(&lock_path), + LOCK, + "rollback must restore the pristine lock" + ); } /// The legacy `[metadata.files]` lock (lock_version 2) redirects the package diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 3c33af6d0..e8f743ccb 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -60,7 +60,8 @@ const MAJOR_ENV: &str = socket_patch_core::utils::pipenv::MAJOR_OVERRIDE_ENV; const LOCK: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile.lock"); -const PIPFILE: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); +const PIPFILE: &str = + include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); /// The upstream and patched bytes of the record's one file, so the venv /// tests can materialize a real `Ready` (upstream) install. @@ -125,7 +126,9 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -374,8 +377,15 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { ); let before: serde_json::Value = serde_json::from_str(LOCK).unwrap(); let after: serde_json::Value = serde_json::from_str(&redirected).unwrap(); - assert_eq!(after["_meta"], before["_meta"], "the Pipfile content hash stays"); - assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE, "Pipfile untouched"); + assert_eq!( + after["_meta"], before["_meta"], + "the Pipfile content hash stays" + ); + assert_eq!( + read(&tmp.path().join("Pipfile")), + PIPFILE, + "Pipfile untouched" + ); assert_no_ledger(tmp.path()); // Attested from this run's fetched record (keyed by RECORD_PURL, assume // applied) although the base purl the run confirmed differs from the @@ -383,13 +393,25 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); - assert_eq!(statements[0]["vulnerability"]["name"].as_str(), Some(GHSA), "{vex}"); - assert_eq!(statements[0]["status"].as_str(), Some("not_affected"), "{vex}"); + assert_eq!( + statements[0]["vulnerability"]["name"].as_str(), + Some(GHSA), + "{vex}" + ); + assert_eq!( + statements[0]["status"].as_str(), + Some("not_affected"), + "{vex}" + ); // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); + assert_eq!( + read(&lock_path), + redirected, + "re-scan must not touch the lock" + ); assert_no_ledger(tmp.path()); // Manifest-less VEX over the committed state (the depscan / CI shape). @@ -399,7 +421,11 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { // 3. rollback restores the upstream registry entry. roll_back(tmp.path(), &server).await; - assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte"); + assert_eq!( + read(&lock_path), + LOCK, + "rollback must restore the pristine lock byte for byte" + ); } #[tokio::test] @@ -422,7 +448,10 @@ async fn legacy_installer_major_selects_path_references() { "Pipenv 7–11 install `path` references: {redirected}" ); assert!(entry.get("file").is_none(), "{entry}"); - assert_eq!(entry["hashes"], serde_json::json!([format!("sha256:{}", sha256())])); + assert_eq!( + entry["hashes"], + serde_json::json!([format!("sha256:{}", sha256())]) + ); // The legacy `path` reference is discovered just like `file`. manifestless_vex(tmp.path(), "pipenv legacy path", &|p: &Path| { @@ -443,7 +472,9 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { write_project(tmp.path()); // The Pipfile.lock left behind pins a DIFFERENT package; the project // installs from requirements.txt. - let stale = LOCK.replace("\"urllib3\"", "\"six\"").replace("==1.26.18", "==1.16.0"); + let stale = LOCK + .replace("\"urllib3\"", "\"six\"") + .replace("==1.26.18", "==1.16.0"); std::fs::write(tmp.path().join("Pipfile.lock"), &stale).unwrap(); // An unpatched, unhashed sibling makes the file's hash mode derivable, // so rollback can restore the hosted line (a file whose every line is a @@ -471,10 +502,7 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { }); roll_back(tmp.path(), &server).await; - assert_eq!( - read(&tmp.path().join("requirements.txt")), - REQS - ); + assert_eq!(read(&tmp.path().join("requirements.txt")), REQS); assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale); } @@ -559,16 +587,27 @@ async fn warm_venv_with_the_upstream_release_is_not_attested() { // attested and the embedded-VEX contract fails the command. let code = run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; let redirected = read(&lock_path); - assert!(redirected.contains(HOSTED_URL), "the lock is still repointed: {redirected}"); + assert!( + redirected.contains(HOSTED_URL), + "the lock is still repointed: {redirected}" + ); let attested = vex_path .exists() .then(|| serde_json::from_str::(&read(&vex_path)).unwrap()) .and_then(|v| v["statements"].as_array().map(Vec::len)) .unwrap_or(0); - assert_eq!(attested, 0, "a stale install must not be attested from the fetched record"); + assert_eq!( + attested, 0, + "a stale install must not be attested from the fetched record" + ); assert_ne!(code, 0, "nothing to attest fails the embedded-VEX run"); assert_eq!( - std::fs::read(site_packages(tmp.path()).join("urllib3").join("response.py")).unwrap(), + std::fs::read( + site_packages(tmp.path()) + .join("urllib3") + .join("response.py") + ) + .unwrap(), UPSTREAM, "the probe is read-only" ); diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 4519d1d4d..a0393d106 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -56,7 +56,10 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { }))) .mount(server) .await; - std::env::set_var("SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri())); + std::env::set_var( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ); let code = rollback::run(RollbackArgs { targets: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -805,7 +808,11 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_legacy_ledger_and_api() { ..VexRun::offline() }, ); - assert_eq!(out.code, Some(0), "[{lock_name}] legacy ledger, offline: {out}"); + assert_eq!( + out.code, + Some(0), + "[{lock_name}] legacy ledger, offline: {out}" + ); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); assert_eq!(api.request_count(), seen); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index d1b5b1607..605731fc5 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1144,8 +1144,9 @@ async fn a_git_pattern_hosted_pin_is_refused_not_restored_to_the_registry() { assert!( envelope["hosted"]["failed"][0]["error"] .as_str() - .is_some_and(|e| e.contains("installs from git") - && e.contains("`git checkout -- yarn.lock`")), + .is_some_and( + |e| e.contains("installs from git") && e.contains("`git checkout -- yarn.lock`") + ), "{envelope}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 36eb10e12..631ad53f6 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1113,7 +1113,10 @@ async fn berry_stale_pnp_loader_under_non_pnp_linker_vendors() { #[tokio::test] async fn berry_lock_only_pnp_project_refused_up_front() { for (label, yarnrc) in [ - ("explicit pnp", Some("nodeLinker: pnp\nenableGlobalCache: false\n")), + ( + "explicit pnp", + Some("nodeLinker: pnp\nenableGlobalCache: false\n"), + ), ("default linker", Some("enableGlobalCache: false\n")), ("no yarnrc", None), ] { diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e/vlt.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e/vlt.rs index 8779d2e84..9c08880b2 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e/vlt.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e/vlt.rs @@ -221,7 +221,10 @@ async fn vlt_repair_reports_a_missing_ledger() { lock_bytes, "{lock:?}" ); - assert!(tmp.path().join(rel()).join("index.js").is_file(), "{lock:?}"); + assert!( + tmp.path().join(rel()).join("index.js").is_file(), + "{lock:?}" + ); } } diff --git a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs index d4830cbd9..31f457502 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs @@ -533,8 +533,7 @@ fn bare_word_target_stays_identifier_error() { )], false, ); - let manifest_before = - std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); + let manifest_before = std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); let (code, stdout, stderr) = run(tmp.path(), &["--offline", "lodash"]); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs b/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs index 5fee90f88..87d4900a3 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs @@ -253,7 +253,10 @@ fn rollback_dispatch_branch_deno() { .unwrap_or_else(|e| panic!("rollback envelope must parse ({e}); stdout={stdout}")); let code = out.status.code().unwrap_or(-1); - assert_eq!(code, 0, "rollback --ecosystems=deno: expected exit 0; env={env}"); + assert_eq!( + code, 0, + "rollback --ecosystems=deno: expected exit 0; env={env}" + ); assert_eq!( env["status"], "success", "rollback --ecosystems=deno: expected success; env={env}" @@ -294,7 +297,8 @@ fn rollback_dispatch_branch_deno() { // The decisive check: the on-disk bytes are restored to ORIGINAL. let restored = std::fs::read(&verify_file).unwrap(); assert_eq!( - restored, ORIGINAL, + restored, + ORIGINAL, "rollback --ecosystems=deno: {} was not restored to its original bytes", verify_file.display() ); diff --git a/crates/socket-patch-cli/tests/scan/scan_invariants.rs b/crates/socket-patch-cli/tests/scan/scan_invariants.rs index c3316ee78..f4bb749e1 100644 --- a/crates/socket-patch-cli/tests/scan/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan/scan_invariants.rs @@ -1787,7 +1787,11 @@ async fn report_only_scan_json_redirect_state_keys_on_lock_pins() { serde_json::json!([{ "purl": purl, "uuid": AGENT_WARN_UUID }]), "the lock pin is the record; envelope={v}" ); - assert_eq!(state["wiringLive"], serde_json::json!([purl]), "envelope={v}"); + assert_eq!( + state["wiringLive"], + serde_json::json!([purl]), + "envelope={v}" + ); // No pin, no ledger: the key must stay absent (additive contract). let clean = tempfile::tempdir().expect("tempdir"); @@ -1832,7 +1836,8 @@ async fn report_only_scan_json_ignores_a_stale_pre_v5_ledger_record() { integrity sha512-orig==\n", ) .unwrap(); - let ledger_before = std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + let ledger_before = + std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); for extra in [&["--prune"][..], &["--mode", "agent", "--dry-run"][..]] { let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), extra); @@ -2053,10 +2058,7 @@ async fn scan_ignores_a_malformed_pre_v5_ledger() { "{extra:?}: a pre-v5 ledger is never read, so never reported: {stderr}" ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert!( - v.get("redirectState").is_none(), - "{extra:?}: envelope={v}" - ); + assert!(v.get("redirectState").is_none(), "{extra:?}: envelope={v}"); assert_eq!( std::fs::read(vendor_dir.join("redirect-state.json")).unwrap(), b"{ torn ledger", @@ -2090,7 +2092,11 @@ async fn ecosystems_filter_keeps_records_but_not_wiring_live() { /*with_record=*/ true, ); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"], + ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; diff --git a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs index 8ad94e551..64ea1197c 100644 --- a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs @@ -216,7 +216,11 @@ async fn paths_scope_narrows_the_query() { let tmp = tempfile::tempdir().unwrap(); write_two_subtree_project(tmp.path()); - let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &server.uri(), + &["packages/app", "--mode", "agent", "--dry-run"], + ); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" @@ -477,7 +481,11 @@ async fn supplements_excluded_with_warning() { // purl reaches the API. let scoped_server = MockServer::start().await; mock_batch_empty(&scoped_server).await; - let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &scoped_server.uri(), + &["packages/app", "--mode", "agent", "--dry-run"], + ); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" diff --git a/crates/socket-patch-cli/tests/update/covgap_commands_update.rs b/crates/socket-patch-cli/tests/update/covgap_commands_update.rs index b2d75aafc..16836e614 100644 --- a/crates/socket-patch-cli/tests/update/covgap_commands_update.rs +++ b/crates/socket-patch-cli/tests/update/covgap_commands_update.rs @@ -268,9 +268,8 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -338,7 +337,8 @@ mod pty { "a declined update exits 1 (codebase convention); got: {output}" ); assert!( - !output.contains("Updated socket-patch") && !output.contains("Reinstalled socket-patch"), + !output.contains("Updated socket-patch") + && !output.contains("Reinstalled socket-patch"), "a declined update must not report a swap; got: {output}" ); diff --git a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs index e8ff74216..dffab3915 100644 --- a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs +++ b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs @@ -660,7 +660,9 @@ pub fn run_manifestless_vex_matrix(flow: &BerryVexFlow<'_>) -> Vec { crate::vex_e2e_common::assert_no_hosted_ledger(&fresh, "manifest-deleted"); } else { assert!( - fresh.join(socket_patch_core::vendor::VENDOR_STATE_REL).is_file(), + fresh + .join(socket_patch_core::vendor::VENDOR_STATE_REL) + .is_file(), "manifest-deleted: the vendored flow must have left its .socket/vendor ledger" ); } diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 949931782..58ca27078 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -164,8 +164,14 @@ pub fn batch_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> /// classify a recorded patch (ALREADY vs UPGRADE) and to report /// `updates[]`, on the same records that pick the patch, so selection, /// classification and reporting cannot disagree. -pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool { - key_supersedes(&rank_search_result(candidate), &rank_search_result(recorded)) +pub fn search_result_supersedes( + candidate: &PatchSearchResult, + recorded: &PatchSearchResult, +) -> bool { + key_supersedes( + &rank_search_result(candidate), + &rank_search_result(recorded), + ) } fn key_supersedes(c: &RankKey<'_>, a: &RankKey<'_>) -> bool { @@ -371,12 +377,7 @@ mod tests { "2020-01-01T00:00:00Z", &["critical", "high"] ), - search_multi( - "z_new_low", - "free", - "2026-08-01T00:00:00Z", - &["low", "low"] - ), + search_multi("z_new_low", "free", "2026-08-01T00:00:00Z", &["low", "low"]), ]), "a_old_critical" ); diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 8ae536e64..250ef86bf 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -73,7 +73,8 @@ pub enum NpmPkgManager { /// /// Precedence (first match wins): /// -/// 1. `.pnp.cjs`, `.pnp.js`, or `.pnp.loader.mjs` → yarn-berry PnP — +/// 1. `.pnp.cjs`, `.pnp.js`, or `.pnp.loader.mjs`, while the configured +/// yarn `nodeLinker` is `pnp` or unset ([`live_pnp_marker`]) → yarn-berry PnP — /// unless the tree is pnpm's own `node-linker=pnp` layout (see /// [`pnpm_pnp_layout`]), which also writes a `.pnp.cjs` but keeps /// real package dirs in the pnpm virtual store → pnpm. @@ -102,10 +103,7 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { // mean "packages aren't on disk" — refuse rather than silently // fall through to Unknown (a Yarn 2 PnP tree has no // `node_modules/`, so it would otherwise escape the refusal). - if crate::constants::npm_family::PNP_MARKERS - .iter() - .any(|m| project_root.join(m).is_file()) - { + if live_pnp_marker(project_root, |m| project_root.join(m).is_file()).is_some() { // Carve-out: pnpm has its OWN PnP mode (`node-linker=pnp` in // `.npmrc`) which also writes a `.pnp.cjs` loader at the root // — but unlike yarn-berry the packages are real directories in @@ -163,6 +161,58 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { NpmPkgManager::Unknown } +/// The `nodeLinker` yarn resolves for `project_root`: `YARN_NODE_LINKER` +/// when set (yarn lets every setting be overridden from the environment), +/// else the nearest `.yarnrc.yml` at or above the project that sets +/// `nodeLinker` (yarn merges every rc file up to the filesystem root, the +/// closest winning). `None` when nothing sets it: yarn berry then uses its +/// default linker, `pnp`. +pub fn yarn_node_linker(project_root: &Path) -> Option { + yarn_node_linker_with(project_root, std::env::var("YARN_NODE_LINKER").ok()) +} + +fn yarn_node_linker_with(project_root: &Path, env: Option) -> Option { + if let Some(linker) = env.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) { + return Some(linker); + } + let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + start.ancestors().find_map(|dir| { + let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc.yml")).ok()?; + crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }) +} + +/// Whether a yarn `nodeLinker` setting (`None` = unset) is Plug'n'Play: +/// `pnp` itself, or nothing set at all (berry's default). +pub fn yarn_linker_is_pnp(linker: Option<&str>) -> bool { + linker.is_none_or(|l| l == "pnp") +} + +/// The PnP loader file that makes `project_root` a live yarn Plug'n'Play +/// tree, as `exists` sees the files, or `None`. A loader only counts while +/// the configured linker is still `pnp` (or unset): a Yarn 2 → Yarn 4 +/// migration that switched `nodeLinker` to `node-modules` or `pnpm` keeps +/// the old `.pnp.js`, which yarn ignores, and so must every caller (#975). +/// Every `PNP_MARKERS` decision goes through here or +/// [`live_pnp_marker_with`]. +pub fn live_pnp_marker(project_root: &Path, exists: impl Fn(&str) -> bool) -> Option<&'static str> { + live_pnp_marker_with(|| yarn_node_linker(project_root), exists) +} + +/// [`live_pnp_marker`] over any file view: `linker` supplies the configured +/// `nodeLinker`, only asked for when a loader file exists. +pub fn live_pnp_marker_with( + linker: impl FnOnce() -> Option, + exists: impl Fn(&str) -> bool, +) -> Option<&'static str> { + let marker = crate::constants::npm_family::PNP_MARKERS + .into_iter() + .find(|m| exists(m))?; + yarn_linker_is_pnp(linker().as_deref()).then_some(marker) +} + /// Is a PnP-marker-bearing project root actually pnpm's own PnP mode /// (`node-linker=pnp` in `.npmrc`) rather than yarn-berry? /// @@ -195,6 +245,73 @@ pub(crate) fn pnpm_pnp_layout(project_root: &Path) -> bool { mod tests { use super::*; + /// #975: yarn 4 keeps a Yarn 2 `.pnp.js` after a switch to the + /// node-modules or pnpm linker; yarn ignores it, so must detection. + #[test] + fn stale_pnp_loader_under_non_pnp_linker_is_not_pnp() { + for linker in ["node-modules", "pnpm", "'node-modules' # migrated"] { + let d = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(d.path().join("node_modules")).unwrap(); + std::fs::write(d.path().join("yarn.lock"), "__metadata:\n").unwrap(); + std::fs::write(d.path().join(".pnp.js"), "").unwrap(); + std::fs::write( + d.path().join(".yarnrc.yml"), + format!("nodeLinker: {linker}\n"), + ) + .unwrap(); + assert_eq!( + live_pnp_marker_with( + || yarn_node_linker_with(d.path(), None), + |m| d.path().join(m).is_file() + ), + None, + "{linker}" + ); + } + } + + #[test] + fn pnp_loader_counts_under_pnp_or_unset_linker() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); + let exists = |m: &str| d.path().join(m).is_file(); + assert_eq!( + live_pnp_marker_with(|| yarn_node_linker_with(d.path(), None), exists), + Some(".pnp.cjs") + ); + std::fs::write(d.path().join(".yarnrc.yml"), "nodeLinker: \"pnp\"\n").unwrap(); + assert_eq!( + live_pnp_marker_with(|| yarn_node_linker_with(d.path(), None), exists), + Some(".pnp.cjs") + ); + } + + /// yarn's own precedence: the env var, then the nearest rc file that + /// sets the key, walking up past rc files that don't. + #[test] + fn yarn_node_linker_follows_yarn_precedence() { + let d = tempfile::tempdir().unwrap(); + let member = d.path().join("packages/a"); + std::fs::create_dir_all(&member).unwrap(); + assert_eq!(yarn_node_linker_with(&member, None), None); + std::fs::write(d.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); + std::fs::write(member.join(".yarnrc.yml"), "enableGlobalCache: false\n").unwrap(); + assert_eq!( + yarn_node_linker_with(&member, None).as_deref(), + Some("node-modules") + ); + std::fs::write(member.join(".yarnrc.yml"), "nodeLinker: pnp\n").unwrap(); + assert_eq!(yarn_node_linker_with(&member, None).as_deref(), Some("pnp")); + assert_eq!( + yarn_node_linker_with(&member, Some(" pnpm ".into())).as_deref(), + Some("pnpm") + ); + assert_eq!( + yarn_node_linker_with(&member, Some(String::new())).as_deref(), + Some("pnp") + ); + } + #[test] fn unknown_for_empty_dir() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 7019e8945..ad4125e15 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -582,9 +582,7 @@ async fn pdm_saved_interpreter(cwd: &Path) -> Option { let saved = match read_regular_to_string(&cwd.join(".pdm-python")).await { Ok(text) => text.trim().to_string(), Err(_) => { - let text = read_regular_to_string(&cwd.join(".pdm.toml")) - .await - .ok()?; + let text = read_regular_to_string(&cwd.join(".pdm.toml")).await.ok()?; let doc = text.parse::().ok()?; doc.get("python")?.get("path")?.as_str()?.trim().to_string() } @@ -3670,7 +3668,11 @@ mod tests { fake_venv(&tmp.path().join("uv-env"), "venv"); let uv_env = env_of(&[( "UV_PROJECT_ENVIRONMENT", - tmp.path().join("uv-env").join("venv").to_string_lossy().into_owned(), + tmp.path() + .join("uv-env") + .join("venv") + .to_string_lossy() + .into_owned(), )]); assert_eq!( find_local_venv_site_packages_with(&project, &uv_env).await, diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index 58b4dc2bc..3e9cb9c5b 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -34,7 +34,6 @@ use crate::utils::purl::simple_purl; use crate::vendor::cargo_tag; use crate::vendor::lock_inventory::{LockIntegrity, LockfileEntry, SourceKind}; - // ── entry model ── /// The `[metadata]` key a v1 lock files `name`+`version`'s checksum under. @@ -332,7 +331,6 @@ pub(crate) fn parse_ref(spelled: &str) -> (&str, Option<&str>, Option<&str>) { (name, version, source) } - // ── the model ── /// One `Cargo.lock`, parsed once (see the module docs). @@ -500,7 +498,13 @@ impl CargoLock { uuid: &str, copy_tagged: bool, ) -> CopyClaim<'_> { - vendored_copy_claim(&self.packages, &self.unused, name, version, uuid, copy_tagged) + vendored_copy_claim( + &self.packages, + &self.unused, + name, + version, + uuid, + copy_tagged, + ) } } - diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs index 8efa3c178..d45156ceb 100644 --- a/crates/socket-patch-core/src/formats/composer/mod.rs +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -22,7 +22,6 @@ use crate::utils::digest::sha1_hex; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; use crate::vendor::path::{parse_vendor_path, VendorPathParts}; - // ── entry model ── /// One entry of a parsed `composer.lock` (see [`composer_lock_packages`]). @@ -107,7 +106,6 @@ pub(crate) fn composer_lock_packages(doc: &Value) -> Vec out } - // ── the model ── /// One `composer.lock`, read once (see the module docs). @@ -177,4 +175,3 @@ impl<'a> ComposerLock<'a> { out } } - diff --git a/crates/socket-patch-core/src/formats/gem/gemfile.rs b/crates/socket-patch-core/src/formats/gem/gemfile.rs index 8203c8a0a..34232745f 100644 --- a/crates/socket-patch-core/src/formats/gem/gemfile.rs +++ b/crates/socket-patch-core/src/formats/gem/gemfile.rs @@ -370,8 +370,14 @@ mod tests { #[test] fn escaped_quotes_and_hashes_inside_strings_stay_in_the_string() { - assert_eq!(key(", require: 'it\\'s', gitlab: \"x\""), Some("gitlab:".into())); - assert_eq!(key(", require: \"a\\\"b\", git: \"x\""), Some("git:".into())); + assert_eq!( + key(", require: 'it\\'s', gitlab: \"x\""), + Some("gitlab:".into()) + ); + assert_eq!( + key(", require: \"a\\\"b\", git: \"x\""), + Some("git:".into()) + ); assert_eq!(key(", local: \"#{name}\""), Some("local:".into())); } diff --git a/crates/socket-patch-core/src/formats/gem/hosted.rs b/crates/socket-patch-core/src/formats/gem/hosted.rs index 0416c3594..5dd4dc8b3 100644 --- a/crates/socket-patch-core/src/formats/gem/hosted.rs +++ b/crates/socket-patch-core/src/formats/gem/hosted.rs @@ -304,4 +304,3 @@ pub(crate) fn checksum_entry_span(lock: &str, name: &str, version: &str) -> Opti } None } - diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index 3a8f17af2..21bd39008 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -28,7 +28,6 @@ use crate::utils::digest::sha256_hex; use crate::utils::purl::simple_purl; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; - /// The Bundler lockfiles, legacy spelling first: `Gemfile.lock` and /// `gems.locked` (what bundler writes instead when the manifest is /// `gems.rb`). @@ -223,7 +222,6 @@ impl<'t> GemfileLock<'t> { } } - /// Where a rubygems-compatible registry at `base` (no trailing `/`) serves /// `name`-`version`'s `.gem` — the inventory's resolved URL and ledger /// recovery's fetch URL. `None` for a non-http(s) base. diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index f3ea013a3..31ed9059e 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -26,13 +26,13 @@ //! [`registry()`] is the one table of which project files carry a lock or //! its wiring, and in which roles. +pub(crate) mod bun; pub mod cargo; pub mod composer; pub mod gem; pub(crate) mod maven; pub(crate) mod nuget; pub mod pnpm; -pub(crate) mod bun; pub mod registry; pub mod yarn; @@ -81,7 +81,11 @@ mod architecture_tests { .filter(|l| !l.trim_start().starts_with("//")) .collect::>() .join("\n"); - let used: Vec<&str> = IMPURE.iter().copied().filter(|n| code.contains(n)).collect(); + let used: Vec<&str> = IMPURE + .iter() + .copied() + .filter(|n| code.contains(n)) + .collect(); assert!( used.is_empty(), "{}: a format model uses {used:?} — models are pure (module docs)", diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index c7d47c1f2..a632c9c3a 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -39,7 +39,6 @@ use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry}; use crate::vendor::path::parse_vendor_path; - // ── entry model ── /// One `packages:` entry of a pnpm lock, read with the entry grammar @@ -283,7 +282,10 @@ fn lock_versions(text: &str) -> impl Iterator, u32)> + '_ { let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); let mut parts = value.split('.'); let major = parts.next().and_then(|m| m.parse::().ok()); - let minor = parts.next().and_then(|m| m.parse::().ok()).unwrap_or(0); + let minor = parts + .next() + .and_then(|m| m.parse::().ok()) + .unwrap_or(0); Some((major, minor)) }) } @@ -303,7 +305,8 @@ pub fn lock_version_major(text: &str) -> Option { /// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion /// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. pub fn may_need_store_flag(text: &str) -> bool { - text.lines().any(|line| line.starts_with("shrinkwrapVersion:")) + text.lines() + .any(|line| line.starts_with("shrinkwrapVersion:")) || lock_versions(text).any(|(major, minor)| major == Some(5) && minor <= 2) } @@ -491,7 +494,9 @@ pub(crate) fn vendored_npm_uuids(text: &str) -> HashSet { if !in_section { continue; } - if let Some(uuid) = lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) { + if let Some(uuid) = + lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) + { out.insert(uuid); } } @@ -508,17 +513,52 @@ mod tests { fn resolves_reads_every_key_generation_boundary_anchored() { let lock = |keys: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{keys}"); let yes = [ - (" left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n", "left-pad", "1.3.0"), - (" /left-pad@1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), - (" /left-pad/1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), - (" 'left-pad@1.3.0(react@18.0.0)':\n dev: false\n", "left-pad", "1.3.0"), - (" /left-pad/1.3.0_react@18.0.0:\n dev: false\n", "left-pad", "1.3.0"), - (" '@scope/name@1.0.0':\n dev: false\n", "@scope/name", "1.0.0"), - (" /@scope/name@1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), - (" /@scope/name/1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), + ( + " left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad@1.3.0:\n resolution: {}\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad/1.3.0:\n resolution: {}\n", + "left-pad", + "1.3.0", + ), + ( + " 'left-pad@1.3.0(react@18.0.0)':\n dev: false\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad/1.3.0_react@18.0.0:\n dev: false\n", + "left-pad", + "1.3.0", + ), + ( + " '@scope/name@1.0.0':\n dev: false\n", + "@scope/name", + "1.0.0", + ), + ( + " /@scope/name@1.0.0:\n dev: false\n", + "@scope/name", + "1.0.0", + ), + ( + " /@scope/name/1.0.0:\n dev: false\n", + "@scope/name", + "1.0.0", + ), ]; for (keys, name, version) in yes { - assert!(PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + assert!( + PnpmLock::parse(&lock(keys)).resolves(name, version), + "{keys}" + ); } let no = [ (" left-pad@1.3.0-beta.1:\n dev: false\n", "left-pad", "1.3.0"), @@ -534,7 +574,10 @@ mod tests { ), ]; for (keys, name, version) in no { - assert!(!PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + assert!( + !PnpmLock::parse(&lock(keys)).resolves(name, version), + "{keys}" + ); } // Keys outside `packages:` (importers, overrides) resolve nothing. let importers = "lockfileVersion: '9.0'\n\nimporters:\n\n left-pad@1.3.0:\n x: y\n"; @@ -557,7 +600,10 @@ mod tests { let other = "22222222-2222-4222-8222-222222222222"; assert!(!PnpmLock::parse(text).vendored_in_use(other)); let crlf = text.replace('\n', "\r\n"); - assert!(PnpmLock::parse(&crlf).vendored_in_use(UUID), "CRLF reads like LF"); + assert!( + PnpmLock::parse(&crlf).vendored_in_use(UUID), + "CRLF reads like LF" + ); } // An overrides declaration alone is not usage. let overrides = format!( diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index c690f6be8..de6adedb1 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -68,7 +68,11 @@ const fn row(path: &'static str, ecosystem: &'static str, roles: u8) -> FormatFi const REGISTRY: &[FormatFile] = &[ // ── npm family ── row("package-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), - row("npm-shrinkwrap.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + row( + "npm-shrinkwrap.json", + "npm", + HOSTED | VENDORED | PROBE | ROOT, + ), row( "pnpm-lock.yaml", "npm", @@ -119,7 +123,11 @@ const REGISTRY: &[FormatFile] = &[ row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE), // ── composer ── row("composer.json", "composer", VENDORED), - row("composer.lock", "composer", HOSTED | VENDORED | PROBE | ROOT), + row( + "composer.lock", + "composer", + HOSTED | VENDORED | PROBE | ROOT, + ), // ── nuget ── row("nuget.config", "nuget", HOSTED | PROBE), row("NuGet.config", "nuget", HOSTED | PROBE), @@ -271,7 +279,11 @@ mod tests { paths.dedup(); assert_eq!(before, paths.len(), "duplicate registry path"); for f in REGISTRY.iter().filter(|f| f.has(ROOT)) { - assert!(!f.path.contains('/'), "{}: a root marker is a basename", f.path); + assert!( + !f.path.contains('/'), + "{}: a root marker is a basename", + f.path + ); } } diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 389dadde0..3c7e71eec 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -64,7 +64,10 @@ mod tests { #[test] fn sniff_prefers_berry_and_skips_a_bom() { - assert_eq!(sniff_grammar("__metadata:\n version: 8\n"), Some(YarnLockGrammar::Berry)); + assert_eq!( + sniff_grammar("__metadata:\n version: 8\n"), + Some(YarnLockGrammar::Berry) + ); assert_eq!( sniff_grammar("\u{feff}# yarn lockfile v1\r\n"), Some(YarnLockGrammar::Classic) diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 51ec85483..81bf03d76 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -173,9 +173,7 @@ pub fn pnpm_lock_carries_hosted_redirect( pub fn npm_lock_url_needles(artifact_url: &str) -> Vec { let mut needles: Vec = crate::patch::redirect::artifact_url_spellings(artifact_url).into(); - needles.push(crate::utils::uri::encode_uri_component( - artifact_url, - )); + needles.push(crate::utils::uri::encode_uri_component(artifact_url)); needles } @@ -310,11 +308,7 @@ fn npm_allow_remote_preamble(hosts: &[&str]) -> String { /// The auto-config variant: `allow-remote=all` was (or, on `--dry-run`, /// would be) written to the project `.npmrc`, so installs need no flags. -pub fn npm_allow_remote_configured_detail( - hosts: &[&str], - created: bool, - dry_run: bool, -) -> String { +pub fn npm_allow_remote_configured_detail(hosts: &[&str], created: bool, dry_run: bool) -> String { let how = match (created, dry_run) { (true, false) => "`allow-remote=all` was written to a new", (false, false) => "`allow-remote=all` was appended to the existing", diff --git a/crates/socket-patch-core/src/hosted/memory/discover.rs b/crates/socket-patch-core/src/hosted/memory/discover.rs index 6475a0cc0..9469b50ba 100644 --- a/crates/socket-patch-core/src/hosted/memory/discover.rs +++ b/crates/socket-patch-core/src/hosted/memory/discover.rs @@ -14,9 +14,7 @@ use std::time::Duration; use crate::api::client::{ApiError, ApiFuture, PatchApi}; use crate::api::ranking::cmp_search_results; -use crate::api::types::{ - BatchPackagePatches, PackageVendorResult, PatchResponse, SearchResponse, -}; +use crate::api::types::{BatchPackagePatches, PackageVendorResult, PatchResponse, SearchResponse}; use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; use super::types::MAX_REFERENCE_BATCH; diff --git a/crates/socket-patch-core/src/hosted/memory/limits.rs b/crates/socket-patch-core/src/hosted/memory/limits.rs index 9f895d6c9..da4dd695d 100644 --- a/crates/socket-patch-core/src/hosted/memory/limits.rs +++ b/crates/socket-patch-core/src/hosted/memory/limits.rs @@ -42,12 +42,7 @@ impl ResolvedOptions { /// as `flag`), then the socket.yml `patches.maxNewPatches`, then /// unlimited; `maxNewPatchesCap` only tightens it. pub(crate) fn max_new(&self, file: Option) -> crate::rollout::MaxNew { - crate::rollout::resolve_max_new( - self.max_new_patches, - None, - file, - self.max_new_patches_cap, - ) + crate::rollout::resolve_max_new(self.max_new_patches, None, file, self.max_new_patches_cap) } } @@ -79,8 +74,9 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result None, Some(value) => Some(( - crate::policy::parse_min_severity(value) - .map_err(|e| EngineError::invalid("invalid_min_severity", format!("minSeverity: {e}")))?, + crate::policy::parse_min_severity(value).map_err(|e| { + EngineError::invalid("invalid_min_severity", format!("minSeverity: {e}")) + })?, crate::policy::OverrideSource::Flag, )), }; diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 85a0d3c48..51651e5fc 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -58,17 +58,17 @@ pub use limits::SessionBuilder; pub use select::{candidate_files, safe_repo_path, select_paths}; pub use types::*; +use crate::policy::{ + canon, patch_severity_order, policy_block, FilterReason, FilteredEntry, MemoryPolicyFs, + PolicyError, PolicySource, Root, RootFile, SelectionPolicy, PATCHES_DISABLED, + POLICY_FILE_NAMES, +}; use crate::rollout::stage::{ classify, lookup_incomplete, mentioned_uuids, offers_from_results, Offers, RecordedIndex, Row, - Stage, - ROLLOUT_DEFERRED, + Stage, ROLLOUT_DEFERRED, }; use discover::Provider; use stages::{Planned, RewriteRefused, Rewritten, StageOptions}; -use crate::policy::{ - canon, patch_severity_order, policy_block, FilterReason, FilteredEntry, MemoryPolicyFs, PolicyError, - PolicySource, Root, RootFile, SelectionPolicy, PATCHES_DISABLED, POLICY_FILE_NAMES, -}; /// `"+"`; the sha comes from the /// `SOCKET_PATCH_GIT_SHA` build-time variable. @@ -419,11 +419,8 @@ fn memory_recorded( .map(|p| (purl.clone(), p.uuid.clone())) }) .collect(); - let merged = crate::ledgers::merge_ledger_records_for_updates( - manifest.as_ref(), - vendor.as_ref(), - &pins, - ); + let merged = + crate::ledgers::merge_ledger_records_for_updates(manifest.as_ref(), vendor.as_ref(), &pins); RecordedIndex::new(merged.as_deref(), &pins) } @@ -450,13 +447,20 @@ async fn engine( // The repo's socket.yml policy, before any root is processed: a file // that cannot be honored fails the whole session closed. - let (policy, policy_warnings) = - match SelectionPolicy::load(&memory_policy_fs(&files, &options.policy_paths), &options.policy_overrides) { - Ok(loaded) => loaded, - Err(error) => { - return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); - } - }; + let (policy, policy_warnings) = match SelectionPolicy::load( + &memory_policy_fs(&files, &options.policy_paths), + &options.policy_overrides, + ) { + Ok(loaded) => loaded, + Err(error) => { + return Ok(policy_error_output( + &error, + warnings, + files_input, + bytes_input, + )); + } + }; // Path selection chose which files to send by the policy it read; a // different policy here would judge roots it never fetched. let read = match policy.source() { @@ -465,16 +469,27 @@ async fn engine( }; // Selection returns no digest when it bypassed the file, so a digest // with a bypassed session means the two sides disagree. - let expected = if options.policy_overrides.bypass { None } else { read.map(|(_, sha)| sha) }; + let expected = if options.policy_overrides.bypass { + None + } else { + read.map(|(_, sha)| sha) + }; if expected != options.policy_sha256.as_deref() { let error = PolicyError::Invalid { - file: read.map_or(POLICY_FILE_NAMES[0], |(path, _)| path).to_string(), + file: read + .map_or(POLICY_FILE_NAMES[0], |(path, _)| path) + .to_string(), key: String::new(), message: "the policy content differs from the one path selection read: pass \ selectHostedScanPaths' policySha256 and stream the same text" .to_string(), }; - return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); + return Ok(policy_error_output( + &error, + warnings, + files_input, + bytes_input, + )); } for w in policy_warnings { warnings.push(EngineWarning::new(w.code, w.detail, None)); @@ -722,23 +737,25 @@ async fn engine( // the tree's manifest and vendor ledger, and the hosted pins its // lockfiles name. ALREADY rows carry the recorded uuid, so a re-scan // re-confirms a pin instead of swapping it. - let mut stage = Stage::new(options.max_new(policy.max_new_patches()), None, std::path::Path::new("")); + let mut stage = Stage::new( + options.max_new(policy.max_new_patches()), + None, + std::path::Path::new(""), + ); // A root whose every lookup failed hides packages that could have been // NEW: a capped run then admits none anywhere (§5.2). - stage.incomplete |= states - .iter() - .any(|s| s.error.as_ref().is_some_and(|e| e.code == "patch_lookup_failed")); + stage.incomplete |= states.iter().any(|s| { + s.error + .as_ref() + .is_some_and(|e| e.code == "patch_lookup_failed") + }); let roots_by_path: Vec = states.iter().map(|s| s.root.clone()).collect(); for state in states.iter_mut().filter(|s| s.error.is_none()) { let Some(project) = state.project.as_ref() else { continue; }; let recorded = memory_recorded(project, &state.root, &roots_by_path, &state.offers); - stage.incomplete |= lookup_incomplete( - &recorded, - &state.failed_details, - batch_failed, - ); + stage.incomplete |= lookup_incomplete(&recorded, &state.failed_details, batch_failed); let mut rows = classify(&state.offers, &recorded, &state.root); for row in &mut rows { row.candidate.in_flight = options.in_flight.contains(&row.candidate.base_purl); @@ -873,8 +890,11 @@ async fn engine( unknown_roots.contains(&row.candidate.project) || confirmed.contains(&(row.candidate.project.clone(), row.writer.uuid.clone())) }); - let deferred_rows: Vec<(crate::rollout::Candidate, u32)> = - stage.plan.as_ref().map(|p| p.deferred.clone()).unwrap_or_default(); + let deferred_rows: Vec<(crate::rollout::Candidate, u32)> = stage + .plan + .as_ref() + .map(|p| p.deferred.clone()) + .unwrap_or_default(); if !deferred_rows.is_empty() { let root_index: BTreeMap = states .iter() @@ -1126,7 +1146,10 @@ fn select_with_policy( let mut by_purl: BTreeMap> = BTreeMap::new(); for (patch, reason) in dropped { if !chosen.contains(patch.purl.as_str()) { - by_purl.entry(patch.purl.clone()).or_default().push((patch, reason)); + by_purl + .entry(patch.purl.clone()) + .or_default() + .push((patch, reason)); } } for (purl, mut group) in by_purl { diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index 35f39be1a..1a68e6528 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -31,17 +31,23 @@ pub const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ /// trees, VCS and tool state, and vendored dependencies. Structural, so no /// policy can negate them. (Test and fixture trees are the socket.yml /// policy's overridable built-in ignores.) -pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; +pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = + ["node_modules", ".git", ".socket", ".yarn", "vendor"]; /// The marker basenames of `root` among `paths` (the files the policy's /// path filters test for that root). -pub(crate) fn root_markers<'a>(root: &str, paths: impl IntoIterator) -> Vec { +pub(crate) fn root_markers<'a>( + root: &str, + paths: impl IntoIterator, +) -> Vec { let mut out: Vec = paths .into_iter() .filter_map(|path| { let (dir, base) = split_path(path); let marker = marker_ecosystem(base).is_some() - || UNSUPPORTED_MARKERS.iter().any(|(_, names)| names.contains(&base)); + || UNSUPPORTED_MARKERS + .iter() + .any(|(_, names)| names.contains(&base)); (dir == root && marker).then(|| base.to_string()) }) .collect(); @@ -202,7 +208,15 @@ mod tests { #[test] fn root_markers_name_every_marker_of_the_root_only() { assert_eq!( - root_markers("a", ["a/yarn.lock", "a/package.json", "a/b/yarn.lock", "a/pom.xml"]), + root_markers( + "a", + [ + "a/yarn.lock", + "a/package.json", + "a/b/yarn.lock", + "a/pom.xml" + ] + ), vec!["pom.xml".to_string(), "yarn.lock".to_string()] ); } diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index f18efa81e..04dcee403 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -15,8 +15,8 @@ use crate::patch::redirect::npmrc::NPMRC_REL; use crate::utils::python_lock::is_python_lock_name; use crate::policy::{ - MemoryPolicyFs, PolicyOverrides, PolicySource, Root, RootFile, SelectionPolicy, POLICY_FILE_NAMES, - SOCKET_YML_INVALID, + MemoryPolicyFs, PolicyOverrides, PolicySource, Root, RootFile, SelectionPolicy, + POLICY_FILE_NAMES, SOCKET_YML_INVALID, }; use super::roots::{ @@ -185,7 +185,10 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { /// The listed root policy files with the text the caller fetched first. A /// listed file with no text (not passed, `missing`, or a symlink) is present /// without content, so loading it fails closed. -fn selection_policy_fs(blobs: &BTreeMap, supplied: &[PolicyFileInput]) -> MemoryPolicyFs { +fn selection_policy_fs( + blobs: &BTreeMap, + supplied: &[PolicyFileInput], +) -> MemoryPolicyFs { let mut fs = MemoryPolicyFs::default(); for name in POLICY_FILE_NAMES { let Some(&symlink) = blobs.get(name) else { @@ -211,7 +214,10 @@ fn selection_policy( options: &SelectOptions, ) -> Result { let supplied = options.policy_files.as_deref().unwrap_or_default(); - if let Some(bad) = supplied.iter().find(|f| !POLICY_FILE_NAMES.contains(&f.path.as_str())) { + if let Some(bad) = supplied + .iter() + .find(|f| !POLICY_FILE_NAMES.contains(&f.path.as_str())) + { return Err(PolicyErrorInfo { code: SOCKET_YML_INVALID.to_string(), detail: format!( diff --git a/crates/socket-patch-core/src/hosted/memory/types.rs b/crates/socket-patch-core/src/hosted/memory/types.rs index 2a11daed7..fa81876e8 100644 --- a/crates/socket-patch-core/src/hosted/memory/types.rs +++ b/crates/socket-patch-core/src/hosted/memory/types.rs @@ -171,7 +171,9 @@ impl<'de> Deserialize<'de> for MaxNewPatchesOption { if v == "none" { Ok(MaxNewPatchesOption(None)) } else { - Err(E::custom(format!("maxNewPatches must be a number or \"none\", not `{v}`"))) + Err(E::custom(format!( + "maxNewPatches must be a number or \"none\", not `{v}`" + ))) } } } diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs index 9bcf56623..582ca464f 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -371,7 +371,6 @@ pub fn uuid_only_record(uuid: &str) -> PatchRecord { } } - /// Fold the hosted pins and the vendor ledger's patch records into the /// manifest view update detection consults. Hosted mode records purl→uuid /// ONLY in the lockfiles (`hosted_pins`, uuid only; v5 keeps no hosted diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 143eae979..c15fe9e64 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -16,7 +16,6 @@ pub mod utils; pub mod vendor; pub mod vex; - #[cfg(test)] mod golden; #[cfg(test)] diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 453e0ab2f..7032d435c 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -32,7 +32,10 @@ pub fn vulnerabilities_for_manifest( /// `patch`. `files` is the (purl-keyed) before/after-hash map the /// caller built — semantics for what counts as a "patchable file" differ /// between the get and download flows, so the caller owns that decision. -pub fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { +pub fn build_patch_record( + patch: &PatchResponse, + files: HashMap, +) -> PatchRecord { PatchRecord { uuid: patch.uuid.clone(), exported_at: patch.published_at.clone(), diff --git a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs index 082849761..5863631df 100644 --- a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs @@ -97,7 +97,6 @@ fn synth_lock(rng: &mut Rng, blocks: usize, v1: bool) -> String { out } - const INDEX: &str = "sparse+https://socket.example/cargo/index/"; fn plan_new(lock: &str, name: &str, version: &str, cksum: &str) -> CargoLockPlan { @@ -182,7 +181,8 @@ fn span_splice_matches_golden_on_hand_written_locks() { "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"d 1.0.0 ({crates_io})\",\n]\n\n[[package]]\nname = \"d\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\"\n\n[[package]]\nname = \"u\"\nversion = \"2.0.0\"\nsource = \"{crates_io}\"\ndependencies = [\n \"d 1.0.0 ({crates_io})\",\n]\n\n[metadata]\n\"checksum d 1.0.0 ({crates_io})\" = \"cc\"\n\"checksum u 2.0.0 ({crates_io})\" = \"dd\"\n" ); let sourceless_v1 = "[[package]]\nname = \"s\"\nversion = \"1.0.0\"\n\n[metadata]\n\"checksum s 1.0.0 (registry+x)\" = \"ee\"\n".to_string(); - let source_at_eof = format!("[[package]]\nname = \"e\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\""); + let source_at_eof = + format!("[[package]]\nname = \"e\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\""); let bare = "version = 3\n\n[[package]]\nname = \"b\"\nversion = \"1.0.0\"\n\n[[package]]\nname = \"c\"\nversion = \"1.0.0\"\n".to_string(); let mut g = Golden::new( "cargo_lock_hand_written", diff --git a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs index 3a8ebf5c2..075f630b4 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs @@ -10,7 +10,11 @@ use super::*; use crate::golden::Golden; use crate::test_rng::Rng; -fn run(g: &mut Golden, files: &BTreeMap, overrides: &[DepOverride]) -> RewriteResult { +fn run( + g: &mut Golden, + files: &BTreeMap, + overrides: &[DepOverride], +) -> RewriteResult { let mut got = RewriteResult::default(); rewrite_golang(files, overrides, &mut got); g.next(&(files, overrides), &got); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 72c8b2967..19516bdec 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -48,41 +48,41 @@ mod pdm; mod pipenv; pub mod presence; // The pnpm hosted planner lives with the format's model. -use crate::formats::pnpm::plan_hosted; +use crate::formats::cargo::hosted::CargoLockPlan; +#[cfg(test)] +use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::gem::gemfile; use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source}; use crate::formats::gem::lock_lists_direct_dependency; -pub(crate) use crate::formats::yarn::is_berry_lock; -use crate::formats::cargo::hosted::CargoLockPlan; -#[cfg(test)] -use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; +use crate::formats::pnpm::plan_hosted; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; +pub(crate) use crate::formats::yarn::is_berry_lock; +pub mod gradle; #[cfg(test)] mod pnpm_equivalence_tests; mod poetry; #[cfg(test)] mod python_lock_equivalence_tests; mod requirements; -pub mod gradle; pub use requirements::preflight_requirements_takeover; +pub(crate) mod hosted_url; mod staged; mod state; -pub(crate) mod hosted_url; pub mod upstream; pub mod vlt; pub mod vlt_heal; pub mod vlt_preflight; -pub use state::{ - load_redirect_state, save_redirect_state, - CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, -}; /// Hosted-artifact leaf ownership rule, shared with `vex`'s bun lockfile /// discovery (which recovers a URL tuple's version from that leaf). pub(crate) use hosted_url::{hosted_url_names, hosted_url_version}; +pub use state::{ + load_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, + REDIRECT_STATE_REL, +}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). #[derive(Debug, Clone, Default, Deserialize)] @@ -4009,7 +4009,12 @@ fn rewrite_yarn_berry_with_manifests( result.edits.push(FileEdit { path: BERRY_MANIFEST.into(), kind: "redirect_yarn_berry_resolution".into(), - action: if original.is_some() { "rewritten" } else { "added" }.into(), + action: if original.is_some() { + "rewritten" + } else { + "added" + } + .into(), key: Some(selector), original: original.map(Value::String), new: Some(Value::String(dep.artifact_url.clone())), @@ -4251,7 +4256,10 @@ impl BerryResolutionsPin { } let mut changed = Vec::new(); for selector in &self.selectors { - let previous = table.get(selector).and_then(Value::as_str).map(str::to_string); + let previous = table + .get(selector) + .and_then(Value::as_str) + .map(str::to_string); if previous.as_deref() != Some(url) { table.insert(selector.clone(), Value::String(url.to_string())); changed.push((selector.clone(), previous)); @@ -4433,7 +4441,11 @@ fn berry_catalog_selectors(yarnrc: Option<&str>, name: &str, ranges: &[&str]) -> /// order before the edit (`was_sorted`, from [`berry_entries_sorted`]; a /// hand-edited lock) keeps the entry in place, so a pin and its rollback /// still round-trip byte-exactly. -pub(crate) fn berry_reposition_blocks(blocks: &mut Vec, moved: &[String], was_sorted: bool) { +pub(crate) fn berry_reposition_blocks( + blocks: &mut Vec, + moved: &[String], + was_sorted: bool, +) { if !was_sorted { return; } @@ -4458,7 +4470,6 @@ pub(crate) fn berry_reposition_blocks(blocks: &mut Vec, moved: &[String] } } - // ── bun.lock (text lockfile) ───────────────────────────────────────────────── // A registry 4-tuple `["name@version", "", {deps}, "sha512-…"]` is // rewritten to a URL 3-tuple `["name@", {deps verbatim}, @@ -5059,7 +5070,6 @@ fn rewrite_uv_lock( } } - // ── composer.lock ──────────────────────────────────────────────────────────── /// Whether `text` points at `artifact_url` in any spelling a rewritten file may /// carry: the raw url every rewriter emits — composer.lock included, since @@ -8069,7 +8079,10 @@ mod tests { let files = BTreeMap::from([("nuget.config".into(), config)]); let result = rewrite_registry_redirect(&files, &[nuget_override()]); let out = result.files.get("nuget.config").expect("config rewritten"); - assert!(out.contains(&source), "original source bytes preserved: {out}"); + assert!( + out.contains(&source), + "original source bytes preserved: {out}" + ); // XML normalizes literal attribute whitespace to spaces, but // preserves character references. The fallback must keep the // same source identity under a real XML reader, not just ours. @@ -8626,7 +8639,11 @@ mod tests { #[test] fn yarn_berry_hosted_pin_routes_resolutions_to_a_tarball_entry() { let checksum = format!("10c0/{}", "7".repeat(128)); - let scoped_url = berry_hosted_url("@isaacs/string-locale-compare", "string-locale-compare", "1.1.0"); + let scoped_url = berry_hosted_url( + "@isaacs/string-locale-compare", + "string-locale-compare", + "1.1.0", + ); let plain_url = berry_hosted_url("left-pad", "left-pad", "1.3.0"); let scoped = DepOverride { namespace: Some("@isaacs".into()), @@ -8659,8 +8676,14 @@ mod tests { )), "unscoped entry re-keyed to its tarball: {out}" ); - assert!(!out.contains("__archiveUrl") && !out.contains("@npm:"), "{out}"); - assert!(out.ends_with("linkType: hard\n"), "trailing newline kept: {out:?}"); + assert!( + !out.contains("__archiveUrl") && !out.contains("@npm:"), + "{out}" + ); + assert!( + out.ends_with("linkType: hard\n"), + "trailing newline kept: {out:?}" + ); let manifest: Value = serde_json::from_str(&r.files["package.json"]).unwrap(); assert_eq!( manifest["resolutions"], @@ -8672,11 +8695,17 @@ mod tests { ); assert_eq!(manifest["name"], "app", "the rest of the manifest is kept"); assert_eq!( - r.edits.iter().filter(|e| e.kind == "redirect_yarn_berry_entry").count(), + r.edits + .iter() + .filter(|e| e.kind == "redirect_yarn_berry_entry") + .count(), 2 ); assert_eq!( - r.edits.iter().filter(|e| e.kind == "redirect_yarn_berry_resolution").count(), + r.edits + .iter() + .filter(|e| e.kind == "redirect_yarn_berry_resolution") + .count(), 2 ); } @@ -8909,10 +8938,7 @@ mod tests { rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); assert!(r.warnings.is_empty(), "{:?}", r.warnings); let out = &r.files["yarn.lock"]; - let keys: Vec<&str> = out - .lines() - .filter(|l| l.starts_with('"')) - .collect(); + let keys: Vec<&str> = out.lines().filter(|l| l.starts_with('"')).collect(); assert_eq!( keys, vec![ @@ -8956,7 +8982,11 @@ mod tests { let mut again = RewriteResult::default(); rewrite_yarn_berry(&pinned, std::slice::from_ref(&ovr), &mut again); assert!(again.warnings.is_empty(), "{:?}", again.warnings); - assert!(again.files.is_empty(), "repeat run rewrites nothing: {:?}", again.files); + assert!( + again.files.is_empty(), + "repeat run rewrites nothing: {:?}", + again.files + ); // A pin already complete is confirmed without a write. assert!(again.confirmed_yarn_berry_uuids.contains(BERRY_UUID)); @@ -8969,7 +8999,10 @@ mod tests { assert!(out.contains(&format!("\"left-pad@{new_url}\":")), "{out}"); assert!(!out.contains(BERRY_UUID), "{out}"); let manifest: Value = serde_json::from_str(&repin.files["package.json"]).unwrap(); - assert_eq!(manifest["resolutions"], json!({"left-pad@npm:^1.3.0": new_url})); + assert_eq!( + manifest["resolutions"], + json!({"left-pad@npm:^1.3.0": new_url}) + ); } /// The URL-keyed lock entry alone is half a pin: with its manifest @@ -9216,7 +9249,9 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); let out = &r.files["yarn.lock"]; assert!( - out.contains(&format!("\"left-pad@{url}\":\n version: 1.3.0\n resolution: \"left-pad@{url}\"\n")), + out.contains(&format!( + "\"left-pad@{url}\":\n version: 1.3.0\n resolution: \"left-pad@{url}\"\n" + )), "{out}" ); assert!(!out.contains("__archiveUrl"), "{out}"); @@ -9242,10 +9277,17 @@ mod tests { "{{\n \"name\": \"app\",\n \"resolutions\": {{\n \"{selector}\": \"1.3.0\"\n }}\n}}\n" ); let mut r = RewriteResult::default(); - rewrite_yarn_berry(&berry_files(berry_lock("10c0"), manifest), std::slice::from_ref(&ovr), &mut r); + rewrite_yarn_berry( + &berry_files(berry_lock("10c0"), manifest), + std::slice::from_ref(&ovr), + &mut r, + ); assert!(r.files.is_empty(), "{label}: {:?}", r.files); assert_eq!( - r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), vec!["redirect_yarn_berry_resolutions_conflict"], "{label}" ); @@ -9270,12 +9312,20 @@ mod tests { "mirror tarball" ); // An unrelated user entry is kept as-is next to ours. - let manifest = "{\n \"name\": \"app\",\n \"resolutions\": {\n \"other\": \"2.0.0\"\n }\n}\n"; + let manifest = + "{\n \"name\": \"app\",\n \"resolutions\": {\n \"other\": \"2.0.0\"\n }\n}\n"; let mut r = RewriteResult::default(); - rewrite_yarn_berry(&berry_files(berry_lock("10c0"), manifest.into()), std::slice::from_ref(&ovr), &mut r); + rewrite_yarn_berry( + &berry_files(berry_lock("10c0"), manifest.into()), + std::slice::from_ref(&ovr), + &mut r, + ); assert!(r.warnings.is_empty(), "{:?}", r.warnings); let m: Value = serde_json::from_str(&r.files["package.json"]).unwrap(); - assert_eq!(m["resolutions"], json!({"other": "2.0.0", "left-pad@npm:^1.3.0": url})); + assert_eq!( + m["resolutions"], + json!({"other": "2.0.0", "left-pad@npm:^1.3.0": url}) + ); let mut files = BTreeMap::new(); files.insert("yarn.lock".to_string(), berry_lock("10c0")); @@ -9283,7 +9333,10 @@ mod tests { rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); assert!(r.files.is_empty(), "{:?}", r.files); assert_eq!( - r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), vec!["redirect_yarn_berry_manifest_missing"] ); @@ -9294,10 +9347,17 @@ mod tests { berry_lock("10c0") ); let mut r = RewriteResult::default(); - rewrite_yarn_berry(&berry_files(with_patch, berry_manifest()), std::slice::from_ref(&ovr), &mut r); + rewrite_yarn_berry( + &berry_files(with_patch, berry_manifest()), + std::slice::from_ref(&ovr), + &mut r, + ); assert!(r.files.is_empty(), "{:?}", r.files); let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); - assert!(codes.contains(&"redirect_yarn_berry_shared_descriptor"), "{codes:?}"); + assert!( + codes.contains(&"redirect_yarn_berry_shared_descriptor"), + "{codes:?}" + ); } /// Yarn routes a URL locator to its tarball fetcher only when it is an @@ -9322,7 +9382,10 @@ mod tests { assert!(r.files.is_empty(), "{url}: nothing written"); assert!(r.edits.is_empty(), "{url}: {:?}", r.edits); assert_eq!( - r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), vec!["redirect_yarn_berry_artifact_url_unsupported"], "{url}" ); @@ -12586,7 +12649,11 @@ mod tests { let out = r.files.get("Gemfile.lock").expect("lock rewritten"); let rows: Vec<&str> = out .lines() - .filter(|l| l.trim_start().starts_with("rails (7.0.0)") && l.starts_with(" ") && !l.starts_with(" ")) + .filter(|l| { + l.trim_start().starts_with("rails (7.0.0)") + && l.starts_with(" ") + && !l.starts_with(" ") + }) .collect(); assert_eq!( rows, @@ -12599,7 +12666,11 @@ mod tests { "{entry}: the entry keeps its line ending: {out:?}" ); let model = crate::formats::gem::GemfileLock::parse(out); - assert_eq!(model.checksum("rails", "7.0.0"), Some(patched.as_str()), "{entry}"); + assert_eq!( + model.checksum("rails", "7.0.0"), + Some(patched.as_str()), + "{entry}" + ); assert!(!out.contains("\r\r"), "line endings kept: {out:?}"); let edit = r .edits @@ -12614,7 +12685,10 @@ mod tests { files.insert("Gemfile.lock".to_string(), out.clone()); let again = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); assert!( - !again.edits.iter().any(|e| e.kind == "redirect_gemfile_lock_checksum"), + !again + .edits + .iter() + .any(|e| e.kind == "redirect_gemfile_lock_checksum"), "{entry}: rerun is a no-op: {:?}", again.edits ); @@ -13118,11 +13192,19 @@ mod tests { let redacted = format!( "https://patch.socket.dev/patch/npm/left-pad/1.3.0//{uuid}/left-pad-1.3.0.tgz?x=1" ); - assert_eq!(redact_grant_token(&url, &url, uuid), redacted, "the URL alone"); + assert_eq!( + redact_grant_token(&url, &url, uuid), + redacted, + "the URL alone" + ); let text = format!("vlt would fail to verify {url}: fetch error GET {url}: reset"); - let want = format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); + let want = + format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); assert_eq!(redact_grant_token(&text, &url, uuid), want, "every quote"); - assert!(!redact_grant_token(&text, &url, uuid).contains(token), "no token left"); + assert!( + !redact_grant_token(&text, &url, uuid).contains(token), + "no token left" + ); let registry = format!("https://patch.socket.dev/patch-registry/npm/{token}/{uuid}"); assert_eq!( redact_grant_token(®istry, ®istry, uuid), @@ -14877,7 +14959,10 @@ mod tests { ("crlf", lf.replace('\n', "\r\n")), ("tabs", lf.replace(" ", "\t")), ("bom", format!("\u{feff}{lf}")), - ("bom+crlf+tabs", format!("\u{feff}{}", lf.replace(" ", "\t").replace('\n', "\r\n"))), + ( + "bom+crlf+tabs", + format!("\u{feff}{}", lf.replace(" ", "\t").replace('\n', "\r\n")), + ), ]; for (shape, pristine) in shapes { let mut files = BTreeMap::new(); @@ -14893,7 +14978,10 @@ mod tests { "http://patch.test/left-pad-1.3.0.tgz", ) .replace("sha512-UPSTREAM==", "sha512-PATCHED=="); - assert_eq!(out, &expected, "{shape}: only the rewired values may change"); + assert_eq!( + out, &expected, + "{shape}: only the rewired values may change" + ); } } @@ -17237,7 +17325,8 @@ packages: ); // One edit; its fragments are the on-disk bytes of the entry. - let lock_edits: Vec<&FileEdit> = r.edits.iter().filter(|e| e.path == "yarn.lock").collect(); + let lock_edits: Vec<&FileEdit> = + r.edits.iter().filter(|e| e.path == "yarn.lock").collect(); assert_eq!(lock_edits.len(), 1, "{label}"); let edit = lock_edits[0]; let (orig, new) = ( @@ -17247,15 +17336,8 @@ packages: assert_eq!( (orig, new), ( - respell( - lf_edit - .original - .as_ref() - .unwrap() - .as_str() - .unwrap() - ) - .trim_start_matches('\u{feff}'), + respell(lf_edit.original.as_ref().unwrap().as_str().unwrap()) + .trim_start_matches('\u{feff}'), respell(lf_edit.new.as_ref().unwrap().as_str().unwrap()) .trim_start_matches('\u{feff}'), ), @@ -19326,7 +19408,11 @@ packages: format!( "__metadata:\n version: 8\n cacheKey: 10c0\n\n{key}:\n version: 9.0.1\n \ resolution: \"x\"\n{} languageName: node\n linkType: hard\n", - if bin { " bin:\n uuid: dist/bin/uuid\n" } else { "" } + if bin { + " bin:\n uuid: dist/bin/uuid\n" + } else { + "" + } ) }; let needs = |lock: String| berry_pin_needs_manifest(&berry_bin_entries(&lock), &dep); @@ -19337,9 +19423,14 @@ packages: assert!(!needs(entry("\"uuid@npm:other-uuid@^9.0.0\"", true))); assert!(!needs(entry("\"uuid@npm:^9.0.0, other@npm:^1.0.0\"", true))); assert!(!needs(entry("\"uuid@patch:uuid@npm%3A9.0.1#x\"", true))); - assert!(!needs(entry("\"uuid@https://mirror.example/uuid-9.0.1.tgz\"", true))); + assert!(!needs(entry( + "\"uuid@https://mirror.example/uuid-9.0.1.tgz\"", + true + ))); // Another version of the package (`9.0.10` shares the prefix). - assert!(!needs(entry("\"uuid@npm:^9.0.0\"", true).replace("9.0.1\n", "9.0.10\n"))); + assert!(!needs( + entry("\"uuid@npm:^9.0.0\"", true).replace("9.0.1\n", "9.0.10\n") + )); } /// A bun URL 3-tuple already at the CURRENT artifact URL but with a stale diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index ac102ef78..6a9c4a17a 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -33,8 +33,6 @@ //! and — when the project file is silent — the user / global / builtin //! config files ([`resolve_outer_allow_remote`]). - - /// Repo-relative path of the project `.npmrc` the auto-config edits. pub const NPMRC_REL: &str = ".npmrc"; @@ -1137,5 +1135,4 @@ mod tests { ); } } - } diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index 95d910f23..608dbfd74 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -235,9 +235,18 @@ mod tests { #[test] fn legacy_formats_warn_stale_install_risk_once() { for (fixture, warns) in [ - (include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), true), - (include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), true), - (include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), false), + ( + include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), + true, + ), + ( + include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), + true, + ), + ( + include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), + false, + ), ] { let mut result = RewriteResult::default(); rewrite( @@ -410,7 +419,11 @@ mod parse_reuse_equivalence_tests { let what = format!("{fixture} extra={extra} crlf={crlf}"); let mut got = RewriteResult::default(); rewrite(&files, &deps, &mut got); - g.case(what.replace(' ', "/"), &(&files, &deps), &format!("{got:?}")); + g.case( + what.replace(' ', "/"), + &(&files, &deps), + &format!("{got:?}"), + ); confirmed += got.confirmed_pdm_uuids.len(); let mut again = files.clone(); diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index 0371ec923..c13b4a567 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -456,7 +456,10 @@ mod tests { let original = serde_json::to_string(&value).unwrap(); // A live lock (Pipfile beside it): conflicts veto the siblings. let files = BTreeMap::from([ - ("Pipfile".to_string(), "[packages]\nurllib3 = \"*\"\n".to_string()), + ( + "Pipfile".to_string(), + "[packages]\nurllib3 = \"*\"\n".to_string(), + ), ("Pipfile.lock".to_string(), original), ]); let mut result = RewriteResult::default(); @@ -496,20 +499,30 @@ mod tests { for stale in &stale_locks { let files = BTreeMap::from([ ("Pipfile.lock".to_string(), stale.clone()), - ("requirements.txt".to_string(), "urllib3==1.26.18\n".to_string()), + ( + "requirements.txt".to_string(), + "urllib3==1.26.18\n".to_string(), + ), ]); - let result = super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); + let result = + super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); assert!( !result.refused_pipenv_uuids.contains("patch-one"), "a non-conflict must not veto: {stale}" ); assert!( - result.warnings.iter().any(|w| w.code == "redirect_pipenv_skipped"), + result + .warnings + .iter() + .any(|w| w.code == "redirect_pipenv_skipped"), "{:?}", result.warnings ); assert!( - result.files.get("requirements.txt").is_some_and(|t| t.contains("patch.socket.dev")), + result + .files + .get("requirements.txt") + .is_some_and(|t| t.contains("patch.socket.dev")), "requirements.txt must still be redirected past a stale Pipfile.lock: {result:?}" ); assert!(!result.files.contains_key("Pipfile.lock")); @@ -567,7 +580,10 @@ mod tests { let files = |text: &str| BTreeMap::from([("Pipfile.lock".to_string(), text.to_string())]); assert!(lock_targets(&files(&lock()), std::slice::from_ref(&dep))); assert!(!lock_targets(&files(&lock()), std::slice::from_ref(&other))); - assert!(!lock_targets(&files("{ not json"), std::slice::from_ref(&dep))); + assert!(!lock_targets( + &files("{ not json"), + std::slice::from_ref(&dep) + )); assert!(!lock_targets(&BTreeMap::new(), std::slice::from_ref(&dep))); let mut npm = dep.clone(); npm.ecosystem = "npm".into(); @@ -593,7 +609,10 @@ mod tests { let entry: Value = serde_json::from_str(&fixed).unwrap(); assert!(entry["default"]["urllib3"].get("version").is_none()); assert_eq!(entry["default"]["urllib3"]["index"], json!("pypi")); - assert!(entry["default"]["urllib3"]["file"].as_str().unwrap().contains("patch-one")); + assert!(entry["default"]["urllib3"]["file"] + .as_str() + .unwrap() + .contains("patch-one")); value["default"]["urllib3"]["version"] = json!("==2.0.0"); let conflicting = serde_json::to_string(&value).unwrap(); @@ -614,7 +633,10 @@ mod tests { assert!(owned_url(public, &dep)); assert!(!owned_url("https://example.org/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl", &dep)); dep.artifact_url = "https://patches.internal.example:8443/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl".into(); - assert!(owned_url(&dep.artifact_url, &dep), "the grant's own origin is ours"); + assert!( + owned_url(&dep.artifact_url, &dep), + "the grant's own origin is ours" + ); assert!(owned_url(public, &dep), "and so is the public service"); assert!(!owned_url("https://patches.internal.example:8443/patch/pypi/urllib3/1.26.19/tok/patch-one/urllib3-1.26.19-py3-none-any.whl", &dep), "another version is not"); // Rotation on the custom origin re-points the owned entry. @@ -625,7 +647,6 @@ mod tests { assert!(second.contains("/rotated/") && !second.contains("/tok/")); } - /// Hosted Pipenv recognizes its own pins through the shared recognizer /// (#563): a path-prefixed `--patch-server-url` deployment rotates its /// grant instead of refusing its own previous reference, and a hosted @@ -699,7 +720,9 @@ mod compatibility_tests { assert!(!result.refused_pipenv_uuids.contains("patch-one")); assert!(result.files["requirements.txt"].contains("patch.socket.dev")); assert!(!result.files.contains_key("Pipfile.lock")); - assert!(result.warnings.iter().any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); + assert!(result + .warnings + .iter() + .any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); } - } diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index 624b74c4a..b84dde5fa 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -92,7 +92,9 @@ pub(super) fn rewrite_poetry( } } Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -100,7 +102,9 @@ pub(super) fn rewrite_poetry( continue; } } - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); content = rewrite.text; if !stale_warned { if let Some(format) = @@ -136,14 +140,18 @@ pub(super) fn rewrite_poetry( } // Already redirected to this artifact (idempotent re-scan). Ok(Some(_)) => { - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -268,7 +276,11 @@ mod equivalence_tests { let mut again = files.clone(); again.extend(got.files.clone()); let got = run(rewrite_poetry, &again, &deps); - g.case(format!("{what}/re-run"), &(&again, &deps), &format!("{got:?}")); + g.case( + format!("{what}/re-run"), + &(&again, &deps), + &format!("{got:?}"), + ); } } } diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 6d1b2f5d0..98d0b620e 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -56,7 +56,6 @@ impl RedirectState { records: BTreeMap::new(), } } - } impl Default for RedirectState { @@ -518,5 +517,4 @@ mod tests { "changed bytes still go through the (here refused) atomic write" ); } - } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs index f51142f69..87c49a542 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -332,7 +332,10 @@ mod tests { let package_start = u64::from_le_bytes(lock[110..118].try_into().unwrap()) as usize; // The root resolution's flag byte (its last). let flags_at = package_start + count * 16 + 63; - assert_eq!(lock[flags_at], crate::vendor::bun_lockb::NORMALIZED_FORMAT_1); + assert_eq!( + lock[flags_at], + crate::vendor::bun_lockb::NORMALIZED_FORMAT_1 + ); lock[flags_at] |= 0x40; BunLockb::parse(&lock).unwrap().validate_mutation().unwrap(); let (outcome, after) = run(&lock, &vendor_opts()).await; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index c44d7919e..70ca86a6d 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -97,7 +97,10 @@ pub(crate) async fn restore( ) }); let cksums: BTreeMap> = - futures_util::future::join_all(lookups).await.into_iter().collect(); + futures_util::future::join_all(lookups) + .await + .into_iter() + .collect(); let mut changed = false; let mut restored: Vec<(&LockHit, String)> = Vec::new(); for hit in &hits { @@ -116,7 +119,9 @@ pub(crate) async fn restore( } // The entries' own source + checksum values, spliced at the parse's // spans (every hit is a distinct block: its source names its uuid). - let spans = model.spans().expect("a lock parsed from text carries spans"); + let spans = model + .spans() + .expect("a lock parsed from text carries spans"); let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); for (hit, cksum) in &restored { let at = &spans.packages[hit.index]; @@ -133,7 +138,10 @@ pub(crate) async fn restore( } for (hit, cksum) in &restored { // Dependents' full-id references and the v1 `[metadata]` key. - lock = lock.replace(&format!("({})", hit.source), &format!("({CRATES_IO_SOURCE})")); + lock = lock.replace( + &format!("({})", hit.source), + &format!("({CRATES_IO_SOURCE})"), + ); let metadata_key = format!( "\"checksum {} {} ({CRATES_IO_SOURCE})\" = \"", hit.name, hit.version @@ -152,7 +160,11 @@ pub(crate) async fn restore( if changed { view.write( "Cargo.lock", - if crlf { lock.replace('\n', "\r\n") } else { lock }, + if crlf { + lock.replace('\n', "\r\n") + } else { + lock + }, ); } } @@ -317,11 +329,10 @@ fn remove_registry_block(config: &str, reg: &str) -> Option { end -= 1; } let fragment = format!("{}\n", lines[i..end].join("\n")); - let removed = remove_appended_cargo_block(&lf, &fragment) - .or_else(|| { - // The block ends the file with no final newline. - remove_appended_cargo_block(&lf, fragment.trim_end_matches('\n')) - })?; + let removed = remove_appended_cargo_block(&lf, &fragment).or_else(|| { + // The block ends the file with no final newline. + remove_appended_cargo_block(&lf, fragment.trim_end_matches('\n')) + })?; Some(if crlf { removed.replace('\n', "\r\n") } else { @@ -388,7 +399,10 @@ mod tests { #[test] fn table_form_line_is_dropped() { - assert_eq!(unpin_line(&format!("registry = \"{REG}\""), REG), Some(None)); + assert_eq!( + unpin_line(&format!("registry = \"{REG}\""), REG), + Some(None) + ); } #[test] @@ -397,7 +411,10 @@ mod tests { let hosted = format!( "{original}\n[registries.{REG}]\nindex = \"sparse+https://patch.socket.dev/x/index/\"\n" ); - assert_eq!(remove_registry_block(&hosted, REG).as_deref(), Some(original)); + assert_eq!( + remove_registry_block(&hosted, REG).as_deref(), + Some(original) + ); let created = format!("[registries.{REG}]\nindex = \"sparse+https://x/\"\n"); assert_eq!(remove_registry_block(&created, REG).as_deref(), Some("")); } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index a20a3cb3c..c47227d7e 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -57,11 +57,11 @@ use std::collections::{BTreeMap, BTreeSet}; use regex::Regex; use super::{Ctx, FormatResult, HostedPin, View}; -use crate::utils::line_endings::{to_lf, LineEndings}; -use crate::vendor::gem::{gem_declaration_any, quoted_literal}; use crate::formats::gem::{ bundler_manifest_for, parse_spec, same_remote, split_checksum_entry, BUNDLER_LOCKS, }; +use crate::utils::line_endings::{to_lf, LineEndings}; +use crate::vendor::gem::{gem_declaration_any, quoted_literal}; /// The default upstream `GEM` remote. const RUBYGEMS_REMOTE: &str = "https://rubygems.org/"; @@ -250,17 +250,14 @@ fn choose_upstream( /// The line after which a spec named `name-version` sorts into `sec` /// (bundler writes specs sorted by full name). fn insertion_point(sec: &GemSec, full_name: &str) -> Option { - let pred = sec - .entries - .iter() - .rfind(|e| { - let full = if e.version.is_empty() { - e.name.clone() - } else { - format!("{}-{}", e.name, e.version) - }; - full.as_str() < full_name - }); + let pred = sec.entries.iter().rfind(|e| { + let full = if e.version.is_empty() { + e.name.clone() + } else { + format!("{}-{}", e.name, e.version) + }; + full.as_str() < full_name + }); pred.map(|e| e.last).or(sec.specs_line) } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs b/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs index 4ce16051f..cee422040 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs @@ -65,7 +65,10 @@ pub(crate) async fn restore( (uuid.clone(), ctx.client.go_sums(module, version).await) }); let sums: std::collections::BTreeMap> = - futures_util::future::join_all(lookups).await.into_iter().collect(); + futures_util::future::join_all(lookups) + .await + .into_iter() + .collect(); let mut go_mod_next = go_mod.clone(); let mut go_sum = view.read("go.sum").await.ok().flatten(); @@ -88,8 +91,8 @@ pub(crate) async fn restore( } } if let Some(text) = go_sum.as_deref() { - let mut next = remove_module_prefix_lines(text, socket_module) - .unwrap_or_else(|| text.to_string()); + let mut next = + remove_module_prefix_lines(text, socket_module).unwrap_or_else(|| text.to_string()); let upstream = format!( "{module} {version} {}\n{module} {version}/go.mod {}\n", sums.zip_h1, sums.mod_h1 diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 8417b5a8b..3de39ee92 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -350,9 +350,7 @@ pub struct RestoreOutcome { impl RestoreOutcome { pub fn restored(&self) -> impl Iterator { - self.pins - .iter() - .filter(|p| p.status == PinStatus::Restored) + self.pins.iter().filter(|p| p.status == PinStatus::Restored) } pub fn refused(&self) -> impl Iterator { @@ -684,9 +682,7 @@ async fn restore_pass(view: &mut View<'_>, active: &[&HostedPin], ctx: &Ctx<'_>) Format::YarnLock => npm::restore_yarn_locks(view, &pins, &files, ctx).await, Format::PnpmLock => npm::restore_pnpm_locks(view, &pins, &files, ctx).await, Format::BunLock => npm::restore_bun_locks(view, &pins, &files, ctx).await, - Format::BunLockb if ctx.bun_lockb => { - bun_lockb::restore(view, &pins, &files, ctx).await - } + Format::BunLockb if ctx.bun_lockb => bun_lockb::restore(view, &pins, &files, ctx).await, Format::Cargo => cargo::restore(view, &pins, &files, ctx).await, Format::Golang => golang::restore(view, &pins, &files, ctx).await, Format::Gem => gem::restore(view, &pins, &files, ctx).await, diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs b/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs index ac105569f..8530ddf48 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs @@ -57,7 +57,16 @@ fn files_value(release: &[PypiFile], by_url: bool) -> Option { let key = if by_url { "url" } else { "file" }; let mut located: Vec<(&str, &PypiFile)> = release .iter() - .map(|f| (if by_url { f.url.as_str() } else { f.filename.as_str() }, f)) + .map(|f| { + ( + if by_url { + f.url.as_str() + } else { + f.filename.as_str() + }, + f, + ) + }) .collect(); // PDM orders each entry's files by the location it writes: a `static_urls` // lock by URL (so an sdist under `0c/…` precedes a wheel under `b0/…`), diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index 4e1fe9479..66fb3c863 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -454,7 +454,8 @@ fn compile(file: &str, lists: &[(&'static str, &[String])]) -> Result &'static SelectionPolicy { - static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(SelectionPolicy::unrestricted); + static DEFAULTS: std::sync::LazyLock = + std::sync::LazyLock::new(SelectionPolicy::unrestricted); &DEFAULTS } @@ -803,7 +804,9 @@ fn ceiling_dirs() -> Vec { #[cfg(unix)] fn trusted_owner(meta: &std::fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; - let sudo_uid = std::env::var("SUDO_UID").ok().and_then(|v| v.trim().parse::().ok()); + let sudo_uid = std::env::var("SUDO_UID") + .ok() + .and_then(|v| v.trim().parse::().ok()); // SAFETY: geteuid has no preconditions and cannot fail. owner_trusted(meta.uid(), unsafe { libc::geteuid() }, sudo_uid) } diff --git a/crates/socket-patch-core/src/policy/report.rs b/crates/socket-patch-core/src/policy/report.rs index ba8ff4221..0d095c7dc 100644 --- a/crates/socket-patch-core/src/policy/report.rs +++ b/crates/socket-patch-core/src/policy/report.rs @@ -48,7 +48,9 @@ pub fn policy_block( let (floor, floor_source) = policy.min_severity(); // Sorted: crawl order is filesystem order, and the two engines differ. let mut filtered: Vec<&FilteredEntry> = filtered.iter().collect(); - filtered.sort_by(|a, b| (&a.project, &a.purl, a.reason.code()).cmp(&(&b.project, &b.purl, b.reason.code()))); + filtered.sort_by(|a, b| { + (&a.project, &a.purl, a.reason.code()).cmp(&(&b.project, &b.purl, b.reason.code())) + }); let mut retained: Vec<&RetainedEntry> = retained.iter().collect(); retained.sort_by(|a, b| (&a.project, &a.purl).cmp(&(&b.project, &b.purl))); let filtered: Vec = filtered diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs index 30a99499c..103fe20bd 100644 --- a/crates/socket-patch-core/src/policy/socket_yml.rs +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -486,7 +486,11 @@ pub(crate) fn package_spec_error(spec: &str) -> Option<&'static str> { if spec.is_empty() { return Some("package spec is empty"); } - if let Some(rest) = spec.get(..4).filter(|p| p.eq_ignore_ascii_case("pkg:")).map(|_| &spec[4..]) { + if let Some(rest) = spec + .get(..4) + .filter(|p| p.eq_ignore_ascii_case("pkg:")) + .map(|_| &spec[4..]) + { let valid = rest.split_once('/').is_some_and(|(ty, name)| { !ty.is_empty() && !name.trim_matches('/').is_empty() && !name.starts_with('@') }); @@ -785,7 +789,9 @@ pub(crate) fn parse_file( Some(Err((key, message))) => { warnings.push(PolicyWarning { code: super::SOCKET_YML_IGNORED_VALUE, - detail: super::strip_unsafe(&format!("{file}: {key} {message}; the key is ignored")), + detail: super::strip_unsafe(&format!( + "{file}: {key} {message}; the key is ignored" + )), }); Vec::new() } @@ -916,8 +922,12 @@ mod tests { // YAML beats everything; the case variant beats the version gate; // the version gate beats the keys. assert_eq!(err_key("patches: {minSeverty: x}\n").0, "version"); - assert!(err_key("Patches: {}\npatches: {minSeverty: x}\n").1.contains("misspelled")); - assert!(err_key("patches: {minSeverty: x\n").1.contains("invalid YAML")); + assert!(err_key("Patches: {}\npatches: {minSeverty: x}\n") + .1 + .contains("misspelled")); + assert!(err_key("patches: {minSeverty: x\n") + .1 + .contains("invalid YAML")); } #[test] @@ -986,12 +996,9 @@ mod tests { let (key, message) = err_key(text); assert_eq!(key, "", "{text:?}"); assert!( - [ - "invalid YAML", - "top level must be a mapping", - ] - .iter() - .any(|m| message.contains(m)), + ["invalid YAML", "top level must be a mapping",] + .iter() + .any(|m| message.contains(m)), "{text:?}: {message}" ); } diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index 9a6d247a2..f64635eba 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -418,8 +418,14 @@ fn composer_package_filters_match_release_identity_and_preserve_branch_case() { Err(FilterReason::PackageIgnored { .. }) )); assert!(policy.admits_purl("pkg:composer/psr/log@3.0.3").is_ok()); - assert!(package_spec_matches("pkg:composer/PSR/Log@3.0.2.0", "pkg:composer/psr/log@3.0.2")); - assert!(!package_spec_matches("pkg:composer/psr/log@dev-Feature", "pkg:composer/psr/log@dev-feature")); + assert!(package_spec_matches( + "pkg:composer/PSR/Log@3.0.2.0", + "pkg:composer/psr/log@3.0.2" + )); + assert!(!package_spec_matches( + "pkg:composer/psr/log@dev-Feature", + "pkg:composer/psr/log@dev-feature" + )); } #[test] @@ -577,7 +583,10 @@ mod disk { assert!(owner_trusted(1000, 1000, None)); assert!(owner_trusted(0, 1000, None)); assert!(!owner_trusted(1001, 1000, None)); - assert!(owner_trusted(1001, 1000, Some(1001)), "sudo's invoking user"); + assert!( + owner_trusted(1001, 1000, Some(1001)), + "sudo's invoking user" + ); assert!(owner_trusted(1001, 0, None), "root trusts every owner"); } @@ -598,13 +607,21 @@ mod disk { fn this_repos_socket_yml_loads_and_excludes_its_fixtures() { let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let (policy, warnings) = - SelectionPolicy::load(&DiskPolicyFs::new(&repo), &PolicyOverrides::default()).expect("valid"); + SelectionPolicy::load(&DiskPolicyFs::new(&repo), &PolicyOverrides::default()) + .expect("valid"); assert!(warnings.is_empty(), "{warnings:?}"); assert!(matches!(policy.source(), PolicySource::File { path, .. } if path == "socket.yml")); let lock = strings(&["package-lock.json"]); let err = policy - .admits_root(&root("crates/socket-patch-core/tests/fixtures/redirect/npm", &lock, true)) + .admits_root(&root( + "crates/socket-patch-core/tests/fixtures/redirect/npm", + &lock, + true, + )) .unwrap_err(); - assert_eq!(err.detail(), "crates/socket-patch-core/tests/fixtures/** (projectIgnorePaths)"); + assert_eq!( + err.detail(), + "crates/socket-patch-core/tests/fixtures/** (projectIgnorePaths)" + ); assert!(policy.admits_root(&root("", &lock, true)).is_ok()); } diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index 9ebd7e7ac..7c24ebadf 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -394,7 +394,11 @@ impl Stage { "a patch lookup failed for a package that could get its first patch, so \ no new patches were added this run ({} deferred) and none can take the \ missing package's place; re-run once the API answers", - if deferred == 1 { "1 package".to_string() } else { format!("{deferred} packages") } + if deferred == 1 { + "1 package".to_string() + } else { + format!("{deferred} packages") + } ), )); } @@ -415,7 +419,9 @@ impl Stage { purl: c.purl.clone(), uuid: c.uuid.clone(), reason: ROLLOUT_DEFERRED.to_string(), - detail: Some(format!("rank {rank} in the rollout queue; a later scan adds it")), + detail: Some(format!( + "rank {rank} in the rollout queue; a later scan adds it" + )), }) .collect() } @@ -502,4 +508,3 @@ pub fn rollout_json(configured: &MaxNew, plan: Option<&RolloutPlan>) -> serde_js "deferred": deferred, }) } - diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index d49aaa5c6..5f0eccb8d 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -4,9 +4,7 @@ use once_cell::sync::Lazy; use uuid::Uuid; use crate::constants::USER_AGENT; -use crate::utils::env_compat::{ - is_debug_enabled, is_offline_env, proxy_url_from_env, -}; +use crate::utils::env_compat::{is_debug_enabled, is_offline_env, proxy_url_from_env}; use crate::utils::fs::home_dir; use crate::vex::time::unix_to_ymdhms; diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs index ea7339f63..fbee9ebc8 100644 --- a/crates/socket-patch-core/src/update/download.rs +++ b/crates/socket-patch-core/src/update/download.rs @@ -739,7 +739,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("never-existed"); sweep_stale_stages(&missing); - assert!(!missing.exists(), "sweep must not create the destination dir"); + assert!( + !missing.exists(), + "sweep must not create the destination dir" + ); } /// A write failure AFTER a successful open (EFBIG here, standing in @@ -755,8 +758,7 @@ mod tests { #[test] fn stage_write_failure_cleans_up_stage_file() { const CHILD_ENV: &str = "SOCKET_PATCH_CORE_TEST_STAGE_FSIZE_CHILD"; - const TEST_NAME: &str = - "update::download::tests::stage_write_failure_cleans_up_stage_file"; + const TEST_NAME: &str = "update::download::tests::stage_write_failure_cleans_up_stage_file"; if std::env::var_os(CHILD_ENV).is_none() { let exe = std::env::current_exe().expect("test binary path must resolve"); let output = std::process::Command::new(exe) @@ -822,7 +824,10 @@ mod tests { matches!(err, UpdateError::SwapFailed(_)), "expected SwapFailed, got: {err}" ); - assert!(err.to_string().contains("error writing staged binary"), "{err}"); + assert!( + err.to_string().contains("error writing staged binary"), + "{err}" + ); let leftovers: Vec = std::fs::read_dir(tmp.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index 5b2869012..7c3b04c29 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -751,9 +751,11 @@ mod tests { .mount(&server) .await; - let client = - metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) - .unwrap(); + let client = metadata_client( + &short_timeouts(), + follow_redirect_policy(&default_endpoints()), + ) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -786,9 +788,11 @@ mod tests { .mount(&server) .await; - let client = - metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) - .unwrap(); + let client = metadata_client( + &short_timeouts(), + follow_redirect_policy(&default_endpoints()), + ) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -864,7 +868,10 @@ mod tests { .unwrap_err(); assert!(matches!(err, UpdateError::CheckFailed(_)), "{err:?}"); let msg = err.to_string(); - assert!(msg.contains("expected a redirect to the latest tag"), "{msg}"); + assert!( + msg.contains("expected a redirect to the latest tag"), + "{msg}" + ); assert!(msg.contains("API fallback:"), "{msg}"); assert!(msg.contains("returned 500"), "{msg}"); } @@ -945,8 +952,14 @@ mod tests { #[test] fn url_host_keeps_explicit_ports() { - assert_eq!(url_host("http://127.0.0.1:9/x").as_deref(), Some("127.0.0.1:9")); - assert_eq!(url_host("https://github.com/a").as_deref(), Some("github.com")); + assert_eq!( + url_host("http://127.0.0.1:9/x").as_deref(), + Some("127.0.0.1:9") + ); + assert_eq!( + url_host("https://github.com/a").as_deref(), + Some("github.com") + ); assert_eq!(url_host("not a url"), None); } @@ -959,7 +972,9 @@ mod tests { // code stays `check_failed` (stable contract). let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) + .and(path( + "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", + )) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; @@ -992,7 +1007,9 @@ mod tests { // silently. let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) + .and(path( + "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", + )) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index 7b93ada11..94a2c1118 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -304,9 +304,9 @@ where // write the lock edits beside the pre-run ledger. Put the caller's value // back before the unwind continues — the same value a caught-and- // continued caller holds. - if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - edit(Arc::make_mut(value)) - })) { + if let Err(panic) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| edit(Arc::make_mut(value)))) + { files.insert(key, captured(value)); drop(files); std::panic::resume_unwind(panic); @@ -1766,7 +1766,10 @@ mod tests { .unwrap(); remove_dir_after_commit(&dir).await; drop(dropped); - assert!(dir.join("config.toml").exists(), "an abandoned commit removes nothing"); + assert!( + dir.join("config.toml").exists(), + "an abandoned commit removes nothing" + ); let group = GroupCommit::begin(root); super::super::fs::remove_file(&dir.join("config.toml")) @@ -1775,7 +1778,10 @@ mod tests { remove_dir_after_commit(&dir).await; assert!(dir.join("config.toml").exists(), "captured, still on disk"); group.commit().await.unwrap(); - assert!(!dir.exists(), "the emptied directory is removed after the commit"); + assert!( + !dir.exists(), + "the emptied directory is removed after the commit" + ); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("config.toml"), b"[patch]\n").unwrap(); @@ -1787,7 +1793,10 @@ mod tests { remove_dir_after_commit(&dir).await; group.commit().await.unwrap(); assert!(!dir.join("config.toml").exists()); - assert!(dir.join("credentials.toml").exists(), "a non-empty directory is kept"); + assert!( + dir.join("credentials.toml").exists(), + "a non-empty directory is kept" + ); remove_dir_after_commit(&root.join("gone")).await; std::fs::remove_file(dir.join("credentials.toml")).unwrap(); diff --git a/crates/socket-patch-core/src/utils/line_endings.rs b/crates/socket-patch-core/src/utils/line_endings.rs index 889f6ad32..c6f257359 100644 --- a/crates/socket-patch-core/src/utils/line_endings.rs +++ b/crates/socket-patch-core/src/utils/line_endings.rs @@ -119,5 +119,4 @@ mod tests { assert_eq!(majority_terminator("a\r\nb\n"), "\n", "a tie is LF"); assert_eq!(majority_terminator("{}"), "\n", "no break: LF, not os.EOL"); } - } diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index c69552a73..733489194 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -89,9 +89,7 @@ pub(crate) fn resolve_app_alias_with( std::env::split_paths(&path) .filter(|dir| dir.is_absolute()) .map(|dir| dir.join(format!("{name}.exe"))) - .find(|candidate| { - std::fs::symlink_metadata(candidate).is_ok_and(|meta| !meta.is_dir()) - }) + .find(|candidate| std::fs::symlink_metadata(candidate).is_ok_and(|meta| !meta.is_dir())) } /// A plain file that cannot be executed (a stray `bun` data file on PATH) @@ -427,7 +425,11 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let safe = tmp.path().join("bin"); std::fs::create_dir_all(&safe).unwrap(); - let relative = [PathBuf::from("."), PathBuf::from(""), PathBuf::from("planted")]; + let relative = [ + PathBuf::from("."), + PathBuf::from(""), + PathBuf::from("planted"), + ]; let only_relative = std::env::join_paths(&relative).unwrap(); let var = |name: &str| (name == "PATH").then(|| only_relative.clone()); @@ -437,7 +439,10 @@ mod tests { let with_safe = std::env::join_paths(relative.iter().cloned().chain([safe.clone()])).unwrap(); let var = |name: &str| (name == "PATH").then(|| with_safe.clone()); - assert_eq!(resolve_app_alias_with("yarn", &var), Some(safe.join("yarn.exe"))); + assert_eq!( + resolve_app_alias_with("yarn", &var), + Some(safe.join("yarn.exe")) + ); } #[test] diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index df7d84223..72ffdcf0c 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -686,7 +686,12 @@ mod rendering_tests { "{direct}" ); assert!(uv_line.ends_with('}'), "{direct}"); - assert!(direct.starts_with("[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"), "{direct}"); + assert!( + direct.starts_with( + "[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n" + ), + "{direct}" + ); assert_settled(&direct); let transitive = rewrite_project_metadata( diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index f349f94b6..10173ec37 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -1872,7 +1872,10 @@ mod tests { lock.set_package(package.id, &repin, &digest()).unwrap(); assert_eq!(lock.bytes().len(), first.len(), "{version}"); assert!( - !lock.bytes().windows(token.len()).any(|w| w == token.as_bytes()), + !lock + .bytes() + .windows(token.len()) + .any(|w| w == token.as_bytes()), "{version}: the superseded URL is gone" ); // A remote tarball keeps the registry record's inactive bytes; a @@ -1915,7 +1918,9 @@ mod tests { .set_package(1, ".socket/vendor/npm/x/minimist-1.2.2.tgz", &digest()) .unwrap(); let at = local.resolution_at(1); - assert!(local.data[at + 16..at + local.resolution_size].iter().all(|b| *b == 0)); + assert!(local.data[at + 16..at + local.resolution_size] + .iter() + .all(|b| *b == 0)); } #[test] diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 7e50bccaf..73bdf8275 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -64,11 +64,9 @@ use std::sync::Arc; use toml_edit::{DocumentMut, Item, Table}; use super::cargo_tag; -use crate::formats::cargo::{ - locked_packages, metadata_checksum_key, parse_ref, LockedPackage, -}; use super::parse_memo::ParseMemo; use super::state::CargoLockOriginal; +use crate::formats::cargo::{locked_packages, metadata_checksum_key, parse_ref, LockedPackage}; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; /// Why a lock edit could not be performed. diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index a9e7d65ff..77613e080 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -1636,12 +1636,14 @@ fn rest_blocks_edit(rest: &str) -> Option { } // A `**opts` splat or hash literal is kept after `path:` (#847): a // source hidden in it makes bundler refuse the Gemfile loudly. - gemfile::source_option(rest).filter(|opt| !opt.dynamic).map(|opt| { - format!( - "the declaration already carries `{}` (revert any previous vendoring first)", - opt.spelling - ) - }) + gemfile::source_option(rest) + .filter(|opt| !opt.dynamic) + .map(|opt| { + format!( + "the declaration already carries `{}` (revert any previous vendoring first)", + opt.spelling + ) + }) } /// The quoted `path:` option value on a gem line's argument tail (only the diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index acd48d721..a3bba7502 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -12,13 +12,11 @@ use std::io; use std::path::Path; use std::sync::Arc; -use crate::constants::npm_family::{ - BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, -}; -use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; -use crate::vendor::npm_flavor::NpmLockFlavor; +use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_LOCK}; use crate::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; use crate::formats::yarn::{sniff_grammar, YarnLockGrammar, UNIDENTIFIED_DETAIL}; +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; +use crate::vendor::npm_flavor::NpmLockFlavor; use crate::vendor::VendorWarning; /// One in-memory file. @@ -376,7 +374,21 @@ pub(crate) async fn detect_npm_lock_flavor_in( }) }; - if let Some(marker) = PNP_MARKERS.iter().find(|m| exists(m)) { + // A loader the configured `nodeLinker` disowns is stale (#975). Only the + // project's own `.yarnrc.yml` is in a memory snapshot. + let linker = || { + std::env::var("YARN_NODE_LINKER") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| { + let rc = project.read_text(".yarnrc.yml").ok()?; + crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }) + }; + if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, &exists) { return Err(( "vendor_yarn_berry_unsupported", format!( @@ -553,6 +565,19 @@ mod tests { .0, NpmLockFlavor::Vlt ); + // #975: a stale Yarn 2 loader under a non-pnp linker is ignored. + let stale = project(&[ + (".pnp.js", MemoryEntry::Present), + (".yarnrc.yml", text("nodeLinker: node-modules\n")), + ("yarn.lock", text("__metadata:\n version: 8\n")), + ]); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&stale)) + .await + .unwrap() + .0, + NpmLockFlavor::YarnBerry + ); let pnp = project(&[(".pnp.cjs", MemoryEntry::Present), ("yarn.lock", text(""))]); assert_eq!( detect_npm_lock_flavor_in(&ProjectView::Memory(&pnp)) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index 9ed45e49d..c3c21f8e9 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -7,12 +7,12 @@ use toml_edit::{DocumentMut, Item}; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK}; use crate::formats::pnpm::PnpmLock; +use crate::formats::yarn::is_berry_lock; use crate::utils::digest::is_sri_pin; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use crate::utils::python_lock::{ lock_artifact, lock_package_collection, package_artifacts, uv_source_location, }; -use crate::formats::yarn::is_berry_lock; use crate::vendor::bun_lock_text::{decode_json_string, split_name_spec}; use crate::vendor::bun_lockb::BunLockb; use crate::vendor::yarn_berry_lock::berry_field; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 21fb4aa46..c3778ee98 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -5,7 +5,9 @@ //! a `pnpm-lock.yaml` only routes to the pnpm backend when its //! `lockfileVersion` is one we have fixtures for, and a `yarn.lock` routes //! to classic or berry by its header (the v1 comment vs a top-level -//! `__metadata:` key). Only PnP projects (`.pnp.*` loaders) are refused +//! `__metadata:` key). Only PnP projects (a `.pnp.*` loader the configured +//! yarn `nodeLinker` still uses; forward vendoring also refuses a berry +//! project configured for PnP before its loader exists) are refused //! outright: yarn-berry PnP because its packages never land on disk to //! stage, and pnpm's own `node-linker=pnp` mode (same loader file, real //! package dirs) because the file: rewiring is unvalidated under that @@ -88,9 +90,7 @@ impl NpmLockFlavor { /// Yarn berry Plug'n'Play loaders: packages live inside `.yarn/cache/` zips, /// so there is nothing on disk to stage and no lockfile entry to rewire. -use crate::constants::npm_family::{ - BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, -}; +use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_LOCK}; /// Every lockfile name the probe knows, grouped into wiring families: the /// flavor that owns a family wires (or supersedes) every file in it, so only @@ -163,31 +163,34 @@ pub(crate) async fn detect_npm_lock_flavor( // wrong twice over. Vendor still refuses (the file: rewiring has no // fixtures under pnpm's PnP linker — fail closed), but with a pnpm // diagnosis and remedy. - for marker in PNP_MARKERS { - if exists(marker).await { - if crate::crawlers::pkg_managers::pnpm_pnp_layout(project_root) { - return Err(( - "vendor_pnpm_pnp_unsupported", - format!( - "found `{marker}` alongside pnpm-lock.yaml and an installed pnpm \ - store: this is a pnpm project using `node-linker=pnp` (.npmrc), \ - not yarn berry — vendor's relative file: rewiring is not \ - validated under pnpm's Plug'n'Play linker; use `socket-patch \ - scan --mode hosted` (which edits pnpm-lock.yaml in place), or \ - switch .npmrc to `node-linker=isolated`, run `pnpm install`, \ - and re-run vendor" - ), - )); - } + // A loader the configured `nodeLinker` disowns is stale (#975) and + // skipped, so the yarn berry sniff below decides. + let marker = crate::crawlers::pkg_managers::live_pnp_marker(project_root, |m| { + std::fs::metadata(project_root.join(m)).is_ok() + }); + if let Some(marker) = marker { + if crate::crawlers::pkg_managers::pnpm_pnp_layout(project_root) { return Err(( - "vendor_yarn_berry_unsupported", + "vendor_pnpm_pnp_unsupported", format!( - "found `{marker}`: this is a yarn berry Plug'n'Play project — packages \ - live inside .yarn/cache/ zips, not node_modules/, so there is nothing \ - vendor could stage or rewire; use `yarn patch ` instead" + "found `{marker}` alongside pnpm-lock.yaml and an installed pnpm \ + store: this is a pnpm project using `node-linker=pnp` (.npmrc), \ + not yarn berry — vendor's relative file: rewiring is not \ + validated under pnpm's Plug'n'Play linker; use `socket-patch \ + scan --mode hosted` (which edits pnpm-lock.yaml in place), or \ + switch .npmrc to `node-linker=isolated`, run `pnpm install`, \ + and re-run vendor" ), )); } + return Err(( + "vendor_yarn_berry_unsupported", + format!( + "found `{marker}`: this is a yarn berry Plug'n'Play project — packages \ + live inside .yarn/cache/ zips, not node_modules/, so there is nothing \ + vendor could stage or rewire; use `yarn patch ` instead" + ), + )); } let detected = 'flavor: { @@ -334,6 +337,39 @@ async fn sniff_yarn_lock(project_root: &Path) -> Result Result<(NpmLockFlavor, Vec), (&'static str, String)> { + let found = detect_npm_lock_flavor(project_root).await?; + if found.0 != NpmLockFlavor::YarnBerry { + return Ok(found); + } + let linker = crate::crawlers::pkg_managers::yarn_node_linker(project_root); + if !crate::crawlers::pkg_managers::yarn_linker_is_pnp(linker.as_deref()) { + return Ok(found); + } + let why = match linker { + Some(_) => "the configured yarn linker is `nodeLinker: pnp`", + None => "no `.yarnrc.yml` sets `nodeLinker`, so yarn berry uses its default `pnp` linker", + }; + Err(( + "vendor_yarn_berry_unsupported", + format!( + "{why}: this is a yarn berry Plug'n'Play project — packages live inside \ + .yarn/cache/ zips, not node_modules/, so vendor does not wire it; set \ + `nodeLinker: node-modules` in .yarnrc.yml and run `yarn install`, or use \ + `yarn patch ` instead" + ), + )) +} + /// Vendor one npm package through whichever lockfile-flavor backend serves /// this project (package-lock / yarn classic / yarn berry node-modules / /// pnpm / pnpm legacy / bun / vlt). Probe refusals (PnP, unsupported lock @@ -352,7 +388,7 @@ pub async fn vendor_npm_any<'a>( service: Option<&super::VendorServiceConfig>, ) -> VendorOutcome { let installed_dir = installed_dir.into(); - let (flavor, probe_warnings) = match detect_npm_lock_flavor(project_root).await { + let (flavor, probe_warnings) = match detect_vendorable_npm_flavor(project_root).await { Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; @@ -463,9 +499,10 @@ pub async fn vlt_routes(project_root: &Path) -> Option Some(Ok(())), Ok(_) => None, Err(refusal) => { - let pnp = PNP_MARKERS - .iter() - .any(|m| std::fs::symlink_metadata(project_root.join(m)).is_ok()); + let pnp = crate::crawlers::pkg_managers::live_pnp_marker(project_root, |m| { + std::fs::symlink_metadata(project_root.join(m)).is_ok() + }) + .is_some(); let vlt = tokio::fs::metadata(project_root.join(VLT_LOCK)) .await .is_ok(); @@ -478,7 +515,7 @@ pub async fn preflight_packages( project_root: &Path, packages: &[(&str, &PatchRecord)], ) -> Vec> { - let flavor = match detect_npm_lock_flavor(project_root).await { + let flavor = match detect_vendorable_npm_flavor(project_root).await { Ok((flavor, _probe_warnings)) => flavor, Err((code, _detail)) => return vec![Err(code); packages.len()], }; @@ -517,7 +554,7 @@ pub async fn lock_text_refusals( project_root: &Path, packages: &[(&str, &PatchRecord)], ) -> Vec> { - let flavor = match detect_npm_lock_flavor(project_root).await { + let flavor = match detect_vendorable_npm_flavor(project_root).await { Ok((flavor, _)) => flavor, Err(_) => return vec![None; packages.len()], }; @@ -924,6 +961,7 @@ mod tests { } use super::*; + use crate::constants::npm_family::PNP_MARKERS; use crate::hash::git_sha256::compute_git_sha256_from_bytes; #[test] @@ -1006,6 +1044,56 @@ mod tests { } } + /// #975: a Yarn 2 loader left behind by a switch to the node-modules + /// or pnpm linker is ignored; the berry lock decides. + #[tokio::test] + async fn stale_pnp_loader_under_non_pnp_linker_is_not_refused() { + for linker in ["node-modules", "pnpm"] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), ".pnp.js", "/* stale */").await; + touch( + tmp.path(), + ".yarnrc.yml", + &format!("nodeLinker: {linker}\n"), + ) + .await; + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry, "{linker}"); + let (flavor, _) = detect_vendorable_npm_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry, "{linker}"); + } + } + + /// #539: forward vendoring refuses a berry project configured for PnP + /// (explicitly, or by berry's default) before its loader exists, while + /// the read-only probe still reports the berry flavor. + #[tokio::test] + async fn vendorable_probe_refuses_berry_configured_for_pnp() { + for (rc, why) in [ + (Some("nodeLinker: pnp\n"), "`nodeLinker: pnp`"), + (Some("enableGlobalCache: false\n"), "default `pnp` linker"), + (None, "default `pnp` linker"), + ] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + if let Some(rc) = rc { + touch(tmp.path(), ".yarnrc.yml", rc).await; + } + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + let (code, detail) = detect_vendorable_npm_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_yarn_berry_unsupported", "{rc:?}"); + assert!(detail.contains(why), "{rc:?}: {detail}"); + assert!(detail.contains("nodeLinker: node-modules"), "{detail}"); + } + // Classic locks have no linker setting and are never PnP. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_V1).await; + let (flavor, _) = detect_vendorable_npm_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + } + /// Stage the root markers a real `pnpm install` with /// `node-linker=pnp` emits (layout verified against pnpm 10.28.2): /// the `.pnp.cjs` loader, pnpm-lock.yaml, and an installed virtual diff --git a/crates/socket-patch-core/src/vendor/prestage.rs b/crates/socket-patch-core/src/vendor/prestage.rs index fcc149cc1..78f5d4c98 100644 --- a/crates/socket-patch-core/src/vendor/prestage.rs +++ b/crates/socket-patch-core/src/vendor/prestage.rs @@ -490,7 +490,10 @@ mod sweep_tests { for dir in &kept { assert!(v.join(dir).exists(), "{dir} kept"); } - assert!(!v.join("gem").exists(), "the levels only the tree kept alive are pruned"); + assert!( + !v.join("gem").exists(), + "the levels only the tree kept alive are pruned" + ); assert!(!v.join(format!("composer/{u}/psr/log@3.0.2")).exists()); assert!(v.join("state.json").exists()); assert_eq!(sweep_stale(root).await, 0, "idempotent"); diff --git a/crates/socket-patch-core/src/vendor/toml_surgery.rs b/crates/socket-patch-core/src/vendor/toml_surgery.rs index 0b1777008..4d151f613 100644 --- a/crates/socket-patch-core/src/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/vendor/toml_surgery.rs @@ -519,7 +519,8 @@ mod tests { // CRLF, and a hand edit can leave a mixed-ending file, so the // removal helpers must never normalize: every byte outside the // removed segment survives verbatim. - let wired = "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; + let wired = + "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; let after = remove_exact_line(wired, "foo = { path = \"w.whl\" }").unwrap(); assert_eq!(after, "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\n"); assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 86aab22de..4056ea30b 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -738,23 +738,22 @@ async fn vendored_from_patches( } let copy_tagged = matches!(tag, CopyTag::Tagged(_) | CopyTag::Unreadable); if let Lock::Parsed(lock) = lock { - let why = - match lock.vendored_in_use(name, version, &vref.uuid, copy_tagged) { - CopyClaim::Consumed => None, - CopyClaim::OtherTag(other) => Some(format!( - "{CARGO_LOCK} builds the copy tagged for patch {other} ({name} {})", - cargo_tag::tag_version(version, other) - )), - CopyClaim::UntaggedOverride => Some(format!( - "{CARGO_LOCK} builds an untagged {name} {version}, not the copy (which \ + let why = match lock.vendored_in_use(name, version, &vref.uuid, copy_tagged) { + CopyClaim::Consumed => None, + CopyClaim::OtherTag(other) => Some(format!( + "{CARGO_LOCK} builds the copy tagged for patch {other} ({name} {})", + cargo_tag::tag_version(version, other) + )), + CopyClaim::UntaggedOverride => Some(format!( + "{CARGO_LOCK} builds an untagged {name} {version}, not the copy (which \ cargo would lock as {}): another [patch] or path dependency overrides it", - cargo_tag::tag_version(version, &vref.uuid) - )), - CopyClaim::NotConsumed => Some(format!( - "{CARGO_LOCK} does not build {name}@{version} from it (an unused patch, \ + cargo_tag::tag_version(version, &vref.uuid) + )), + CopyClaim::NotConsumed => Some(format!( + "{CARGO_LOCK} does not build {name}@{version} from it (an unused patch, \ or the lock resolves it from a registry)" - )), - }; + )), + }; if let Some(why) = why { out.diag( DIAG_REF_INVALID, diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index c73e1f978..c880b5afa 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -88,15 +88,15 @@ use super::{ Discovery, PatchedRef, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::maven::{ + is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, + PomRepo, +}; use crate::patch::redirect::{ local_repo_artifact_path, MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON, }; use crate::utils::digest::sha256_hex; use crate::vendor::lock_inventory::LockIntegrity; -use crate::formats::maven::{ - is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, - PomRepo, -}; use crate::vendor::maven_repo::{sha1_sidecar_matches, VENDOR_REPO_URL_PREFIX}; use crate::vendor::path::{sweep_vendor_dirs, VENDOR_DIR}; diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index d7f3cd95d..3f608233f 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -73,8 +73,8 @@ use super::{ Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; -use crate::vendor::lock_inventory::LockIntegrity; use crate::formats::nuget::{parse_config, NugetConfig}; +use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::nuget_config::{same_file, CONFIG_NAMES}; use crate::vendor::nuget_feed::{is_plain_nuget_token, nuget_lock_entries, nupkg_leaf}; use crate::vendor::path::VENDOR_DIR; diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 041c323ad..ffe7b5943 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -569,5 +569,8 @@ async fn fetch_missing_blobs_mixed_outcomes_aggregate_and_format() { // End-to-end formatter exercise with a genuinely mixed result. let rendered = format_fetch_result(&result); assert!(rendered.contains("Downloaded 1 blob\n"), "{rendered}"); - assert!(rendered.contains("Failed to download 2 blobs"), "{rendered}"); + assert!( + rendered.contains("Failed to download 2 blobs"), + "{rendered}" + ); } diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 3c4c872f5..997175812 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -99,7 +99,11 @@ async fn get_vendor_paths_global_nonexistent_composer_home_falls_back() { fn write_composer_shim(dir: &Path, echo_path: &Path) { use std::os::unix::fs::PermissionsExt; let shim = dir.join("composer"); - std::fs::write(&shim, format!("#!/bin/sh\necho '{}'\n", echo_path.display())).unwrap(); + std::fs::write( + &shim, + format!("#!/bin/sh\necho '{}'\n", echo_path.display()), + ) + .unwrap(); std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); } diff --git a/crates/socket-patch-core/tests/hosted_inventory.rs b/crates/socket-patch-core/tests/hosted_inventory.rs index 1bb3772d2..db1ecd6ae 100644 --- a/crates/socket-patch-core/tests/hosted_inventory.rs +++ b/crates/socket-patch-core/tests/hosted_inventory.rs @@ -51,9 +51,15 @@ async fn contradicted_hosted_lock_is_contested_not_absent() { assert!(!inv.is_empty(), "contested wiring is hosted state: {inv:?}"); let refusal = inv.contested_refusal().expect("a refusal"); assert!(refusal.contains("npm-shrinkwrap.json"), "{refusal}"); - assert!(refusal.contains("git checkout -- npm-shrinkwrap.json"), "{refusal}"); + assert!( + refusal.contains("git checkout -- npm-shrinkwrap.json"), + "{refusal}" + ); assert!(refusal.contains("patched_ref_unattributable"), "{refusal}"); - assert!(!refusal.contains(GRANT), "the grant token is not a patch: {refusal}"); + assert!( + !refusal.contains(GRANT), + "the grant token is not a patch: {refusal}" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/tests/poetry_hosted.rs b/crates/socket-patch-core/tests/poetry_hosted.rs index bd3ca3c83..2d2e17d5d 100644 --- a/crates/socket-patch-core/tests/poetry_hosted.rs +++ b/crates/socket-patch-core/tests/poetry_hosted.rs @@ -1,6 +1,4 @@ -use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect, DepOverride, Integrity, -}; +use socket_patch_core::patch::redirect::{rewrite_registry_redirect, DepOverride, Integrity}; use socket_patch_core::utils::poetry_lock::rewrite_poetry_lock; use std::collections::BTreeMap; @@ -63,7 +61,11 @@ fn native_lock_generations_redirect_idempotently() { let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - if pre_1_4 { vec!["redirect_poetry_stale_install_risk"] } else { vec![] }, + if pre_1_4 { + vec!["redirect_poetry_stale_install_risk"] + } else { + vec![] + }, "{version}: {:?}", result.warnings ); @@ -92,12 +94,24 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert!(lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), "{lock10}"); + assert!( + lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), + "{lock10}" + ); assert!(lock10.contains("reference = \"\""), "{lock10}"); - assert!(lock10.contains(&format!("urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]")), "{lock10}"); + assert!( + lock10.contains(&format!( + "urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]" + )), + "{lock10}" + ); // Poetry >= 1.2 consuming this 1.0 lock verifies the package `files` // entry, so it is written too (1.0 ignores the extra key). - assert_eq!(lock10.matches(&format!("sha256:{sha}")).count(), 2, "{lock10}"); + assert_eq!( + lock10.matches(&format!("sha256:{sha}")).count(), + 2, + "{lock10}" + ); let doc: toml_edit::DocumentMut = lock10.parse().unwrap(); assert!(doc["package"][0]["files"].is_array(), "{lock10}"); @@ -124,7 +138,11 @@ fn hosted_shapes_match_each_lock_generations_installer() { &BTreeMap::from([("poetry.lock".to_string(), lock10_populated)]), &[patch()], ); - assert!(rerun.files.is_empty() && rerun.warnings.is_empty(), "{:?}", rerun.warnings); + assert!( + rerun.files.is_empty() && rerun.warnings.is_empty(), + "{:?}", + rerun.warnings + ); let lock11 = rewrite_registry_redirect( &BTreeMap::from([("poetry.lock".to_string(), original("1.2.2"))]), @@ -132,8 +150,15 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!(lock11.matches(&format!("sha256:{sha}")).count(), 2, "package files + metadata.files:\n{lock11}"); - assert!(lock11.contains(&format!("url = \"{URL}\"")), "no fragment on 1.1"); + assert_eq!( + lock11.matches(&format!("sha256:{sha}")).count(), + 2, + "package files + metadata.files:\n{lock11}" + ); + assert!( + lock11.contains(&format!("url = \"{URL}\"")), + "no fragment on 1.1" + ); assert!(!lock11.contains("reference"), "{lock11}"); let doc: toml_edit::DocumentMut = lock11.parse().unwrap(); assert!(doc["package"][0]["files"].is_array()); @@ -145,11 +170,19 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!(lock21.matches(&format!("sha256:{sha}")).count(), 1, "{lock21}"); + assert_eq!( + lock21.matches(&format!("sha256:{sha}")).count(), + 1, + "{lock21}" + ); assert!(!lock21.contains("reference")); let pristine: toml_edit::DocumentMut = original("2.4.3").parse().unwrap(); let doc: toml_edit::DocumentMut = lock21.parse().unwrap(); - assert_eq!(doc["metadata"].to_string(), pristine["metadata"].to_string(), "[metadata] untouched on 2.x"); + assert_eq!( + doc["metadata"].to_string(), + pristine["metadata"].to_string(), + "[metadata] untouched on 2.x" + ); } #[test] @@ -248,14 +281,21 @@ fn absent_entries_warn_once_and_missing_sha256_is_gated_once_per_dep() { let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - vec!["redirect_poetry_entry_not_found", "redirect_poetry_entry_not_found"] + vec![ + "redirect_poetry_entry_not_found", + "redirect_poetry_entry_not_found" + ] ); let mut missing_hash = patch(); missing_hash.integrity.sha256 = None; let result = rewrite_registry_redirect(&files, &[missing_hash]); assert!(result.files.is_empty()); let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); - assert_eq!(codes, vec!["redirect_poetry_missing_sha256"], "gated once, not once per lock"); + assert_eq!( + codes, + vec!["redirect_poetry_missing_sha256"], + "gated once, not once per lock" + ); } /// A future Poetry that bumps the lock minor (2.2) is rewritten like 2.1 in @@ -278,11 +318,20 @@ fn rotated_grant_token_supersedes_the_prior_hosted_url() { let first = rewrite_registry_redirect(&files, &[patch()]); let mut rotated = patch(); rotated.token = "00000000-0000-4000-8000-000000000000".into(); - rotated.artifact_url = URL.replace("7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", "00000000-0000-4000-8000-000000000000"); + rotated.artifact_url = URL.replace( + "7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", + "00000000-0000-4000-8000-000000000000", + ); let second = rewrite_registry_redirect(&first.files, &[rotated.clone()]); assert!(second.warnings.is_empty(), "{:?}", second.warnings); let lock = &second.files["poetry.lock"]; assert!(lock.contains(&rotated.artifact_url) && !lock.contains(URL)); assert_eq!(second.edits.len(), 1); - assert!(second.edits[0].original.as_ref().unwrap().as_str().unwrap().contains(URL)); + assert!(second.edits[0] + .original + .as_ref() + .unwrap() + .as_str() + .unwrap() + .contains(URL)); } diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index 33c34297c..abde352bb 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -18,11 +18,7 @@ use socket_patch_core::telemetry::{is_telemetry_disabled, sanitize_error_message /// Every environment variable that can independently disable telemetry. /// Scrubbing the full set is what makes the per-var causation asserts honest. -const DISABLE_VARS: &[&str] = &[ - "SOCKET_TELEMETRY_DISABLED", - "VITEST", - "SOCKET_OFFLINE", -]; +const DISABLE_VARS: &[&str] = &["SOCKET_TELEMETRY_DISABLED", "VITEST", "SOCKET_OFFLINE"]; /// Run `f` with all telemetry-disabling vars removed, restoring the prior /// values afterward even if `f` panics (so one failing assert can't poison diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 863f8dc99..33ec523f2 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -13,10 +13,12 @@ use std::fs; use std::path::{Path, PathBuf}; use serial_test::serial; -use socket_patch_core::patch::redirect::{rewrite_registry_redirect_with_pipenv_version, DepOverride}; use socket_patch_core::patch::redirect::upstream::{ restore_upstream, HostedPin, PinStatus, RestoreOptions, }; +use socket_patch_core::patch::redirect::{ + rewrite_registry_redirect_with_pipenv_version, DepOverride, +}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -29,7 +31,10 @@ fn walk(dir: &Path) -> BTreeMap { if !dir.is_dir() { return out; } - for entry in walkdir::WalkDir::new(dir).into_iter().filter_map(Result::ok) { + for entry in walkdir::WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + { if entry.file_type().is_file() { let rel = entry .path() @@ -45,16 +50,27 @@ fn walk(dir: &Path) -> BTreeMap { /// Tokens of `pattern` that `input` holds and `expected` does not: the /// upstream values the hosted rewrite replaced. -fn vanished(input: &BTreeMap, expected: &BTreeMap, re: &str) -> Vec { +fn vanished( + input: &BTreeMap, + expected: &BTreeMap, + re: &str, +) -> Vec { let re = regex::Regex::new(re).unwrap(); let all = |files: &BTreeMap| -> BTreeSet { files .values() - .flat_map(|t| re.captures_iter(t).map(|c| c[1].to_string()).collect::>()) + .flat_map(|t| { + re.captures_iter(t) + .map(|c| c[1].to_string()) + .collect::>() + }) .collect() }; let after = all(expected); - let mut out: Vec = all(input).into_iter().filter(|t| !after.contains(t)).collect(); + let mut out: Vec = all(input) + .into_iter() + .filter(|t| !after.contains(t)) + .collect(); out.sort(); out } @@ -80,10 +96,9 @@ fn load(flavor: &str) -> Vec { // `expected/` holds only the files the rewrite changed. let mut expected = input.clone(); expected.extend(walk(&dir.join("expected"))); - let overrides = serde_json::from_str( - &fs::read_to_string(dir.join("overrides.json")).unwrap(), - ) - .unwrap(); + let overrides = + serde_json::from_str(&fs::read_to_string(dir.join("overrides.json")).unwrap()) + .unwrap(); Case { dir, input, @@ -132,10 +147,23 @@ async fn run_case_with( (walk(tmp.path()), statuses) } -fn assert_round_trip(case: &Case, after: &BTreeMap, statuses: &[(String, PinStatus)]) { - assert!(!statuses.is_empty(), "{}: discovery found no hosted pin", case.dir.display()); +fn assert_round_trip( + case: &Case, + after: &BTreeMap, + statuses: &[(String, PinStatus)], +) { + assert!( + !statuses.is_empty(), + "{}: discovery found no hosted pin", + case.dir.display() + ); for (purl, status) in statuses { - assert_eq!(*status, PinStatus::Restored, "{}: {purl}", case.dir.display()); + assert_eq!( + *status, + PinStatus::Restored, + "{}: {purl}", + case.dir.display() + ); } for (rel, want) in &case.input { assert_eq!( @@ -145,8 +173,15 @@ fn assert_round_trip(case: &Case, after: &BTreeMap, statuses: &[ case.dir.display() ); } - let extra: Vec<&String> = after.keys().filter(|k| !case.input.contains_key(*k)).collect(); - assert!(extra.is_empty(), "{}: left behind {extra:?}", case.dir.display()); + let extra: Vec<&String> = after + .keys() + .filter(|k| !case.input.contains_key(*k)) + .collect(); + assert!( + extra.is_empty(), + "{}: left behind {extra:?}", + case.dir.display() + ); } /// Sets env vars for the guard's lifetime (tests using it are `#[serial]`). @@ -207,10 +242,9 @@ async fn npm_mock(case: &Case) -> MockServer { } Mock::given(method("GET")) .and(path(format!("/{}/{version}", name.replace('/', "%2f")))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({ "name": name, "version": version, "dist": dist })), - ) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "name": name, "version": version, "dist": dist }), + )) .mount(&server) .await; } @@ -449,7 +483,12 @@ fn assert_refused( } other => panic!("{}: expected a refusal, got {other:?}", case.dir.display()), } - assert_eq!(after, &case.expected, "{}: a refused pin must change nothing", case.dir.display()); + assert_eq!( + after, + &case.expected, + "{}: a refused pin must change nothing", + case.dir.display() + ); } fn offline() -> RestoreOptions { @@ -541,7 +580,8 @@ fn transitive_lock() -> String { #[serial] async fn gem_edge_shapes_round_trip() { let gemfile = "source \"https://rubygems.org\"\n\ngem \"puma\"\n\ngroup :test do\n gem \"rails\", \"7.0.0\", require: false\nend\n"; - let crlf_gemfile = "source \"https://rubygems.org\"\r\n\r\ngem \"rails\", \"7.0.0\"\r\ngem \"puma\"\r\n"; + let crlf_gemfile = + "source \"https://rubygems.org\"\r\n\r\ngem \"rails\", \"7.0.0\"\r\ngem \"puma\"\r\n"; let two_sources_gemfile = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\nsource \"https://gems.example.com\" do\n gem \"private-gem\"\nend\n"; let two_sources_lock = "GEM\n remote: https://gems.example.com/\n specs:\n private-gem (1.0.0)\n\nGEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n private-gem!\n rails (= 7.0.0)\n\nCHECKSUMS\n private-gem (1.0.0) sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n rails (7.0.0) sha256=2222222222222222222222222222222222222222222222222222222222222222\n\nBUNDLED WITH\n 2.6.2\n"; // Provably transitive: the rewriter appended after a trailing blank @@ -569,12 +609,18 @@ async fn gem_edge_shapes_round_trip() { ), synthetic( "multiple-gem-sections", - &[("Gemfile", two_sources_gemfile), ("Gemfile.lock", two_sources_lock)], + &[ + ("Gemfile", two_sources_gemfile), + ("Gemfile.lock", two_sources_lock), + ], gem_override("rails", "7.0.0"), ), synthetic( "transitive-appended", - &[("Gemfile", transitive_gemfile), ("Gemfile.lock", &transitive)], + &[ + ("Gemfile", transitive_gemfile), + ("Gemfile.lock", &transitive), + ], gem_override("zeitwerk", "2.6.0"), ), ]; @@ -633,7 +679,10 @@ async fn gem_pre_checksums_states() { .replace(" rails (~> 7.0)\n", " rails (= 7.0.0)!\n"); mixed.expected.insert("Gemfile.lock".into(), converged); let (after, statuses) = run_case_with(&mixed, None, &offline()).await; - assert_eq!(statuses, vec![("pkg:gem/rails@7.0.0".to_string(), PinStatus::Restored)]); + assert_eq!( + statuses, + vec![("pkg:gem/rails@7.0.0".to_string(), PinStatus::Restored)] + ); assert_eq!(after["Gemfile.lock"], restored_lock); assert_eq!(after["Gemfile"], restored_gemfile); @@ -646,7 +695,10 @@ async fn gem_pre_checksums_states() { .replace(" rails (~> 7.0)\n", " rails (= 7.0.0)!\n"); mixed.expected.insert("Gemfile.lock".into(), merged); let (after, statuses) = run_case_with(&mixed, None, &offline()).await; - assert_eq!(statuses, vec![("pkg:gem/rails@7.0.0".to_string(), PinStatus::Restored)]); + assert_eq!( + statuses, + vec![("pkg:gem/rails@7.0.0".to_string(), PinStatus::Restored)] + ); assert_eq!(after["Gemfile.lock"], restored_lock); assert_eq!(after["Gemfile"], restored_gemfile); } @@ -676,7 +728,10 @@ async fn gem_transitive_append_round_trips_unless_unprovable() { case.expected.insert("Gemfile".into(), legacy); let (after, statuses) = gem_run(&case).await; assert_eq!(statuses[0].1, PinStatus::Restored); - assert_eq!(after["Gemfile"], format!("{gemfile}gem \"zeitwerk\", \"2.6.0\"\n")); + assert_eq!( + after["Gemfile"], + format!("{gemfile}gem \"zeitwerk\", \"2.6.0\"\n") + ); assert_eq!( after["Gemfile.lock"], lock.replace(" puma\n", " puma\n zeitwerk (= 2.6.0)\n") @@ -705,10 +760,19 @@ async fn gem_refusals_leave_everything_hosted() { // The upstream section is another registry's. let mut foreign = case.clone_with("foreign-upstream"); foreign.edit_both("Gemfile.lock", |t| { - t.replace("remote: https://rubygems.org/", "remote: https://gems.example.com/") + t.replace( + "remote: https://rubygems.org/", + "remote: https://gems.example.com/", + ) }); let (after, statuses) = gem_run(&foreign).await; - assert_refused(&foreign, &after, &statuses, "Gemfile.lock", "not rubygems.org"); + assert_refused( + &foreign, + &after, + &statuses, + "Gemfile.lock", + "not rubygems.org", + ); // Two upstream sections, neither singled out. let mut ambiguous = case.clone_with("ambiguous-upstream"); ambiguous.edit_both("Gemfile.lock", |t| { @@ -717,18 +781,38 @@ async fn gem_refusals_leave_everything_hosted() { "GEM\n remote: https://gems.example.com/\n specs:\n other (1.0.0)\n\nPLATFORMS", ) }); - ambiguous.edit_both("Gemfile", |t| t.replace("source \"https://rubygems.org\"\n", "")); - ambiguous.edit_both("Gemfile.lock", |t| t.replace("remote: https://rubygems.org/", "remote: https://mirror.example.com/")); + ambiguous.edit_both("Gemfile", |t| { + t.replace("source \"https://rubygems.org\"\n", "") + }); + ambiguous.edit_both("Gemfile.lock", |t| { + t.replace( + "remote: https://rubygems.org/", + "remote: https://mirror.example.com/", + ) + }); let (after, statuses) = gem_run(&ambiguous).await; - assert_refused(&ambiguous, &after, &statuses, "Gemfile.lock", "upstream GEM sections"); + assert_refused( + &ambiguous, + &after, + &statuses, + "Gemfile.lock", + "upstream GEM sections", + ); // The Gemfile block was hand-edited. let mut edited = case.clone_with("edited-block"); edited.expected.insert( "Gemfile".into(), - edited.expected["Gemfile"].replace(" gem \"rails\", \"7.0.0\"", " gem \"rails\", \"~> 7.0\""), + edited.expected["Gemfile"] + .replace(" gem \"rails\", \"7.0.0\"", " gem \"rails\", \"~> 7.0\""), ); let (after, statuses) = gem_run(&edited).await; - assert_refused(&edited, &after, &statuses, "Gemfile.lock", "shape other than the source block"); + assert_refused( + &edited, + &after, + &statuses, + "Gemfile.lock", + "shape other than the source block", + ); } // ── composer ──────────────────────────────────────────────────────────────── @@ -897,7 +981,11 @@ async fn composer_edge_shapes_round_trip() { &[("composer.lock", &escaped)], composer_override("acme/tool", "dev-main"), ), - synthetic("crlf", &[("composer.lock", &crlf)], composer_override("psr/log", "1.1.4")), + synthetic( + "crlf", + &[("composer.lock", &crlf)], + composer_override("psr/log", "1.1.4"), + ), ]; for case in &cases { let (after, statuses) = composer_run(case, |_| {}).await; @@ -916,20 +1004,42 @@ async fn composer_refusals_leave_everything_hosted() { // Packagist now serves another commit for the version. let (after, statuses) = composer_run(&case, |d| d["dist"]["reference"] = "feedface".into()).await; - assert_refused(&case, &after, &statuses, "composer.lock", "packagist now serves"); + assert_refused( + &case, + &after, + &statuses, + "composer.lock", + "packagist now serves", + ); // Packagist does not list the version. let (after, statuses) = composer_run(&case, |d| d["version"] = "0.0.1".into()).await; - assert_refused(&case, &after, &statuses, "composer.lock", "does not list version 1.1.4"); + assert_refused( + &case, + &after, + &statuses, + "composer.lock", + "does not list version 1.1.4", + ); // Offline. let (after, statuses) = run_case_with(&case, None, &offline()).await; assert_refused(&case, &after, &statuses, "composer.lock", "offline"); // Locked from another repository. let mut foreign = case.clone_with("foreign"); foreign.edit_both("composer.lock", |t| { - t.replacen("https://packagist.org/downloads/", "https://repo.example.com/downloads/", 1) + t.replacen( + "https://packagist.org/downloads/", + "https://repo.example.com/downloads/", + 1, + ) }); let (after, statuses) = composer_run(&foreign, |_| {}).await; - assert_refused(&foreign, &after, &statuses, "composer.lock", "not packagist"); + assert_refused( + &foreign, + &after, + &statuses, + "composer.lock", + "not packagist", + ); // No notification-url, and composer.json names custom repositories. let mut custom = case.clone_with("custom-repos"); custom.edit_both("composer.lock", |t| { @@ -946,7 +1056,13 @@ async fn composer_refusals_leave_everything_hosted() { ); } let (after, statuses) = composer_run(&custom, |_| {}).await; - assert_refused(&custom, &after, &statuses, "composer.lock", "custom repositories"); + assert_refused( + &custom, + &after, + &statuses, + "composer.lock", + "custom repositories", + ); } // ── PyPI ───────────────────────────────────────────────────────────────────── @@ -984,7 +1100,11 @@ fn urllib3_dep() -> DepOverride { /// PyPI's blake2b-bucketed file URLs, with real urllib3 1.26.18's buckets: the /// sdist sorts before the wheel by URL, the reverse of filename order. fn pypi_file_url(filename: &str) -> String { - let bucket = if filename.ends_with(".tar.gz") { "0c/39" } else { "b0/53" }; + let bucket = if filename.ends_with(".tar.gz") { + "0c/39" + } else { + "b0/53" + }; format!("https://files.pythonhosted.org/packages/{bucket}/{filename}") } @@ -997,8 +1117,18 @@ fn urllib3_release() -> Release<'static> { "urllib3", "1.26.18", vec![ - (URLLIB3_WHEEL, URLLIB3_WHEEL_SHA, 143835, "2023-10-17T17:46:21.184066Z"), - (URLLIB3_SDIST, URLLIB3_SDIST_SHA, 305687, "2023-10-17T17:46:24.000000Z"), + ( + URLLIB3_WHEEL, + URLLIB3_WHEEL_SHA, + 143835, + "2023-10-17T17:46:21.184066Z", + ), + ( + URLLIB3_SDIST, + URLLIB3_SDIST_SHA, + 305687, + "2023-10-17T17:46:24.000000Z", + ), ], ) } @@ -1033,7 +1163,10 @@ async fn pypi_mock(releases: &[Release<'_>]) -> (MockServer, EnvGuard) { } fn tree(files: &[(&str, String)]) -> BTreeMap { - files.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + files + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() } /// `input` as the real hosted rewriter leaves it. @@ -1080,14 +1213,24 @@ async fn assert_pypi_round_trip( pipenv: Option, ) { let rewritten = hosted(input, deps, pipenv); - assert_ne!(&rewritten, input, "{label}: the hosted rewrite changed nothing"); + assert_ne!( + &rewritten, input, + "{label}: the hosted rewrite changed nothing" + ); let (after, statuses) = restore_tree(&rewritten, &RestoreOptions::default()).await; - assert!(!statuses.is_empty(), "{label}: discovery found no hosted pin"); + assert!( + !statuses.is_empty(), + "{label}: discovery found no hosted pin" + ); for (purl, status) in &statuses { assert_eq!(*status, PinStatus::Restored, "{label}: {purl}"); } for (rel, want) in input { - assert_eq!(after.get(rel), Some(want), "{label}: {rel} did not round-trip"); + assert_eq!( + after.get(rel), + Some(want), + "{label}: {rel} did not round-trip" + ); } let extra: Vec<&String> = after.keys().filter(|k| !input.contains_key(*k)).collect(); assert!(extra.is_empty(), "{label}: left behind {extra:?}"); @@ -1110,13 +1253,20 @@ async fn pypi_refusal( PinStatus::Restored => None, }) .collect(); - assert!(!refusals.is_empty() && refusals.len() == statuses.len(), "{statuses:?}"); + assert!( + !refusals.is_empty() && refusals.len() == statuses.len(), + "{statuses:?}" + ); (refusals.join("\n"), rewritten, after) } fn fixture(rel: &str) -> String { - fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(rel)) - .unwrap() + fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(rel), + ) + .unwrap() } #[tokio::test] @@ -1129,7 +1279,12 @@ async fn requirements_golden_restores_modulo_name_casing() { let (after, statuses) = run_case(&case).await; assert!(!statuses.is_empty()); for (purl, status) in &statuses { - assert_eq!(*status, PinStatus::Restored, "{}: {purl}", case.dir.display()); + assert_eq!( + *status, + PinStatus::Restored, + "{}: {purl}", + case.dir.display() + ); } assert_eq!( after["requirements.txt"].to_ascii_lowercase(), @@ -1149,7 +1304,12 @@ async fn uv_golden_without_a_registry_sibling_is_refused() { let (_server, _env) = pypi_mock(&[( "click", "8.1.7", - vec![("click-8.1.7-py3-none-any.whl", URLLIB3_WHEEL_SHA, 1, "2023-08-17T17:29:10Z")], + vec![( + "click-8.1.7-py3-none-any.whl", + URLLIB3_WHEEL_SHA, + 1, + "2023-08-17T17:29:10Z", + )], )]) .await; let mut ran = 0; @@ -1164,7 +1324,10 @@ async fn uv_golden_without_a_registry_sibling_is_refused() { case.dir.display() ); } - assert_eq!(after, case.expected, "a refused pin must leave the files untouched"); + assert_eq!( + after, case.expected, + "a refused pin must leave the files untouched" + ); ran += 1; } assert!(ran > 0); @@ -1271,9 +1434,14 @@ async fn pdm_static_urls_round_trip() { &format!("{{url = \"{}\"", pypi_file_url(URLLIB3_SDIST)), ); // PDM writes a static_urls entry's files in URL order (sdist first here). - let wheel_line = format!(" {{url = \"{}\", hash = \"sha256:{URLLIB3_WHEEL_SHA}\"}},\n", pypi_file_url(URLLIB3_WHEEL)); + let wheel_line = format!( + " {{url = \"{}\", hash = \"sha256:{URLLIB3_WHEEL_SHA}\"}},\n", + pypi_file_url(URLLIB3_WHEEL) + ); assert!(lock.contains(&wheel_line), "{lock}"); - let lock = lock.replacen(&wheel_line, "", 1).replacen("\n]\n", &format!("\n{wheel_line}]\n"), 1); + let lock = + lock.replacen(&wheel_line, "", 1) + .replacen("\n]\n", &format!("\n{wheel_line}]\n"), 1); assert!( lock.find(URLLIB3_SDIST).unwrap() < lock.find(URLLIB3_WHEEL).unwrap(), "{lock}" @@ -1287,12 +1455,17 @@ async fn pdm_static_urls_round_trip() { async fn pdm_narrowed_lock_with_platform_wheels_is_refused() { let wheel = "urllib3-1.26.18-cp311-cp311-manylinux_2_17_x86_64.whl"; let mut release = urllib3_release(); - release.2.push((wheel, URLLIB3_WHEEL_SHA, 1, "2023-10-17T17:46:21Z")); + release + .2 + .push((wheel, URLLIB3_WHEEL_SHA, 1, "2023-10-17T17:46:21Z")); let (_server, _env) = pypi_mock(&[release]).await; let input = tree(&[("pdm.lock", fixture("pdm-native/2.29.2.lock"))]); let (why, rewritten, after) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; - assert!(why.contains("not derivable") && why.contains("cross_platform"), "{why}"); + assert!( + why.contains("not derivable") && why.contains("cross_platform"), + "{why}" + ); assert!(why.contains("git checkout -- pdm.lock"), "{why}"); assert_eq!(after, rewritten); // A cross-platform lock records every file, whatever its tags. @@ -1352,7 +1525,9 @@ async fn pipfile_lock_fixture_and_every_category_round_trip() { assert_pypi_round_trip(&label, &input, &[urllib3_dep()], None).await; } // Pipenv 7.x–2017 writes `path` (and, before 2018, no `index`). - let old = text.replace(",\n \"index\": \"pypi\"", "").replace("\"index\": \"pypi\",\n ", ""); + let old = text + .replace(",\n \"index\": \"pypi\"", "") + .replace("\"index\": \"pypi\",\n ", ""); assert!(!old.contains("\"index\""), "{old}"); let input = tree(&[("Pipfile.lock", old), ("Pipfile", "[packages]\n".into())]); assert_pypi_round_trip("pipenv 2017", &input, &[urllib3_dep()], Some(11)).await; @@ -1386,7 +1561,9 @@ async fn pipfile_lock_real_pipenv_shapes_round_trip_their_index() { let pipfile = fixture(&format!("{dir}/Pipfile")); let pristine: serde_json::Value = serde_json::from_str(&lock).unwrap(); assert_eq!( - pristine["default"]["urllib3"].get("index").and_then(|v| v.as_str()), + pristine["default"]["urllib3"] + .get("index") + .and_then(|v| v.as_str()), index, "{dir}: fixture drifted from what Pipenv writes" ); @@ -1401,9 +1578,16 @@ async fn pipfile_lock_real_pipenv_shapes_round_trip_their_index() { let hosted_lock = hosted(&input, &[urllib3_dep()], major)["Pipfile.lock"].clone(); let entry: serde_json::Value = serde_json::from_str(&hosted_lock).unwrap(); let entry = &entry["default"]["urllib3"]; - assert!(entry.get("file").is_some() && entry.get("version").is_none(), "{label}: {entry}"); + assert!( + entry.get("file").is_some() && entry.get("version").is_none(), + "{label}: {entry}" + ); for key in ["index", "markers", "extras"] { - assert_eq!(entry.get(key), pristine["default"]["urllib3"].get(key), "{label}: {key}"); + assert_eq!( + entry.get(key), + pristine["default"]["urllib3"].get(key), + "{label}: {key}" + ); } assert_pypi_round_trip(&label, &input, &[urllib3_dep()], major).await; } @@ -1427,12 +1611,17 @@ async fn pipfile_lock_marker_excluded_relock_hybrid_restores_the_original() { ("Pipfile", fixture(&format!("{dir}/Pipfile"))), ]); let rewritten = hosted(&input, &[urllib3_dep()], Some(2026)); - let mut relocked: serde_json::Value = - serde_json::from_str(&rewritten["Pipfile.lock"]).unwrap(); + let mut relocked: serde_json::Value = serde_json::from_str(&rewritten["Pipfile.lock"]).unwrap(); let pristine: serde_json::Value = serde_json::from_str(&lock).unwrap(); let entry = relocked["default"]["urllib3"].as_object_mut().unwrap(); - assert!(entry.contains_key("file") && !entry.contains_key("index"), "{entry:?}"); - entry.insert("hashes".into(), pristine["default"]["urllib3"]["hashes"].clone()); + assert!( + entry.contains_key("file") && !entry.contains_key("index"), + "{entry:?}" + ); + entry.insert( + "hashes".into(), + pristine["default"]["urllib3"]["hashes"].clone(), + ); entry.insert("version".into(), serde_json::json!("==1.26.18")); relocked.sort_all_objects(); let hybrid = reindent4(&serde_json::to_string_pretty(&relocked).unwrap()) + "\n"; @@ -1443,7 +1632,10 @@ async fn pipfile_lock_marker_excluded_relock_hybrid_restores_the_original() { for (purl, status) in &statuses { assert_eq!(*status, PinStatus::Restored, "{purl}"); } - assert_eq!(after["Pipfile.lock"], lock, "the hybrid restores the pristine bytes"); + assert_eq!( + after["Pipfile.lock"], lock, + "the hybrid restores the pristine bytes" + ); } #[tokio::test] @@ -1461,7 +1653,10 @@ async fn pipfile_lock_refusals() { ..Default::default() }; let (why, rewritten, after) = pypi_refusal(&input, &[urllib3_dep()], &offline).await; - assert!(why.contains("offline") && why.contains("git checkout -- Pipfile.lock"), "{why}"); + assert!( + why.contains("offline") && why.contains("git checkout -- Pipfile.lock"), + "{why}" + ); assert_eq!(after, rewritten); // A mirror as the only source. let mirror = lock.replace("https://pypi.org/simple", "https://mirror.example/simple"); @@ -1514,7 +1709,10 @@ async fn requirements_hash_mode_ambiguity_is_refused() { let (_server, _env) = pypi_mock(&[urllib3_release()]).await; let input = tree(&[("requirements.txt", "urllib3==1.26.18\n".into())]); let (why, _, _) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; - assert!(why.contains("hash-checking mode") && why.contains("not derivable"), "{why}"); + assert!( + why.contains("hash-checking mode") && why.contains("not derivable"), + "{why}" + ); let input = tree(&[( "requirements.txt", "idna==3.4 --hash=sha256:aaaa\nsix==1.16.0\nurllib3==1.26.18\n".into(), @@ -1532,7 +1730,10 @@ async fn requirements_hash_mode_ambiguity_is_refused() { offline: true, ..Default::default() }; - let input = tree(&[("requirements.txt", "flask==2.0.1\nurllib3==1.26.18\n".into())]); + let input = tree(&[( + "requirements.txt", + "flask==2.0.1\nurllib3==1.26.18\n".into(), + )]); let rewritten = hosted(&input, &[urllib3_dep()], None); let (after, statuses) = restore_tree(&rewritten, &offline).await; assert_eq!(statuses[0].1, PinStatus::Restored); @@ -1540,7 +1741,9 @@ async fn requirements_hash_mode_ambiguity_is_refused() { // …and is refused in hash mode. let input = tree(&[( "requirements.txt", - format!("idna==3.4 --hash=sha256:aaaa\nurllib3==1.26.18 --hash=sha256:{URLLIB3_WHEEL_SHA}\n"), + format!( + "idna==3.4 --hash=sha256:aaaa\nurllib3==1.26.18 --hash=sha256:{URLLIB3_WHEEL_SHA}\n" + ), )]); let (why, _, _) = pypi_refusal(&input, &[urllib3_dep()], &offline).await; assert!(why.contains("offline"), "{why}"); @@ -1551,7 +1754,12 @@ async fn requirements_hash_mode_ambiguity_is_refused() { async fn a_refused_pin_leaves_the_other_pins_restored() { // PyPI knows urllib3 only: idna's hashes cannot be re-derived. let (_server, _env) = pypi_mock(&[urllib3_release()]).await; - let idna = pypi_dep("idna", "3.4", "idna-3.4-py3-none-any.whl", "44444444-4444-4444-4444-444444444444"); + let idna = pypi_dep( + "idna", + "3.4", + "idna-3.4-py3-none-any.whl", + "44444444-4444-4444-4444-444444444444", + ); let input = tree(&[( "requirements.txt", format!( @@ -1565,7 +1773,10 @@ async fn a_refused_pin_leaves_the_other_pins_restored() { assert!(matches!(status("pkg:pypi/idna@3.4"), PinStatus::Refused(why) if why.contains("404"))); let lines: Vec<&str> = after["requirements.txt"].lines().collect(); assert_eq!(lines[0], "six==1.16.0 --hash=sha256:aaaa"); - assert!(lines[1].starts_with("idna @ https://patch.socket.dev/"), "{lines:?}"); + assert!( + lines[1].starts_with("idna @ https://patch.socket.dev/"), + "{lines:?}" + ); assert_eq!(lines[2], input["requirements.txt"].lines().nth(2).unwrap()); } @@ -1644,7 +1855,13 @@ async fn uv_project_locks_round_trip() { ("uv.lock", lock.replace('\n', eol)), ("pyproject.toml", pyproject.replace('\n', eol)), ]); - assert_pypi_round_trip(&format!("uv direct {eol:?}"), &input, &[urllib3_dep()], None).await; + assert_pypi_round_trip( + &format!("uv direct {eol:?}"), + &input, + &[urllib3_dep()], + None, + ) + .await; } // A transitive dependency: the override the rewrite pins in the // pyproject and the lock's `[manifest]` both go again. @@ -1752,7 +1969,11 @@ wheels = [{{ url = \"{wheel_url}\", upload-time = 2023-10-17T17:46:21.184Z, size /// microseconds), with (`uv export`) or without (`uv pip compile`) an /// `index` on each registry package. fn uv_pylock(index: bool) -> String { - let index = if index { "index = \"https://pypi.org/simple\"\n" } else { "" }; + let index = if index { + "index = \"https://pypi.org/simple\"\n" + } else { + "" + }; format!( "# This file was autogenerated by uv via the following command:\n\ # uv pip compile --format pylock.toml req.in -o pylock.toml\n\ @@ -1780,8 +2001,13 @@ async fn pylock_without_index_round_trips() { assert!(!lock.contains("index")); for eol in ["\n", "\r\n"] { let input = tree(&[("pylock.toml", lock.replace('\n', eol))]); - assert_pypi_round_trip(&format!("pip compile {eol:?}"), &input, &[urllib3_dep()], None) - .await; + assert_pypi_round_trip( + &format!("pip compile {eol:?}"), + &input, + &[urllib3_dep()], + None, + ) + .await; } // A sibling whose files come from another host is not PyPI. let mirror = lock.replace( @@ -1789,9 +2015,11 @@ async fn pylock_without_index_round_trips() { "https://mirror.example.com/packages/21/ed/", ); let input = tree(&[("pylock.toml", mirror)]); - let (why, _, after) = - pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; - assert!(after["pylock.toml"].contains("patch.socket.dev"), "the pin stays wired"); + let (why, _, after) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; + assert!( + after["pylock.toml"].contains("patch.socket.dev"), + "the pin stays wired" + ); assert!(why.contains("not PyPI"), "{why}"); } @@ -1958,7 +2186,10 @@ async fn uv_refusals() { { let (_server, _env) = pypi_mock(&[urllib3_release()]).await; // No other entry shows how this uv joins specifier clauses. - let input = tree(&[("uv.lock", direct.clone()), ("pyproject.toml", pyproject.into())]); + let input = tree(&[ + ("uv.lock", direct.clone()), + ("pyproject.toml", pyproject.into()), + ]); let (why, rewritten, after) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; assert!(why.contains("multi-clause"), "{why}"); @@ -1996,7 +2227,12 @@ async fn uv_refusals() { } // A release with interpreter-specific wheels. let mut release = urllib3_release(); - release.2.push(("urllib3-1.26.18-cp311-cp311-win_amd64.whl", URLLIB3_WHEEL_SHA, 1, "2023-10-17T17:46:21Z")); + release.2.push(( + "urllib3-1.26.18-cp311-cp311-win_amd64.whl", + URLLIB3_WHEEL_SHA, + 1, + "2023-10-17T17:46:21Z", + )); let (_server, _env) = pypi_mock(&[release]).await; let input = tree(&[("uv.lock", direct)]); let (why, _, _) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; @@ -2050,7 +2286,10 @@ async fn vlt_goldens_round_trip() { // refused a package whose package-lock.json entry the rewrite still // pinned; with vlt-lock.json upstream that pin is not live wiring, so // discovery (rightly) reports no pin to restore there. - let not_invertible = ["sibling-package-lock-vlt-installed", "sibling-refused-in-vlt"]; + let not_invertible = [ + "sibling-package-lock-vlt-installed", + "sibling-refused-in-vlt", + ]; let mut ran = 0; for case in load("npm/vlt") { let name = case.dir.file_name().unwrap().to_string_lossy().into_owned(); @@ -2095,7 +2334,10 @@ async fn maven_config_merge_keeps_the_resolver_lines() { .find(|c| c.dir.ends_with("mvn-config-merge")) .unwrap(); let (after, statuses) = run_case(&case).await; - assert!(matches!(statuses[..], [(_, PinStatus::Restored)]), "{statuses:?}"); + assert!( + matches!(statuses[..], [(_, PinStatus::Restored)]), + "{statuses:?}" + ); for rel in ["pom.xml", ".mvn/checksums/checksums.sha256"] { assert_eq!(after.get(rel), case.input.get(rel), "{rel}"); } @@ -2116,7 +2358,9 @@ async fn nuget_mock(case: &Case) -> MockServer { let (id, version) = (id.to_lowercase(), entry["resolved"].as_str().unwrap()); let catalog = format!("{}/catalog0/data/{id}.{version}.json", server.uri()); Mock::given(method("GET")) - .and(path(format!("/v3/registration5-gz-semver2/{id}/{version}.json"))) + .and(path(format!( + "/v3/registration5-gz-semver2/{id}/{version}.json" + ))) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({ "catalogEntry": catalog })), @@ -2186,11 +2430,17 @@ async fn nuget_non_invertible_goldens_restore_or_refuse_as_documented() { let PinStatus::Refused(why) = status else { panic!("{name}: {status:?}"); }; - assert!(why.contains("corp-feed") && why.contains("git checkout"), "{why}"); + assert!( + why.contains("corp-feed") && why.contains("git checkout"), + "{why}" + ); assert_eq!(after, case.expected, "{name}: a refusal changes nothing"); } else { assert_eq!(*status, PinStatus::Restored, "{name}"); - assert_eq!(after.get("packages.lock.json"), case.input.get("packages.lock.json")); + assert_eq!( + after.get("packages.lock.json"), + case.input.get("packages.lock.json") + ); let config = &after["nuget.config"]; assert!(!config.contains("socket-patch") && !config.contains("packageSourceMapping")); } @@ -2228,5 +2478,8 @@ async fn yarn_classic_git_pattern_pin_is_refused() { }, other => panic!("one pin expected: {other:?}"), } - assert_eq!(fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), lock); + assert_eq!( + fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + lock + ); } diff --git a/crates/socket-patch-core/tests/uv_hosted.rs b/crates/socket-patch-core/tests/uv_hosted.rs index 9a2526cd7..81696f5dc 100644 --- a/crates/socket-patch-core/tests/uv_hosted.rs +++ b/crates/socket-patch-core/tests/uv_hosted.rs @@ -1,8 +1,6 @@ use std::collections::BTreeMap; -use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect, DepOverride, Integrity, -}; +use socket_patch_core::patch::redirect::{rewrite_registry_redirect, DepOverride, Integrity}; fn patch(name: &str) -> DepOverride { DepOverride { diff --git a/crates/socket-patch-node/src/lib.rs b/crates/socket-patch-node/src/lib.rs index d83403b84..29fa8e6ae 100644 --- a/crates/socket-patch-node/src/lib.rs +++ b/crates/socket-patch-node/src/lib.rs @@ -15,11 +15,11 @@ use std::sync::Arc; use napi::bindgen_prelude::{Buffer, External, Function, JsObjectValue, Object, PromiseRaw}; use napi::{Env, Status}; use napi_derive::napi; +use socket_patch_core::api::client::PatchApi; use socket_patch_core::hosted::memory::{ - self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, SelectOptions, - SessionBuilder, TreeEntryInput, + self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, + SelectOptions, SessionBuilder, TreeEntryInput, }; -use socket_patch_core::api::client::PatchApi; use tokio_util::sync::CancellationToken; use provider::{JsPatchApi, ProviderRefs}; From 257d86aefb9cb7358343e44ff09093c13333704b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:45:01 +0000 Subject: [PATCH 04/11] Document that yarn PnP follows nodeLinker Assisted-by: Claude Code:claude-opus-5-5 --- docs/testing/yarn-berry-compatibility.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 4ea792d22..5a1090874 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -11,7 +11,12 @@ vendored (`vendor` wires the root linkers are covered end to end; Plug'n'Play keeps packages inside `.yarn/cache` zips, so `vendor` refuses it (`vendor_yarn_berry_unsupported`) and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still -attests a hosted lock's `checksum:` pin. +attests a hosted lock's `checksum:` pin. Plug'n'Play is decided by the +configured linker (`YARN_NODE_LINKER`, else the nearest `.yarnrc.yml` that +sets `nodeLinker`; unset means berry's default, `pnp`), not by whether a +`.pnp.*` loader happens to exist: `vendor` refuses a lock-only PnP checkout up +front, and a stale `.pnp.js` left by a Yarn 2 migration to `node-modules` or +`pnpm` is ignored. ## Hosted pin shape and registry credentials From 369e6a3721c1bcc2de6610bd379a5e8c3ba0cc4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:57:06 +0000 Subject: [PATCH 05/11] Pass the PnP marker probe by value Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/lock_inventory/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index a3bba7502..79e466918 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -388,7 +388,7 @@ pub(crate) async fn detect_npm_lock_flavor_in( .map(str::to_string) }) }; - if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, &exists) { + if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, exists) { return Err(( "vendor_yarn_berry_unsupported", format!( From 8df7bc697133aba76f7119c976d3909bd19f757a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:14:50 +0000 Subject: [PATCH 06/11] Route Gradle and Maven digests through helpers Ports #878 so this branch passes the digest guard test that is red on main: the Gradle cache, JVM jar and Maven sidecar code hashed bytes inline instead of through utils::digest. No-op once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 59eac7e914fb3f50ec70696d9b2588055586fa6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:38:12 +0000 Subject: [PATCH 07/11] Keep the pnpm PnP refusal off the yarn linker A pnpm node-linker=pnp tree writes the same .pnp.cjs as yarn. When an ancestor .yarnrc.yml or YARN_NODE_LINKER set a non-pnp yarn linker, the loader counted as stale and vendor wired the pnpm project instead of refusing it. Decide the pnpm case on any loader first, then apply the yarn linker check. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/vendor/npm_flavor.rs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index c3778ee98..a0612dbfa 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -90,7 +90,9 @@ impl NpmLockFlavor { /// Yarn berry Plug'n'Play loaders: packages live inside `.yarn/cache/` zips, /// so there is nothing on disk to stage and no lockfile entry to rewire. -use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_LOCK}; +use crate::constants::npm_family::{ + BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, +}; /// Every lockfile name the probe knows, grouped into wiring families: the /// flavor that owns a family wires (or supersedes) every file in it, so only @@ -163,12 +165,12 @@ pub(crate) async fn detect_npm_lock_flavor( // wrong twice over. Vendor still refuses (the file: rewiring has no // fixtures under pnpm's PnP linker — fail closed), but with a pnpm // diagnosis and remedy. - // A loader the configured `nodeLinker` disowns is stale (#975) and - // skipped, so the yarn berry sniff below decides. - let marker = crate::crawlers::pkg_managers::live_pnp_marker(project_root, |m| { - std::fs::metadata(project_root.join(m)).is_ok() - }); - if let Some(marker) = marker { + // The pnpm carve-out is decided on any loader: yarn's `nodeLinker` + // says nothing about pnpm's own. A yarn loader the configured + // `nodeLinker` disowns is stale (#975) and skipped, so the yarn berry + // sniff below decides. + let present = |m: &str| std::fs::metadata(project_root.join(m)).is_ok(); + if let Some(marker) = PNP_MARKERS.into_iter().find(|m| present(m)) { if crate::crawlers::pkg_managers::pnpm_pnp_layout(project_root) { return Err(( "vendor_pnpm_pnp_unsupported", @@ -183,6 +185,8 @@ pub(crate) async fn detect_npm_lock_flavor( ), )); } + } + if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker(project_root, present) { return Err(( "vendor_yarn_berry_unsupported", format!( @@ -961,7 +965,6 @@ mod tests { } use super::*; - use crate::constants::npm_family::PNP_MARKERS; use crate::hash::git_sha256::compute_git_sha256_from_bytes; #[test] @@ -1124,6 +1127,18 @@ mod tests { assert!(!detail.contains("yarn patch"), "{detail}"); } + /// pnpm's PnP loader is pnpm's, not yarn's: a yarn `nodeLinker` that + /// disowns yarn loaders (an ancestor `.yarnrc.yml`, `YARN_NODE_LINKER`) + /// must not let a pnpm `node-linker=pnp` tree past its own refusal. + #[tokio::test] + async fn pnpm_pnp_layout_refuses_under_a_non_pnp_yarn_linker() { + let tmp = tempfile::tempdir().unwrap(); + stage_pnpm_pnp_layout(tmp.path()).await; + touch(tmp.path(), ".yarnrc.yml", "nodeLinker: node-modules\n").await; + let (code, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_pnpm_pnp_unsupported"); + } + /// The pnpm-PnP carve-out stays fail-closed: a yarn.lock alongside /// the loader (ambiguous multi-PM tree), or a stale pnpm-lock.yaml /// with no installed pnpm store, keeps the yarn-berry refusal. From 54494cc42c6bd82f8dd912b637896417a888c96c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:41:04 +0000 Subject: [PATCH 08/11] Keep yarn 1 PnP and home-rc linkers in PnP check Two gaps in the nodeLinker-based PnP decision, both reported against this PR by the yarn bug-hunt routines: - Yarn 1 PnP (installConfig.pnp, a classic yarn.lock) has no nodeLinker, and yarn 1 reads neither .yarnrc.yml nor YARN_NODE_LINKER. A berry setting from a parent rc file or the environment hid its .pnp.js, so agent mode skipped the package as "not installed" and hosted/vendored lost the PnP warning. A loader beside a classic lock now always counts. - yarn berry also reads the home folder's rc file and honours YARN_RC_FILENAME. With nodeLinker: node-modules set only there, vendor refused an installed node-modules project as PnP. The linker lookup now reads the renamed rc file everywhere and falls back to the home rc, on disk and in the in-memory lock view. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_safety_yarn_pnp.rs | 70 ++++++ .../src/crawlers/pkg_managers.rs | 211 +++++++++++++++--- .../src/vendor/lock_inventory/view.rs | 39 +++- .../src/vendor/npm_flavor.rs | 57 ++++- docs/testing/yarn-berry-compatibility.md | 13 +- 5 files changed, 347 insertions(+), 43 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index f8a1388cc..b2f1042cb 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -1134,6 +1134,76 @@ fn stale_pnp_loader_under_non_pnp_linker_applies() { } } +/// The home folder's rc file is one yarn reads (after every project-side +/// rc file), so a `nodeLinker: node-modules` there disowns a stale loader +/// in a project outside the home folder, as a project rc does (#975). +#[test] +fn stale_pnp_loader_under_home_rc_linker_applies() { + let home = tempfile::tempdir().unwrap(); + std::fs::write( + home.path().join(".yarnrc.yml"), + "nodeLinker: node-modules\n", + ) + .unwrap(); + let home = home.path().to_str().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"migrated","version":"0.0.0","private":true}"#, + ) + .unwrap(); + std::fs::write(cwd.join("yarn.lock"), "__metadata:\n version: 8\n").unwrap(); + std::fs::write(cwd.join(".pnp.js"), b"// stale Yarn 2 loader\n").unwrap(); + let index = stage_applicable_package(cwd); + let (code, stdout, stderr) = run_with_env( + cwd, + &["apply", "--json"], + &[("HOME", home), ("USERPROFILE", home)], + ); + assert_eq!( + code, 0, + "a home-rc linker disowns the loader.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!(std::fs::read(&index).unwrap(), PATCHED_BYTES); +} + +/// Yarn 1 PnP (`installConfig.pnp`, a classic `yarn.lock` and `.pnp.js`) +/// has no `nodeLinker`, and yarn 1 reads neither `.yarnrc.yml` nor +/// `YARN_NODE_LINKER`. A berry setting from a parent rc file or the +/// environment must not hide its loader: apply still refuses, and the +/// installed-looking copy stays untouched. +#[test] +fn yarn1_pnp_loader_refuses_despite_berry_linker_settings() { + for env in [&[][..], &[("YARN_NODE_LINKER", "node-modules")][..]] { + let w = tempfile::tempdir().unwrap(); + std::fs::write(w.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); + let cwd = w.path().join("proj"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"app","version":"1.0.0","private":true,"installConfig":{"pnp":true}}"#, + ) + .unwrap(); + std::fs::write( + cwd.join("yarn.lock"), + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n", + ) + .unwrap(); + std::fs::write(cwd.join(".pnp.js"), b"// yarn 1 PnP loader\n").unwrap(); + let index = stage_applicable_package(&cwd); + let (code, stdout, _) = run_with_env(&cwd, &["apply", "--json"], env); + assert_eq!(code, 1, "{env:?}: {stdout}"); + let envelope = parse_json_envelope(&stdout); + assert_eq!( + envelope_error_code(&envelope), + Some("yarn_pnp_unsupported"), + "{env:?}" + ); + assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES, "{env:?}"); + } +} + /// The configured linker still refuses a live PnP project: `nodeLinker: /// pnp` with a loader present is refused exactly as before. #[test] diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 250ef86bf..58d1aa63e 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -31,7 +31,7 @@ //! Classic yarn (`yarn.lock` + a real `node_modules/`) behaves like //! npm at the filesystem level, so no special handling is needed. -use std::path::Path; +use std::path::{Path, PathBuf}; /// Identified Node.js package manager / layout flavor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -161,27 +161,90 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { NpmPkgManager::Unknown } -/// The `nodeLinker` yarn resolves for `project_root`: `YARN_NODE_LINKER` -/// when set (yarn lets every setting be overridden from the environment), -/// else the nearest `.yarnrc.yml` at or above the project that sets -/// `nodeLinker` (yarn merges every rc file up to the filesystem root, the -/// closest winning). `None` when nothing sets it: yarn berry then uses its -/// default linker, `pnp`. +/// The yarn environment that decides which rc files yarn berry reads: +/// `YARN_NODE_LINKER`, `YARN_RC_FILENAME` (the rc file name yarn looks for +/// in every folder, `.yarnrc.yml` by default) and the home folder, whose +/// rc file yarn reads last. +#[derive(Debug, Clone)] +pub(crate) struct YarnEnv { + pub node_linker: Option, + pub rc_filename: String, + pub home: Option, +} + +impl YarnEnv { + pub(crate) fn current() -> Self { + let set = |k: &str| { + std::env::var(k) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }; + Self { + node_linker: set("YARN_NODE_LINKER"), + rc_filename: set("YARN_RC_FILENAME").unwrap_or_else(|| ".yarnrc.yml".to_string()), + home: Some(crate::utils::fs::home_dir()), + } + } + + /// The `nodeLinker` the rc file text `rc` sets, if any. + pub(crate) fn rc_node_linker(&self, rc: &str) -> Option { + crate::vendor::yarn_berry_lock::yarnrc_scalar(rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) + } + + /// The `nodeLinker` the home folder's rc file sets: yarn reads it after + /// every project-side rc file, so it applies only when none of those + /// sets the key. + pub(crate) fn home_node_linker(&self) -> Option { + let rc = crate::utils::fs::read_regular_to_string_sync( + &self.home.as_ref()?.join(&self.rc_filename), + ) + .ok()?; + self.rc_node_linker(&rc) + } +} + +/// The `nodeLinker` yarn berry resolves for `project_root`: +/// `YARN_NODE_LINKER` when set (yarn lets every setting be overridden from +/// the environment), else the nearest rc file at or above the project that +/// sets `nodeLinker` (yarn merges every rc file up to the filesystem root, +/// the closest winning), else the home folder's rc file. The rc file name +/// is `YARN_RC_FILENAME` when set. `None` when nothing sets it: yarn berry +/// then uses its default linker, `pnp`. pub fn yarn_node_linker(project_root: &Path) -> Option { - yarn_node_linker_with(project_root, std::env::var("YARN_NODE_LINKER").ok()) + yarn_node_linker_in(project_root, &YarnEnv::current()) } -fn yarn_node_linker_with(project_root: &Path, env: Option) -> Option { - if let Some(linker) = env.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) { +pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option { + if let Some(linker) = env.node_linker.clone() { return Some(linker); } let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); - start.ancestors().find_map(|dir| { - let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc.yml")).ok()?; - crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") - .filter(|v| !v.is_empty()) - .map(str::to_string) - }) + start + .ancestors() + .find_map(|dir| { + let rc = + crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; + env.rc_node_linker(&rc) + }) + .or_else(|| env.home_node_linker()) +} + +/// The linker that decides whether a PnP loader is live: yarn 1 has no +/// `nodeLinker` (its PnP mode is `installConfig.pnp` in package.json, and +/// it reads neither `.yarnrc.yml` nor `YARN_NODE_LINKER`), so a loader +/// beside a classic `yarn.lock` is always live and reports `pnp`. Any +/// other project asks `linker` for yarn berry's configured `nodeLinker`. +pub(crate) fn effective_yarn_linker( + yarn_lock: Option<&str>, + linker: impl FnOnce() -> Option, +) -> Option { + match yarn_lock.and_then(crate::formats::yarn::sniff_grammar) { + Some(crate::formats::yarn::YarnLockGrammar::Classic) => Some("pnp".to_string()), + _ => linker(), + } } /// Whether a yarn `nodeLinker` setting (`None` = unset) is Plug'n'Play: @@ -195,10 +258,19 @@ pub fn yarn_linker_is_pnp(linker: Option<&str>) -> bool { /// the configured linker is still `pnp` (or unset): a Yarn 2 → Yarn 4 /// migration that switched `nodeLinker` to `node-modules` or `pnpm` keeps /// the old `.pnp.js`, which yarn ignores, and so must every caller (#975). +/// Yarn 1 PnP (a classic `yarn.lock`) has no `nodeLinker`, so its loader +/// always counts ([`effective_yarn_linker`]). /// Every `PNP_MARKERS` decision goes through here or /// [`live_pnp_marker_with`]. pub fn live_pnp_marker(project_root: &Path, exists: impl Fn(&str) -> bool) -> Option<&'static str> { - live_pnp_marker_with(|| yarn_node_linker(project_root), exists) + live_pnp_marker_with( + || { + let lock = + crate::utils::fs::read_regular_to_string_sync(&project_root.join("yarn.lock")); + effective_yarn_linker(lock.ok().as_deref(), || yarn_node_linker(project_root)) + }, + exists, + ) } /// [`live_pnp_marker`] over any file view: `linker` supplies the configured @@ -261,7 +333,7 @@ mod tests { .unwrap(); assert_eq!( live_pnp_marker_with( - || yarn_node_linker_with(d.path(), None), + || yarn_node_linker_in(d.path(), &bare_env(None)), |m| d.path().join(m).is_file() ), None, @@ -276,12 +348,12 @@ mod tests { std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); let exists = |m: &str| d.path().join(m).is_file(); assert_eq!( - live_pnp_marker_with(|| yarn_node_linker_with(d.path(), None), exists), + live_pnp_marker_with(|| yarn_node_linker_in(d.path(), &bare_env(None)), exists), Some(".pnp.cjs") ); std::fs::write(d.path().join(".yarnrc.yml"), "nodeLinker: \"pnp\"\n").unwrap(); assert_eq!( - live_pnp_marker_with(|| yarn_node_linker_with(d.path(), None), exists), + live_pnp_marker_with(|| yarn_node_linker_in(d.path(), &bare_env(None)), exists), Some(".pnp.cjs") ); } @@ -293,25 +365,114 @@ mod tests { let d = tempfile::tempdir().unwrap(); let member = d.path().join("packages/a"); std::fs::create_dir_all(&member).unwrap(); - assert_eq!(yarn_node_linker_with(&member, None), None); + assert_eq!(yarn_node_linker_in(&member, &bare_env(None)), None); std::fs::write(d.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); std::fs::write(member.join(".yarnrc.yml"), "enableGlobalCache: false\n").unwrap(); assert_eq!( - yarn_node_linker_with(&member, None).as_deref(), + yarn_node_linker_in(&member, &bare_env(None)).as_deref(), Some("node-modules") ); std::fs::write(member.join(".yarnrc.yml"), "nodeLinker: pnp\n").unwrap(); - assert_eq!(yarn_node_linker_with(&member, None).as_deref(), Some("pnp")); assert_eq!( - yarn_node_linker_with(&member, Some(" pnpm ".into())).as_deref(), + yarn_node_linker_in(&member, &bare_env(None)).as_deref(), + Some("pnp") + ); + assert_eq!( + yarn_node_linker_in(&member, &bare_env(Some("pnpm"))).as_deref(), Some("pnpm") ); + // `YarnEnv::current` drops an empty `YARN_NODE_LINKER`. assert_eq!( - yarn_node_linker_with(&member, Some(String::new())).as_deref(), + yarn_node_linker_in(&member, &bare_env(None)).as_deref(), Some("pnp") ); } + /// A [`YarnEnv`] with no home folder and the default rc file name. + fn bare_env(node_linker: Option<&str>) -> YarnEnv { + YarnEnv { + node_linker: node_linker.map(str::to_string), + rc_filename: ".yarnrc.yml".into(), + home: None, + } + } + + /// yarn reads the home folder's rc file after every project-side one, + /// and `YARN_RC_FILENAME` renames the rc file it looks for everywhere. + #[test] + fn yarn_node_linker_reads_home_rc_and_rc_filename() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let mut env = bare_env(None); + env.home = Some(home.path().to_path_buf()); + assert_eq!(yarn_node_linker_in(project.path(), &env), None); + std::fs::write( + home.path().join(".yarnrc.yml"), + "nodeLinker: node-modules\n", + ) + .unwrap(); + assert_eq!( + yarn_node_linker_in(project.path(), &env).as_deref(), + Some("node-modules"), + "home rc applies to a project outside the home folder" + ); + std::fs::write(project.path().join(".yarnrc.yml"), "nodeLinker: pnp\n").unwrap(); + assert_eq!( + yarn_node_linker_in(project.path(), &env).as_deref(), + Some("pnp"), + "a project rc wins over the home rc" + ); + + env.rc_filename = ".yarnrc.ci.yml".into(); + assert_eq!( + yarn_node_linker_in(project.path(), &env), + None, + "a renamed rc file skips .yarnrc.yml everywhere" + ); + std::fs::write(home.path().join(".yarnrc.ci.yml"), "nodeLinker: pnpm\n").unwrap(); + assert_eq!( + yarn_node_linker_in(project.path(), &env).as_deref(), + Some("pnpm") + ); + std::fs::write( + project.path().join(".yarnrc.ci.yml"), + "nodeLinker: node-modules\n", + ) + .unwrap(); + assert_eq!( + yarn_node_linker_in(project.path(), &env).as_deref(), + Some("node-modules") + ); + } + + /// Yarn 1 PnP (`installConfig.pnp`) has no `nodeLinker`, and yarn 1 + /// reads neither `.yarnrc.yml` nor `YARN_NODE_LINKER`: a loader beside + /// a classic lock stays live whatever a berry setting says. The same + /// setting still disowns a loader beside a berry lock (#975). + #[test] + fn yarn1_pnp_loader_ignores_berry_linker_settings() { + let w = tempfile::tempdir().unwrap(); + let proj = w.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(w.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); + std::fs::write(proj.join(".pnp.js"), "").unwrap(); + let exists = |m: &str| proj.join(m).is_file(); + for env in [bare_env(None), bare_env(Some("node-modules"))] { + let probe = |lock: &str| { + live_pnp_marker_with( + || effective_yarn_linker(Some(lock), || yarn_node_linker_in(&proj, &env)), + exists, + ) + }; + assert_eq!( + probe("# THIS IS AN AUTOGENERATED FILE.\n# yarn lockfile v1\n"), + Some(".pnp.js"), + "{env:?}" + ); + assert_eq!(probe("__metadata:\n version: 8\n"), None, "{env:?}"); + } + } + #[test] fn unknown_for_empty_dir() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 79e466918..7b2fe28ad 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -375,18 +375,21 @@ pub(crate) async fn detect_npm_lock_flavor_in( }; // A loader the configured `nodeLinker` disowns is stale (#975). Only the - // project's own `.yarnrc.yml` is in a memory snapshot. + // project's own rc file is in a memory snapshot; the home folder's rc + // file is the environment's, read from disk as yarn does. A classic + // lock is yarn 1, which has no `nodeLinker`. let linker = || { - std::env::var("YARN_NODE_LINKER") - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .or_else(|| { - let rc = project.read_text(".yarnrc.yml").ok()?; - crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") - .filter(|v| !v.is_empty()) - .map(str::to_string) - }) + let env = crate::crawlers::pkg_managers::YarnEnv::current(); + let lock = project.read_text("yarn.lock").ok(); + crate::crawlers::pkg_managers::effective_yarn_linker(lock.as_deref(), || { + env.node_linker + .clone() + .or_else(|| { + let rc = project.read_text(&env.rc_filename).ok()?; + env.rc_node_linker(&rc) + }) + .or_else(|| env.home_node_linker()) + }) }; if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, exists) { return Err(( @@ -578,6 +581,20 @@ mod tests { .0, NpmLockFlavor::YarnBerry ); + // Yarn 1 PnP: a classic lock has no `nodeLinker`, so a berry + // setting beside it does not disown the loader. + let yarn1_pnp = project(&[ + (".pnp.js", MemoryEntry::Present), + (".yarnrc.yml", text("nodeLinker: node-modules\n")), + ("yarn.lock", text("# yarn lockfile v1\n")), + ]); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&yarn1_pnp)) + .await + .unwrap_err() + .0, + "vendor_yarn_berry_unsupported" + ); let pnp = project(&[(".pnp.cjs", MemoryEntry::Present), ("yarn.lock", text(""))]); assert_eq!( detect_npm_lock_flavor_in(&ProjectView::Memory(&pnp)) diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index a0612dbfa..56c1ffe5a 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -350,18 +350,33 @@ async fn sniff_yarn_lock(project_root: &Path) -> Result Result<(NpmLockFlavor, Vec), (&'static str, String)> { + detect_vendorable_npm_flavor_with(project_root, || { + crate::crawlers::pkg_managers::yarn_node_linker(project_root) + }) + .await +} + +/// [`detect_vendorable_npm_flavor`] with the configured `nodeLinker` +/// supplied by `linker`. +async fn detect_vendorable_npm_flavor_with( + project_root: &Path, + linker: impl FnOnce() -> Option, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { let found = detect_npm_lock_flavor(project_root).await?; if found.0 != NpmLockFlavor::YarnBerry { return Ok(found); } - let linker = crate::crawlers::pkg_managers::yarn_node_linker(project_root); + let linker = linker(); if !crate::crawlers::pkg_managers::yarn_linker_is_pnp(linker.as_deref()) { return Ok(found); } let why = match linker { Some(_) => "the configured yarn linker is `nodeLinker: pnp`", - None => "no `.yarnrc.yml` sets `nodeLinker`, so yarn berry uses its default `pnp` linker", + None => { + "no yarn rc file (the project's, its parents' or the home folder's) \ + sets `nodeLinker`, so yarn berry uses its default `pnp` linker" + } }; Err(( "vendor_yarn_berry_unsupported", @@ -1097,6 +1112,44 @@ mod tests { assert_eq!(flavor, NpmLockFlavor::YarnClassic); } + /// The up-front PnP refusal follows every rc file yarn reads: a + /// `nodeLinker: node-modules` in the home folder's rc file, or in the + /// file `YARN_RC_FILENAME` names, is a node-modules project, which + /// vendor wires (#539 follow-up: these were refused as PnP). + #[tokio::test] + async fn vendorable_probe_follows_home_rc_and_rc_filename() { + use crate::crawlers::pkg_managers::{yarn_node_linker_in, YarnEnv}; + let home = tempfile::tempdir().unwrap(); + touch(home.path(), ".yarnrc.yml", "nodeLinker: node-modules\n").await; + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + touch(tmp.path(), ".yarnrc.yml", "enableGlobalCache: false\n").await; + let env = YarnEnv { + node_linker: None, + rc_filename: ".yarnrc.yml".into(), + home: Some(home.path().to_path_buf()), + }; + let (flavor, _) = + detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) + .await + .unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry, "home rc"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + touch(tmp.path(), ".yarnrc.ci.yml", "nodeLinker: node-modules\n").await; + let env = YarnEnv { + node_linker: None, + rc_filename: ".yarnrc.ci.yml".into(), + home: None, + }; + let (flavor, _) = + detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) + .await + .unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry, "YARN_RC_FILENAME"); + } + /// Stage the root markers a real `pnpm install` with /// `node-linker=pnp` emits (layout verified against pnpm 10.28.2): /// the `.pnp.cjs` loader, pnpm-lock.yaml, and an installed virtual diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 5a1090874..9bbfe2df8 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -12,11 +12,14 @@ linkers are covered end to end; Plug'n'Play keeps packages inside `.yarn/cache` zips, so `vendor` refuses it (`vendor_yarn_berry_unsupported`) and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still attests a hosted lock's `checksum:` pin. Plug'n'Play is decided by the -configured linker (`YARN_NODE_LINKER`, else the nearest `.yarnrc.yml` that -sets `nodeLinker`; unset means berry's default, `pnp`), not by whether a -`.pnp.*` loader happens to exist: `vendor` refuses a lock-only PnP checkout up -front, and a stale `.pnp.js` left by a Yarn 2 migration to `node-modules` or -`pnpm` is ignored. +configured linker (`YARN_NODE_LINKER`, else the nearest rc file at or above +the project that sets `nodeLinker`, else the home folder's rc file; the rc +file is `.yarnrc.yml` unless `YARN_RC_FILENAME` renames it; unset means +berry's default, `pnp`), not by whether a `.pnp.*` loader happens to exist: +`vendor` refuses a lock-only PnP checkout up front, and a stale `.pnp.js` left +by a Yarn 2 migration to `node-modules` or `pnpm` is ignored. Yarn 1 PnP +(`installConfig.pnp`, a classic `yarn.lock`) has no `nodeLinker`, so its +loader is always refused, whatever a berry setting says. ## Hosted pin shape and registry credentials From 8ee4c5479129ac4faa2b441d2fcb44fa688bf50a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:07:46 +0000 Subject: [PATCH 09/11] Read only the snapshot's rc in the memory view A memory snapshot is the repository alone, so its PnP decision must not take the scanning host's YARN_NODE_LINKER, YARN_RC_FILENAME or home .yarnrc.yml. A worker's home rc could otherwise disown a repo's live PnP loader, and a renamed rc file name could skip the snapshot's own .yarnrc.yml. The memory view now reads only the snapshot's .yarnrc.yml, plus the yarn 1 classic-lock rule. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/pkg_managers.rs | 20 +++++++++--------- .../src/vendor/lock_inventory/view.rs | 21 ++++++++----------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 58d1aa63e..b2b4e36f1 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -187,25 +187,25 @@ impl YarnEnv { } } - /// The `nodeLinker` the rc file text `rc` sets, if any. - pub(crate) fn rc_node_linker(&self, rc: &str) -> Option { - crate::vendor::yarn_berry_lock::yarnrc_scalar(rc, "nodeLinker") - .filter(|v| !v.is_empty()) - .map(str::to_string) - } - /// The `nodeLinker` the home folder's rc file sets: yarn reads it after /// every project-side rc file, so it applies only when none of those /// sets the key. - pub(crate) fn home_node_linker(&self) -> Option { + fn home_node_linker(&self) -> Option { let rc = crate::utils::fs::read_regular_to_string_sync( &self.home.as_ref()?.join(&self.rc_filename), ) .ok()?; - self.rc_node_linker(&rc) + rc_node_linker(&rc) } } +/// The `nodeLinker` the rc file text `rc` sets, if any. +fn rc_node_linker(rc: &str) -> Option { + crate::vendor::yarn_berry_lock::yarnrc_scalar(rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + /// The `nodeLinker` yarn berry resolves for `project_root`: /// `YARN_NODE_LINKER` when set (yarn lets every setting be overridden from /// the environment), else the nearest rc file at or above the project that @@ -227,7 +227,7 @@ pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option< .find_map(|dir| { let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; - env.rc_node_linker(&rc) + rc_node_linker(&rc) }) .or_else(|| env.home_node_linker()) } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 7b2fe28ad..6bd48705d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -374,21 +374,18 @@ pub(crate) async fn detect_npm_lock_flavor_in( }) }; - // A loader the configured `nodeLinker` disowns is stale (#975). Only the - // project's own rc file is in a memory snapshot; the home folder's rc - // file is the environment's, read from disk as yarn does. A classic - // lock is yarn 1, which has no `nodeLinker`. + // A loader the configured `nodeLinker` disowns is stale (#975). A + // memory snapshot is the repository alone, not the host it is scanned + // on: only its own `.yarnrc.yml` counts, never the host's + // `YARN_NODE_LINKER`, `YARN_RC_FILENAME` or home rc file. A classic lock + // is yarn 1, which has no `nodeLinker`. let linker = || { - let env = crate::crawlers::pkg_managers::YarnEnv::current(); let lock = project.read_text("yarn.lock").ok(); crate::crawlers::pkg_managers::effective_yarn_linker(lock.as_deref(), || { - env.node_linker - .clone() - .or_else(|| { - let rc = project.read_text(&env.rc_filename).ok()?; - env.rc_node_linker(&rc) - }) - .or_else(|| env.home_node_linker()) + let rc = project.read_text(".yarnrc.yml").ok()?; + crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) }) }; if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, exists) { From decde39c0e87eee95e17a0f222000a0de86bb51c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:11:42 -0400 Subject: [PATCH 10/11] Follow ancestor .yarnrc.yml linker in the in-memory flavor probe The hosted in-memory engine built each root's MemoryProject from the root's own files only, and the memory flavor probe read only the root's .yarnrc.yml. On disk, yarn_node_linker walks every rc file above the project, the closest one that sets nodeLinker winning. A nested yarn root (apps/web) whose stale .pnp.* loader was disowned by a nodeLinker: node-modules in a repository-level .yarnrc.yml was therefore still refused as Plug'n'Play in memory (Bugbot on b6018c5). - select_paths fetches the .yarnrc.yml files strictly above a root that holds a PnP loader. - project_for hands those texts to the root's MemoryProject, nearest first (set_ancestor_yarnrcs). - The memory probe takes the root rc's nodeLinker, else the nearest ancestor's. The host's env and home rc stay out, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/hosted/memory/mod.rs | 56 +++++++++++++++ .../src/hosted/memory/roots.rs | 27 +++++++ .../src/hosted/memory/select.rs | 42 ++++++++++- .../src/vendor/lock_inventory/view.rs | 71 +++++++++++++++++-- 4 files changed, 187 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 358f81e42..06d814f9e 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -239,6 +239,18 @@ fn project_for( } project.insert(rel, file.entry.clone()); } + // yarn berry merges the rc files above the project as well, so a + // nested yarn root sees the repository's `.yarnrc.yml` files above it + // (`select_paths` fetches them for a root holding a PnP loader). + let ancestor_yarnrcs = roots::strict_ancestors(root) + .filter_map( + |dir| match &files.get(&roots::join_root(dir, roots::YARNRC_NAME))?.entry { + MemoryEntry::Text(text) => Some(Arc::clone(text)), + _ => None, + }, + ) + .collect(); + project.set_ancestor_yarnrcs(ancestor_yarnrcs); (project, unreadable) } @@ -1517,6 +1529,50 @@ mod tests { ); } + /// #975: a nested yarn root's loader follows a `nodeLinker` set only in + /// a repository `.yarnrc.yml` above it, as the disk walk does. + #[tokio::test] + async fn nested_yarn_root_follows_an_ancestor_yarnrc_linker() { + use crate::vendor::lock_inventory::view::detect_npm_lock_flavor_in; + let mut files: BTreeMap = BTreeMap::new(); + files.insert( + ".yarnrc.yml".into(), + share(InputFile::Text("nodeLinker: node-modules\n".into())), + ); + files.insert( + "apps/.yarnrc.yml".into(), + share(InputFile::Text("enableGlobalCache: false\n".into())), + ); + files.insert( + "apps/web/yarn.lock".into(), + share(InputFile::Text("__metadata:\n version: 8\n".into())), + ); + files.insert( + "apps/web/.pnp.cjs".into(), + share(InputFile::Present(PresentKind::Present)), + ); + let (web, _) = project_for("apps/web", &files); + assert!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&web)) + .await + .is_ok(), + "the repository rc disowns the stale loader" + ); + files.insert( + "apps/.yarnrc.yml".into(), + share(InputFile::Text("nodeLinker: pnp\n".into())), + ); + let (web, _) = project_for("apps/web", &files); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&web)) + .await + .unwrap_err() + .0, + "vendor_yarn_berry_unsupported", + "the nearest rc above the project wins" + ); + } + /// A multi-project sbt build (no lock, so no root): its root and /// subproject `build.sbt` files raise one run-level maven warning. In a /// rooted repo the root's own `build.sbt` is the root's (rooted) diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index 1a68e6528..6e17d9c18 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -90,6 +90,23 @@ pub(crate) fn join_root(root: &str, rel: &str) -> String { } } +/// yarn berry's rc file, which yarn merges from every directory at or +/// above the project (the closest setting winning). +pub(crate) const YARNRC_NAME: &str = ".yarnrc.yml"; + +/// The directories strictly above `root` inside the repository, nearest +/// first, ending with the repository root (`""`). None for the repository +/// root itself. +pub(crate) fn strict_ancestors(root: &str) -> impl Iterator { + let mut next = (!root.is_empty()).then_some(root); + std::iter::from_fn(move || { + let dir = next?; + let parent = split_path(dir).0; + next = (!parent.is_empty()).then_some(parent); + Some(parent) + }) +} + fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { ecosystems.is_none_or(|list| list.iter().any(|e| e == eco)) } @@ -245,4 +262,14 @@ mod tests { assert_eq!(found, vec!["a"]); assert_eq!(ignored[0].reason, "ecosystem_filtered"); } + + #[test] + fn strict_ancestors_walk_up_to_the_repo_root() { + assert_eq!(strict_ancestors("").count(), 0); + assert_eq!(strict_ancestors("web").collect::>(), vec![""]); + assert_eq!( + strict_ancestors("apps/web/ui").collect::>(), + vec!["apps/web", "apps", ""] + ); + } } diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index 9b91b7bb2..449732997 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -20,8 +20,8 @@ use crate::policy::{ }; use super::roots::{ - detect_roots, join_root, root_markers, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, - UNSUPPORTED_MARKERS, + detect_roots, join_root, root_markers, split_path, strict_ancestors, strip_root, + EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, YARNRC_NAME, }; use super::types::{ IgnoredPath, PathSelection, PolicyErrorInfo, PolicyFileInput, SelectOptions, TreeEntryInput, @@ -366,6 +366,17 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path let slot = needs.entry(full).or_insert(need); *slot = (*slot).min(need); } + // A PnP loader is live only under yarn berry's configured + // `nodeLinker`, which yarn also reads from the rc files above the + // project (#975), so a nested root fetches those too. + if PNP_MARKERS.iter().any(|m| files.contains(m)) { + for dir in strict_ancestors(root) { + let rc = join_root(dir, YARNRC_NAME); + if blobs.contains_key(&rc) { + needs.insert(rc, Need::Text); + } + } + } } for (eco, markers) in UNSUPPORTED_MARKERS { @@ -512,6 +523,33 @@ mod tests { assert_eq!(s.ignored_count, 2); } + /// #975: a nested root's PnP loader is judged by the `nodeLinker` of the + /// rc files above it too, so those are fetched; a root without a loader + /// (or an rc beside no root) does not pull them in. + #[test] + fn a_nested_pnp_root_fetches_the_yarnrc_files_above_it() { + let entries = vec![ + blob(".yarnrc.yml"), + blob("apps/.yarnrc.yml"), + blob("apps/web/yarn.lock"), + blob("apps/web/.pnp.cjs"), + blob("other/.yarnrc.yml"), + blob("tools/yarn.lock"), + ]; + let s = select_paths(&entries, &SelectOptions::default()); + assert_eq!(s.roots, vec!["apps/web", "tools"]); + assert_eq!( + s.fetch_text, + vec![ + ".yarnrc.yml", + "apps/.yarnrc.yml", + "apps/web/yarn.lock", + "tools/yarn.lock" + ] + ); + assert_eq!(s.present_only, vec!["apps/web/.pnp.cjs"]); + } + #[test] fn rush_and_cargo_members_are_fetched_under_their_root() { let entries = vec![ diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 6bd48705d..c6b52093b 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -38,6 +38,11 @@ pub enum MemoryEntry { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MemoryProject { entries: BTreeMap, + /// The `.yarnrc.yml` texts of the repository directories above this + /// project, nearest first. yarn berry merges every rc file at or above + /// the project, so a `nodeLinker` set only there still decides whether + /// a `.pnp.*` loader is live (#975). + ancestor_yarnrcs: Vec>, } impl MemoryProject { @@ -59,6 +64,11 @@ impl MemoryProject { self.insert(rel, MemoryEntry::Present); } + /// Set the `.yarnrc.yml` texts above the project, nearest first. + pub fn set_ancestor_yarnrcs(&mut self, rcs: Vec>) { + self.ancestor_yarnrcs = rcs; + } + pub fn remove(&mut self, rel: &str) -> Option { self.entries.remove(rel) } @@ -376,16 +386,28 @@ pub(crate) async fn detect_npm_lock_flavor_in( // A loader the configured `nodeLinker` disowns is stale (#975). A // memory snapshot is the repository alone, not the host it is scanned - // on: only its own `.yarnrc.yml` counts, never the host's - // `YARN_NODE_LINKER`, `YARN_RC_FILENAME` or home rc file. A classic lock - // is yarn 1, which has no `nodeLinker`. + // on: only the repository's own `.yarnrc.yml` files count (the + // project's, then those above it, the closest setting winning, as on + // disk), never the host's `YARN_NODE_LINKER`, `YARN_RC_FILENAME` or + // home rc file. A classic lock is yarn 1, which has no `nodeLinker`. let linker = || { let lock = project.read_text("yarn.lock").ok(); crate::crawlers::pkg_managers::effective_yarn_linker(lock.as_deref(), || { - let rc = project.read_text(".yarnrc.yml").ok()?; - crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "nodeLinker") - .filter(|v| !v.is_empty()) - .map(str::to_string) + let node_linker = |rc: &str| { + crate::vendor::yarn_berry_lock::yarnrc_scalar(rc, "nodeLinker") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + project + .read_text(".yarnrc.yml") + .ok() + .and_then(|rc| node_linker(&rc)) + .or_else(|| { + project + .ancestor_yarnrcs + .iter() + .find_map(|rc| node_linker(rc)) + }) }) }; if let Some(marker) = crate::crawlers::pkg_managers::live_pnp_marker_with(linker, exists) { @@ -592,6 +614,41 @@ mod tests { .0, "vendor_yarn_berry_unsupported" ); + // The linker set only in a `.yarnrc.yml` above the project (the + // repository root over a nested yarn project) still counts, the + // nearest one winning; the project's own rc wins over both. + let mut parent_rc = stale.clone(); + parent_rc.remove(".yarnrc.yml"); + parent_rc.set_ancestor_yarnrcs(vec![ + Arc::from("enableGlobalCache: false\n"), + Arc::from("nodeLinker: node-modules\n"), + Arc::from("nodeLinker: pnp\n"), + ]); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&parent_rc)) + .await + .unwrap() + .0, + NpmLockFlavor::YarnBerry + ); + let mut own_rc_wins = parent_rc.clone(); + own_rc_wins.insert_text(".yarnrc.yml", "nodeLinker: pnp\n"); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&own_rc_wins)) + .await + .unwrap_err() + .0, + "vendor_yarn_berry_unsupported" + ); + let mut parent_pnp = parent_rc.clone(); + parent_pnp.set_ancestor_yarnrcs(vec![Arc::from("nodeLinker: pnp\n")]); + assert_eq!( + detect_npm_lock_flavor_in(&ProjectView::Memory(&parent_pnp)) + .await + .unwrap_err() + .0, + "vendor_yarn_berry_unsupported" + ); let pnp = project(&[(".pnp.cjs", MemoryEntry::Present), ("yarn.lock", text(""))]); assert_eq!( detect_npm_lock_flavor_in(&ProjectView::Memory(&pnp)) From e03e97234652ac5bf56bd8da8272bc5e0ba8378c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 15:05:07 -0400 Subject: [PATCH 11/11] Refuse lock-only yarn PnP in the hosted-to-vendored takeover preflight yarn_berry_vendor_preflight gated on the read-only flavor probe, which accepts a yarn berry project configured for Plug'n'Play (nodeLinker: pnp, or unset) until `yarn install` writes a .pnp.* loader. The takeover then restored the hosted pin before vendor_npm_any refused the project as PnP, relying on the group-commit savepoint (wet) or takeover_dry_refusal (dry) to undo it. Run the forward-vendoring probe first, as vendor_npm_any does, so the takeover refuses before it touches the hosted wiring. Core fixtures that rewrote .yarnrc.yml without nodeLinker now keep `nodeLinker: node-modules`, so they still exercise the gate they name. Bugbot finding on 641af6a (#978). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_vendor.rs | 30 ++++++++++- .../src/vendor/yarn_berry_lock.rs | 51 ++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index f2fda582a..759f0c20a 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1621,6 +1621,19 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { ) .unwrap(); }; + // #539 (Bugbot on #978): the project switches to Plug'n'Play, explicitly + // or by dropping `nodeLinker` (berry's default), before `yarn install` + // writes a loader — a lock-only PnP project vendored mode refuses. + let pnp_linker: Break = |root, _| { + std::fs::write( + root.join(".yarnrc.yml"), + "nodeLinker: pnp\r\nenableGlobalCache: false\r\n", + ) + .unwrap(); + }; + let default_linker: Break = |root, _| { + std::fs::write(root.join(".yarnrc.yml"), "enableGlobalCache: false\r\n").unwrap(); + }; // ── vendored → hosted ── for (label, breakage, rel, code) in [ @@ -1707,6 +1720,18 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { "", "vendor_yarn_berry_cache_unsupported", ), + ( + "pnp linker", + pnp_linker, + "", + "vendor_yarn_berry_unsupported", + ), + ( + "default pnp linker", + default_linker, + "", + "vendor_yarn_berry_unsupported", + ), ] { for dry in [true, false] { let ctx = format!("hosted→vendored {label} dry={dry}"); @@ -4033,7 +4058,10 @@ snapshots: hosted_npmrc, "the hosted .npmrc must be untouched" ); - assert!(!root.join(".socket/vendor/npm").exists(), "nothing is staged"); + assert!( + !root.join(".socket/vendor/npm").exists(), + "nothing is staged" + ); } /// Hosted → vendored over a linked `.socket/vendor/npm` (#664): the diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index f00df5b3b..05f076e13 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -1047,11 +1047,15 @@ fn gate_refusal(gate: BerryGate) -> VendorOutcome { refused(code, gate.detail()) } -/// The project-level refusals [`vendor_yarn_berry`] raises before any -/// write, whatever the purl: mixed line endings in yarn.lock or -/// package.json, an unsupported `cacheKey`, a non-zero `.yarnrc.yml` -/// `compressionLevel`. `None` unless the project's npm flavor is yarn berry -/// (the probe `vendor_npm_any` routes on) and every gate passes. +/// The project-level refusals a vendored run raises for a yarn berry +/// project before any write, whatever the purl: a configured Plug'n'Play +/// linker (`nodeLinker: pnp`, or unset — berry's default — even before any +/// `.pnp.*` loader exists, #539; `vendor_npm_any`'s forward-vendoring +/// probe, checked first as it is there), then [`vendor_yarn_berry`]'s +/// mixed line endings in yarn.lock or package.json, an unsupported +/// `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`. `None` unless +/// the project's npm flavor is yarn berry (the probe `vendor_npm_any` +/// routes on) and every gate passes. /// /// For the hosted→vendored mode takeover (`vendor`, `scan`/`get --mode /// vendored` over a hosted-redirected purl): the takeover reverts the @@ -1061,13 +1065,19 @@ fn gate_refusal(gate: BerryGate) -> VendorOutcome { /// gone, leaving the package unpatched in both modes. Returns `(code, detail)`, /// exactly the refusal the backend would raise. pub async fn yarn_berry_vendor_preflight(project_root: &Path) -> Option<(&'static str, String)> { - use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; + use super::npm_flavor::{detect_npm_lock_flavor, detect_vendorable_npm_flavor, NpmLockFlavor}; if !matches!( detect_npm_lock_flavor(project_root).await, Ok((NpmLockFlavor::YarnBerry, _)) ) { return None; } + // A lock-only PnP project passes the read-only probe above (no loader + // yet), but `vendor_npm_any` refuses it: the takeover must refuse + // before it restores the hosted pin. + if let Err(refusal) = detect_vendorable_npm_flavor(project_root).await { + return Some(refusal); + } let into_pair = |outcome: VendorOutcome| match outcome { VendorOutcome::Refused { code, detail } => Some((code, detail)), _ => None, @@ -3909,7 +3919,7 @@ __metadata: "compressionLevel", crlf(B3_BEFORE_PKG), crlf(B3_BEFORE_LOCK), - Some("compressionLevel: 9\r\n"), + Some("nodeLinker: node-modules\r\ncompressionLevel: 9\r\n"), ), ] { let fx = fixture_with(&pkg, &lock).await; @@ -3941,6 +3951,31 @@ __metadata: } } + /// #539 + Bugbot on #978: a lock-only yarn berry project configured + /// for Plug'n'Play (explicit `nodeLinker: pnp`, or no `nodeLinker` — + /// berry's default) has no `.pnp.*` loader yet, so the read-only flavor + /// probe accepts it. The takeover preflight must still raise the PnP + /// refusal `vendor_npm_any` would, before the hosted pin is restored. + #[tokio::test] + async fn preflight_refuses_a_lock_only_pnp_project() { + for (label, rc) in [ + ( + "explicit pnp", + "nodeLinker: pnp\nenableGlobalCache: false\n", + ), + ("default linker", "enableGlobalCache: false\n"), + ] { + let fx = fixture_with(B3_BEFORE_PKG, B3_BEFORE_LOCK).await; + tokio::fs::write(fx.root().join(YARNRC), rc).await.unwrap(); + let (code, detail) = yarn_berry_vendor_preflight(fx.root()) + .await + .unwrap_or_else(|| panic!("{label}: the preflight must refuse")); + assert_eq!(code, "vendor_yarn_berry_unsupported", "{label}: {detail}"); + assert!(detail.contains("Plug'n'Play"), "{label}: {detail}"); + fx.assert_untouched().await; + } + } + /// #629: both modes run ONE set of berry project gates, so the same /// project gets the same decision — the same gate, under each mode's /// code prefix, with the same detail — from the vendored preflight and @@ -3983,7 +4018,7 @@ __metadata: "compressionLevel mixed", lf_pkg.clone(), lf_lock.clone(), - Some("compressionLevel: mixed\n"), + Some("nodeLinker: node-modules\ncompressionLevel: mixed\n"), Some("cache_unsupported"), ), (