Skip to content
Merged
1 change: 1 addition & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. |
| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. |
| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. |
| `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket/vendor`, `.socket/vendor/<eco>` or the patch's `<uuid>` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. |
| `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. |
| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. |
| `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. |
Expand Down
110 changes: 110 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ fn refusal_is_benign(code: &str) -> bool {
matches!(code, "vendor_unsupported_ecosystem" | "already_vendored")
}

/// The `vendor_dir_symlink_unsupported` detail when `purl`'s vendor dir
/// (`.socket/vendor`, its ecosystem dir, or the `uuid` unit) is a link.
fn linked_vendor_dir_refusal(project_root: &Path, purl: &str, uuid: &str) -> Option<String> {
let eco = ecosystem_dir_for_purl(purl)?;
vendor::path::vendor_dir_symlink(project_root, eco, Some(uuid))
.map(|link| vendor::path::vendor_dir_symlink_detail(&link))
}

/// Dispatch one purl to its ecosystem backend. `pkg_path` is the crawler's
/// installed location (site-packages root for pypi, the package dir
/// otherwise), or a fetched artifact the backend materialises only if it
Expand All @@ -133,6 +141,16 @@ pub(crate) async fn dispatch_vendor_one(
installed_sites: &vendor::pypi::InstalledSiteListings,
) -> Option<VendorOutcome> {
let eco = ecosystem_dir_for_purl(purl)?;
// Before any backend write: a linked vendor dir is never ours, and the
// unit would land in (and a later revert delete from) its target. The
// vendor loop refuses it earlier still, before a hosted takeover; this
// is the backstop for every other caller.
if let Some(detail) = linked_vendor_dir_refusal(project_root, purl, &record.uuid) {
return Some(VendorOutcome::Refused {
code: "vendor_dir_symlink_unsupported",
detail,
});
}

const SERVICE_ECOSYSTEMS: &[&str] = &[
"npm", "pypi", "cargo", "golang", "composer", "gem", "nuget", "maven",
Expand Down Expand Up @@ -238,6 +256,13 @@ pub(crate) async fn dispatch_revert_one_opts(
project_root: &Path,
opts: RevertOpts,
) -> RevertOutcome {
// Before any lock edit or delete: the unit removal would reach through
// a linked vendor dir into another project's artifacts (#664).
if let Some(link) =
vendor::path::vendor_dir_symlink(project_root, &entry.ecosystem, Some(&entry.uuid))
{
return RevertOutcome::failed(vendor::path::vendor_dir_symlink_detail(&link));
}
match entry.ecosystem.as_str() {
"npm" => vendor::npm_flavor::revert_npm_any_opts(entry, project_root, opts).await,
"pypi" => vendor::pypi::revert_pypi_opts(entry, project_root, opts).await,
Expand Down Expand Up @@ -1899,6 +1924,10 @@ async fn plan_service_downloads(
if bun_refusal.is_some_and(|r| r.applies_to(candidate)) {
continue;
}
// The loop refuses a linked vendor dir; no grant on its behalf.
if linked_vendor_dir_refusal(cwd, candidate, &record.uuid).is_some() {
continue;
}
if takeover_blocked(candidate) {
continue;
}
Expand Down Expand Up @@ -2370,6 +2399,18 @@ pub(crate) async fn vendor_records_reusing(
report_vendor_failure(common, candidate, &refusal.detail);
continue;
}
// A linked vendor dir (#664) is refused before the takeover
// below can restore a live hosted pin's upstream entry: the
// refusal must leave the hosted patch wired.
if let Some(detail) = linked_vendor_dir_refusal(&common.cwd, candidate, &record.uuid) {
has_errors = true;
env.record(
PatchEvent::new(PatchAction::Failed, candidate.clone())
.with_error("vendor_dir_symlink_unsupported", detail.clone()),
);
report_vendor_failure(common, candidate, &detail);
continue;
}

// Cross-mode takeover: vendoring over a LIVE hosted pin must
// first restore the upstream registry entry (v5 keeps no hosted
Expand Down Expand Up @@ -3735,6 +3776,75 @@ mod plan_gate_tests {
order, and none for the package its backend refuses first"
);
}

/// A record whose patch dir is a link (#664) is refused by the loop
/// before dispatch, so the plan leaves it out: a prefetch running ahead
/// of the loop must never stage or extract an archive through the link.
#[cfg(unix)]
#[tokio::test]
async fn the_plan_leaves_out_a_package_whose_vendor_dir_is_linked() {
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("project");
std::fs::create_dir_all(root).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require":{}}"#).unwrap();
let names = ["psr/cache", "psr/container", "psr/log"];
let locked: Vec<serde_json::Value> = names
.iter()
.map(|name| {
serde_json::json!({
"name": name, "version": "1.0.0",
"dist": {"type": "zip", "url": format!("https://example.invalid/{name}.zip"),
"reference": "abc", "shasum": ""},
"type": "library"
})
})
.collect();
std::fs::write(
root.join("composer.lock"),
serde_json::to_vec_pretty(&serde_json::json!({
"content-hash": "x", "packages": locked, "packages-dev": []
}))
.unwrap(),
)
.unwrap();
let mut all_packages: Vec<(String, StagedSource)> = Vec::new();
let mut records: HashMap<String, PatchRecord> = HashMap::new();
for (name, uuid) in names.iter().zip([UUID_A, UUID_B, UUID_C]) {
let purl = format!("pkg:composer/{name}@1.0.0");
let dir = root.join("vendor").join(name);
std::fs::create_dir_all(&dir).unwrap();
all_packages.push((purl.clone(), StagedSource::Installed(dir)));
records.insert(purl, record(uuid));
}
let other = tmp.path().join("other-project-unit");
std::fs::create_dir_all(&other).unwrap();
let eco_dir = root.join(".socket/vendor/composer");
std::fs::create_dir_all(&eco_dir).unwrap();
std::os::unix::fs::symlink(&other, eco_dir.join(UUID_B)).unwrap();

let planned = plan_service_downloads(
root,
false,
&all_packages,
&HashMap::new(),
&records,
&VendorState::default(),
&HashSet::new(),
None,
&|_| false,
(
&tokio::sync::OnceCell::new(),
&vendor::pypi::InstalledSiteListings::default(),
),
)
.await;
let uuids: Vec<&str> = planned.iter().map(|d| d.uuid.as_str()).collect();
assert_eq!(
uuids,
vec![UUID_A, UUID_C],
"the linked package is never planned"
);
}
}

#[cfg(test)]
Expand Down
115 changes: 115 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 @@ -858,3 +858,118 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() {
.run(&fresh);
drop(server);
}

/// #664: two yarn classic projects whose `.socket/vendor/npm` links point
/// at one shared store, both vendored with the same patch. `rollback` in
/// project A used to delete the shared uuid dir through the link and
/// report success, so project B's frozen offline install then failed
/// ("Tarball is not in network and can not be located in cache"). The
/// revert now refuses the linked dir before touching anything: A's
/// rollback fails naming the link, and B still installs the patched
/// bytes from the shared store with an empty yarn cache.
#[cfg(unix)]
#[test]
fn yarn_classic_rollback_keeps_a_shared_vendor_store_intact() {
if !require_yarn_classic("e2e_vendor_yarn_classic_build", |c| {
cache_env::isolate(c);
}) {
return;
}
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("yarn-cache");
let shared = tmp.path().join("mono/shared");
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
let mut projects = Vec::new();
for name in ["a", "b"] {
let proj = tmp.path().join("mono").join(name);
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("package.json"),
format!(
r#"{{"name":"{name}","version":"1.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"#
),
)
.unwrap();
let install = corepack(
&proj,
&yarn_classic(),
&["install", "--no-progress"],
&[("YARN_CACHE_FOLDER", cache.to_str().unwrap())],
);
if !install.status.success() {
skip!(
"fixture `yarn install` failed (registry unreachable?):\n{}",
String::from_utf8_lossy(&install.stderr)
);
return;
}
let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap();
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
stage_patch(&proj, &purl, "package/index.js", &orig, &patched);
let cwd = proj.to_str().unwrap().to_string();
let (code, stdout, stderr) =
run_socket(&proj, &["vendor", "--json", "--offline", "--cwd", &cwd]);
assert_eq!(code, 0, "vendor {name}:\n{stdout}\n{stderr}");

// Share the store: the first project's unit moves into it, and
// both projects' eco dirs become links to it.
let npm = proj.join(".socket/vendor/npm");
if !shared.exists() {
std::fs::rename(&npm, &shared).unwrap();
} else {
std::fs::remove_dir_all(&npm).unwrap();
}
std::os::unix::fs::symlink(&shared, &npm).unwrap();
projects.push(proj);
}
let shared_tgz = shared.join(UUID).join(format!("{DEP}-{DEP_VERSION}.tgz"));
assert!(
shared_tgz.is_file(),
"sanity: the shared store holds the unit"
);

let a = &projects[0];
let a_lock = std::fs::read(a.join("yarn.lock")).unwrap();
let cwd = a.to_str().unwrap().to_string();
let (code, stdout, stderr) = run_socket(
a,
&["rollback", "--json", "--yes", "--offline", "--cwd", &cwd],
);
assert_eq!(
code, 1,
"A's rollback must fail, not delete:\n{stdout}\n{stderr}"
);
assert!(
stdout.contains(".socket/vendor/npm` is a symlink"),
"the failure names the link:\n{stdout}"
);
assert!(shared_tgz.is_file(), "B's committed tarball survives");
assert_eq!(std::fs::read(a.join("yarn.lock")).unwrap(), a_lock);

// B's fresh, frozen, offline install still resolves the shared tarball.
let b = &projects[1];
std::fs::remove_dir_all(b.join("node_modules")).unwrap();
let empty_cache = tmp.path().join("empty-cache");
std::fs::create_dir_all(&empty_cache).unwrap();
let install = corepack(
b,
&yarn_classic(),
&["install", "--frozen-lockfile", "--offline", "--no-progress"],
&[("YARN_CACHE_FOLDER", empty_cache.to_str().unwrap())],
);
assert!(
install.status.success(),
"B's frozen offline install:\n{}",
String::from_utf8_lossy(&install.stderr)
);
// yarn < 1.7 installs nothing for a `file:` tarball entry (see
// `installs_file_tarballs`); the frozen install succeeding is the proof
// there.
if yarn_classic_vex::installs_file_tarballs(&yarn_classic_vex::yarn_classic_version()) {
let installed = std::fs::read(b.join("node_modules").join(DEP).join("index.js")).unwrap();
assert!(
installed.starts_with(MARKER.as_bytes()),
"B installs the patched bytes"
);
}
}
Loading
Loading