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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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))
}

Expand Down
28 changes: 27 additions & 1 deletion crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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());
}
Expand Down
21 changes: 21 additions & 0 deletions crates/socket-patch-cli/src/json_envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SidecarRecord>,
/// 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<RunWarning>,
/// Present only when `--vex <path>` 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
Expand Down Expand Up @@ -108,6 +117,7 @@ impl Envelope {
summary: Summary::default(),
error: None,
sidecars: Vec::new(),
warnings: Vec::new(),
vex: None,
}
}
Expand Down Expand Up @@ -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.
// ---------------------------------------------------------------------------
Expand Down
52 changes: 52 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions crates/socket-patch-core/src/patch/vendor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<VendorWarning> {
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::<serde_json::Value>(&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::<String>()
});
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
Expand Down Expand Up @@ -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());
}
}