From de5f1287ed1f61e996b767e5bcc0894c34d49723 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 24 Jul 2026 15:32:23 -0400 Subject: [PATCH] feat(vendor): warn when a wired classic yarn.lock is one berry install from silent de-patching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proven end-to-end on a real monorepo (2026-07): yarn 2+ (berry) migrates a classic (v1) yarn.lock to its own format on install and re-resolves every entry from the registry — the vendored `file:./.socket/vendor/…` resolutions are dropped with NO warning and every package installs unpatched. New state-based core probe `yarn_classic_berry_migration_risk` (vendor/mod.rs): fires when yarn.lock is classic AND carries vendored wiring AND package.json does not pin yarn classic via `packageManager: yarn@1…` (a corepack pin makes stray berry installs refuse instead of migrate; major is parsed, so yarn@10 does not string-match yarn@1). Reads on-disk state, not run events, so callers invoke it unconditionally at envelope-finalize: unwired projects, yarn@1-pinned projects, and fully-reverted runs stay silent. Surfaced as a new run-level `warnings` array on the JSON envelope (`{code, detail}`, omitted when empty so existing consumers see byte-identical output) plus a stderr line in human mode — wired into the `vendor` command and both scan vendor flows (shared run_scan_vendor_step). Tests: 5 probe unit tests (pin suppression, yarn@4/yarn@10/pnpm pins still warn, unwired/berry/missing locks silent, malformed package.json fails toward warning); the yarn-classic capstone e2e now asserts the advisory appears on first vendor AND on in-sync re-runs, disappears under a yarn@1.22.22 packageManager pin (and the empty array is omitted from JSON), and falls silent after --revert. Co-Authored-By: Claude Fable 5 --- .../src/commands/scan/vendor_flow.rs | 10 +- .../socket-patch-cli/src/commands/vendor.rs | 28 +++- crates/socket-patch-cli/src/json_envelope.rs | 21 +++ .../tests/e2e_vendor_yarn_classic_build.rs | 52 +++++++ .../socket-patch-core/src/patch/vendor/mod.rs | 132 ++++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 86c7e1b..c96da68 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -17,7 +17,9 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; -use crate::commands::vendor::{reconcile_dropped, track_outcomes_for_vendor, vendor_records}; +use crate::commands::vendor::{ + note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, +}; use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; @@ -100,7 +102,10 @@ async fn run_scan_vendor_step( Ok(Some(m)) => m, Ok(None) => { // No manifest ⇒ nothing downloaded and nothing - // pre-existing to vendor: a clean no-op. + // pre-existing to vendor: a clean no-op. Wiring from a + // previous run may still sit in the lockfile, so the + // state-based migration-risk advisory still applies. + note_classic_migration_risk(&mut env, &common.cwd, common); drop(guard); return Ok((false, env)); } @@ -130,6 +135,7 @@ async fn run_scan_vendor_step( if has_errors { env.mark_partial_failure(); } + note_classic_migration_risk(&mut env, &common.cwd, common); Ok((has_errors, env)) } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 627c5f2..09ea79d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -42,7 +42,7 @@ use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; use crate::json_envelope::{ - Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status, VexSummary, + Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, Status, VexSummary, }; #[derive(Args)] @@ -254,6 +254,30 @@ pub(crate) fn record_warning( ); } +/// Run-level advisory shared by the `vendor` command and the scan-driven +/// vendor step: warn (once, at the envelope level — not per package) when +/// the project's classic `yarn.lock` carries vendored wiring that a stray +/// yarn 2+ install would silently drop. The probe is state-based (it reads +/// the on-disk lockfile), so callers invoke it unconditionally at +/// envelope-finalize time — unwired projects and fully-reverted runs stay +/// silent, and dry runs report the risk that already exists on disk. +pub(crate) fn note_classic_migration_risk( + env: &mut Envelope, + project_root: &Path, + common: &GlobalArgs, +) { + let Some(w) = vendor::yarn_classic_berry_migration_risk(project_root) else { + return; + }; + if !common.silent && !common.json { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + env.warnings.push(RunWarning { + code: w.code.to_string(), + detail: w.detail, + }); +} + pub async fn run(args: VendorArgs) -> i32 { apply_env_toggles(&args.common); let (telemetry_client, use_public_proxy) = @@ -355,6 +379,8 @@ pub async fn run(args: VendorArgs) -> i32 { } } + note_classic_migration_risk(&mut env, &args.common.cwd, &args.common); + if args.common.json { println!("{}", env.to_pretty_json()); } diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 8a90647..4977837 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -72,6 +72,15 @@ pub struct Envelope { /// against ecosystems with no sidecar contract (e.g. npm). #[serde(skip_serializing_if = "Vec::is_empty")] pub sidecars: Vec, + /// Run-level advisories that are about the PROJECT's state rather than + /// any single package (e.g. `yarn_classic_berry_migration_risk`: the + /// wired classic lockfile would be silently de-patched by a yarn 2+ + /// install). Distinct from per-purl `events` — consumers alert on these + /// without attributing them to a package. Empty (and omitted from JSON) + /// for runs with nothing to advise, so existing consumers see byte- + /// identical output. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, /// Present only when `--vex ` was passed to `apply`/`scan` and /// an OpenVEX document was successfully generated as a side-effect of /// the run. Describes where it landed and how many statements it @@ -108,6 +117,7 @@ impl Envelope { summary: Summary::default(), error: None, sidecars: Vec::new(), + warnings: Vec::new(), vex: None, } } @@ -437,6 +447,17 @@ impl EnvelopeError { } } +/// One run-level advisory (see [`Envelope::warnings`]). Same `code`/`detail` +/// vocabulary as per-event reasons, but scoped to the whole project/run. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunWarning { + /// Stable routing tag, e.g. `yarn_classic_berry_migration_risk`. + pub code: String, + /// Human-readable explanation with the suggested remediation. + pub detail: String, +} + // --------------------------------------------------------------------------- // Tests — pin the JSON serialization shape that downstream consumers see. // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index 996536a..849dbae 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -267,6 +267,18 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { applied.get("errorCode").is_none(), "clean apply event: {applied}" ); + // Run-level advisory: the fixture has no `packageManager` pin, so the + // wired classic lockfile is one stray `yarn@2+ install` away from being + // silently de-patched — the envelope must say so. + let run_warnings = env["warnings"].as_array().unwrap_or_else(|| { + panic!("wired classic project without a yarn@1 pin must carry run-level warnings: {env}") + }); + assert!( + run_warnings + .iter() + .any(|w| w["code"] == "yarn_classic_berry_migration_risk"), + "expected yarn_classic_berry_migration_risk: {env}" + ); // Artifact: deterministic tarball + informational marker in the uuid dir. let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); @@ -426,6 +438,41 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { lock_wired, "re-vendor must leave yarn.lock byte-identical" ); + // The advisory is state-based: an in-sync re-run (wiring still on disk) + // must warn again… + assert!( + env2["warnings"].as_array().is_some_and(|ws| ws + .iter() + .any(|w| w["code"] == "yarn_classic_berry_migration_risk")), + "in-sync re-run must still carry the migration-risk advisory: {env2}" + ); + // …and a `packageManager: yarn@1…` pin must silence it (corepack makes + // stray berry installs refuse instead of migrate). + let pkg_path = proj.join("package.json"); + let mut pkg: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + pkg["packageManager"] = serde_json::Value::String("yarn@1.22.22".to_string()); + std::fs::write(&pkg_path, serde_json::to_string_pretty(&pkg).unwrap()).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "pinned re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env3 = parse_envelope(&stdout); + assert!( + env3.get("warnings").is_none(), + "a yarn@1 packageManager pin must suppress the advisory (and empty \ + warnings must be omitted from JSON entirely): {env3}" + ); // 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone. let (code, stdout, stderr) = run_socket( @@ -446,6 +493,11 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert!( + renv.get("warnings").is_none(), + "after revert the wiring is gone — the state-based advisory must fall \ + silent: {renv}" + ); assert_eq!( std::fs::read(&lock_path).unwrap(), lock_before, diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index b11cc10..d040c8b 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -109,6 +109,54 @@ impl VendorWarning { } } +/// Advisory probe: is this project one `yarn install` away from silently +/// losing its vendored patches? +/// +/// Yarn 2+ (berry) migrates a classic (v1) `yarn.lock` to its own format on +/// install and re-resolves every entry from the registry — the vendored +/// `file:./.socket/vendor/…` resolutions are dropped with no warning and the +/// packages install unpatched (observed end-to-end on a real monorepo, +/// 2026-07). Returns the warning when ALL of: +/// +/// * `yarn.lock` exists and is classic (`# yarn lockfile v1` marker), AND +/// * it carries vendored wiring (`.socket/vendor/` resolutions), AND +/// * `package.json` does NOT pin yarn classic via `packageManager: yarn@1…` +/// (a corepack pin makes stray berry installs refuse instead of migrate). +/// +/// State-based (reads the wired lockfile, not the current run's events), so +/// callers can invoke it unconditionally at envelope-finalize time: it stays +/// silent on unwired projects and after a full revert. +pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option { + let lock = std::fs::read_to_string(project_root.join("yarn.lock")).ok()?; + if !lock.contains("# yarn lockfile v1") || !lock.contains(".socket/vendor/") { + return None; + } + if let Some(pm) = std::fs::read_to_string(project_root.join("package.json")) + .ok() + .and_then(|pkg| serde_json::from_str::(&pkg).ok()) + .and_then(|v| { + v.get("packageManager") + .and_then(|p| p.as_str().map(String::from)) + }) + { + let major = pm.trim().strip_prefix("yarn@").map(|rest| { + rest.chars() + .take_while(char::is_ascii_digit) + .collect::() + }); + if major.as_deref() == Some("1") { + return None; + } + } + Some(VendorWarning::new( + "yarn_classic_berry_migration_risk", + "yarn.lock is yarn-classic (v1) with vendored resolutions: installing with yarn 2+ \ + (berry) migrates the lockfile and silently drops them — packages install unpatched \ + from the registry. Pin yarn classic (e.g. \"packageManager\": \"yarn@1.22.22\" in \ + package.json) so every install uses yarn 1.", + )) +} + /// Where `vendor` acquires the installable patched artifact for a package. /// /// * `Auto` (default) — try the patch.socket.dev vendoring service first and @@ -997,3 +1045,87 @@ mod harvest_tests { ); } } + +#[cfg(test)] +mod berry_migration_risk_tests { + use super::*; + + const WIRED_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + left-pad@1.3.0:\n version \"1.3.0\"\n \ + resolved \"file:./.socket/vendor/npm/11111111-2222-4333-8444-555555555555/left-pad-1.3.0.tgz#abc\"\n \ + integrity sha512-x==\n"; + const UNWIRED_V1: &str = "# yarn lockfile v1\n\n\n\ + left-pad@1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#abc\"\n"; + + fn project(lock: Option<&str>, package_json: Option<&str>) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + if let Some(l) = lock { + std::fs::write(tmp.path().join("yarn.lock"), l).unwrap(); + } + if let Some(p) = package_json { + std::fs::write(tmp.path().join("package.json"), p).unwrap(); + } + tmp + } + + #[test] + fn wired_classic_without_pin_warns() { + let tmp = project(Some(WIRED_V1), Some(r#"{"name":"x"}"#)); + let w = yarn_classic_berry_migration_risk(tmp.path()).expect("must warn"); + assert_eq!(w.code, "yarn_classic_berry_migration_risk"); + assert!( + w.detail.contains("yarn 2+"), + "detail names the trap: {}", + w.detail + ); + } + + #[test] + fn yarn1_package_manager_pin_suppresses() { + let tmp = project( + Some(WIRED_V1), + Some(r#"{"name":"x","packageManager":"yarn@1.22.22"}"#), + ); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none()); + } + + #[test] + fn non_classic_pins_still_warn() { + // A berry pin does not make a classic lockfile safe — and `yarn@10` + // must not string-match the `yarn@1` prefix. + for pm in ["yarn@4.2.0", "yarn@10.0.0", "pnpm@9.0.0"] { + let pkg = format!(r#"{{"name":"x","packageManager":"{pm}"}}"#); + let tmp = project(Some(WIRED_V1), Some(&pkg)); + assert!( + yarn_classic_berry_migration_risk(tmp.path()).is_some(), + "{pm} must not suppress the warning" + ); + } + } + + #[test] + fn unwired_or_non_classic_locks_stay_silent() { + // Registry-only classic lock: no vendored wiring at risk. + let tmp = project(Some(UNWIRED_V1), Some(r#"{"name":"x"}"#)); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none()); + // Berry-format lock (no v1 marker) even with a vendor-ish string. + let berry = "__metadata:\n version: 8\n\n\"a@npm:1.0.0\":\n resolution: \"a@npm:1.0.0\"\n# .socket/vendor/ mention\n"; + let tmp = project(Some(berry), Some(r#"{"name":"x"}"#)); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none()); + // No lockfile at all. + let tmp = project(None, Some(r#"{"name":"x"}"#)); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none()); + } + + #[test] + fn malformed_or_missing_package_json_still_warns() { + // Fail toward warning: an unreadable pin must not silently vouch + // for the project. + let tmp = project(Some(WIRED_V1), Some("{not json")); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_some()); + let tmp = project(Some(WIRED_V1), None); + assert!(yarn_classic_berry_migration_risk(tmp.path()).is_some()); + } +}