From 8e1374af510fcd5651eeee739e0d01ad0a615c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:22:58 +0000 Subject: [PATCH 1/5] Start fix for #1581 Assisted-by: Claude Code:claude-opus-5-5 From f9bffaae1d4c1c4ad2f243f3719929f52bdf2cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:31:24 +0000 Subject: [PATCH 2/5] Restore npm vendor dir metadata in repair `socket-patch repair` redownloaded a broken npm, pnpm, yarn or bun vendored tarball but never rewrote the `/.gitignore` and `.gitattributes` beside it; only vlt and JVM entries got them back. A project vendored before #1541 kept the old `* -text` attributes, so a repository `*.tgz filter=lfs` rule put the repaired tarball back into Git LFS, and a lost uuid dir came back without its `!*` .gitignore, so a stock `*.tgz` ignore rule left the tarball out of the commit. Both reported a successful repair while fresh clones kept failing. Repair now rewrites both files for every npm-family entry, on the healthy path and after a redownload, then asks git whether it would commit the artifact. A rule the uuid .gitignore cannot override (such as `.socket/`) fails with `vendor_artifact_gitignored`, the same refusal `vendor` gives. Fixes #1581 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/vendored_backend/repair.rs | 76 ++++++-- .../tests/in_process_vendor.rs | 148 ++++++++++++++ .../socket-patch-core/src/vendor/npm_dir.rs | 9 +- .../src/vendor/redownload.rs | 183 +++++++++++++++++- .../socket-patch-core/src/vendor/vlt_lock.rs | 20 +- docs/ecosystems.md | 3 +- 7 files changed, 397 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 3756e7abf..b30fb61fd 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -827,7 +827,7 @@ to **six flavors**. | eco / flavor | vendored artifact | committed wiring | consumption proof | |---|---|---|---| -| npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz`, plus `/.gitignore` (re-includes the tarball against the project's ignores, such as Node.gitignore's `*.tgz`) and `/.gitattributes` (`* -text -filter -ident -working-tree-encoding`, so neither EOL conversion nor a project filter such as a Git LFS `*.tgz` rule changes the committed bytes; a pre-#1541 `* -text` file is rewritten by the next vendored run); every tarball flavor below writes the same pair and refuses `vendor_artifact_gitignored` when git would still drop the tarball | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | +| npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz`, plus `/.gitignore` (re-includes the tarball against the project's ignores, such as Node.gitignore's `*.tgz`) and `/.gitattributes` (`* -text -filter -ident -working-tree-encoding`, so neither EOL conversion nor a project filter such as a Git LFS `*.tgz` rule changes the committed bytes; a pre-#1541 `* -text` file is rewritten by the next vendored run or `repair`); every tarball flavor below writes the same pair (and `repair` restores it) and refuses `vendor_artifact_gitignored` when git would still drop the tarball | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | | npm / yarn classic | (same tarball) | `yarn.lock` only: matching blocks get `resolved "file:./…#"` + `integrity` (both checksums recomputed; merged-key & `npm:`-alias blocks covered); a patch that rewrites the package's `package.json` gets the blocks' `dependencies:` / `optionalDependencies:` sub-maps recomputed, and is refused `vendor_dep_manifest_unlocked` before any write when a dependency it adds or a range it changes to has no lock block of its own (#591); a descriptor the wired blocks' sub-maps already name is upstream's and needs no block of its own (#1578) | `yarn install --frozen-lockfile --offline` (sha1 fragment + sha512 SRI both enforced; byte-stable lock) | | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel`. Both files keep their own layout — a CRLF lock (yarn's output on Windows) is spliced in CRLF, `package.json` is re-serialized with its BOM, indent, line ending and trailing-newline shape — so vendor + `--revert` round-trip byte-exactly; a lock or `package.json` MIXING CRLF and LF is refused before any write (`vendor_yarn_berry_mixed_line_endings`) | | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) **+** the same override in `pnpm-workspace.yaml` (pnpm >= 10.5 reads it there; created with a root-only `packages:` scaffold when absent, and given that scaffold too when the existing file has no keys at all, since pnpm 8.x–10.4 refuse a workspace file holding a key but no `packages` (#1096); revert removes it again). A project with no `pnpm-workspace.yaml` (or a keyless one) pinned to pnpm 9.0–10.4 (every pin, read as for the hosted trust config) gets no file: those read package.json, and a root-only workspace makes `pnpm add` fail there (#734); a later vendor on pnpm >= 10.5 adds it. Residual: an unpinned project with no install record still gets the scaffold, so on pnpm 9.0–10.4 it needs `pnpm add -w ` or a `packageManager` pin | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index ae99806d7..0e03f3f5f 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -492,24 +492,38 @@ impl VendoredBackend<'_> { continue; } } - // vlt's `/.gitignore` and `.gitattributes` are not - // part of the artifact: a missing or edited one is - // simply rewritten. - if entry.ecosystem == "npm" - && entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) - && !common.dry_run - { - if let Err(e) = - vendor::vlt_lock::restore_vlt_uuid_metadata(&entry, &common.cwd).await + // An npm-family `/.gitignore` and `.gitattributes` + // are not part of the artifact: a missing, edited or + // pre-#1541 one is simply rewritten (#1581). + if !common.dry_run { + match vendor::redownload::restore_npm_uuid_metadata(&entry, &common.cwd) + .await { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("cannot restore the vendored dir's .gitignore: {e}"), - ); - continue; + Ok(false) => {} + Ok(true) => { + if !npm_artifact_committable(common, env, purl, &entry).await { + continue; + } + env.record( + PatchEvent::new(PatchAction::Rebuilt, purl.clone()) + .with_details(serde_json::json!({ + "path": entry.artifact.path, + "uuidMetadataRestored": true, + "artifactRebuilt": false, + })), + ); + repaired += 1; + } + Err(e) => { + fail( + env, + common.json, + purl, + "vendor_artifact_unrepairable", + format!("cannot restore the vendored dir's .gitignore: {e}"), + ); + continue; + } } } // Likewise a JVM entry's derived metadata and owned @@ -734,6 +748,11 @@ impl VendoredBackend<'_> { continue; } } + if !npm_artifact_committable(common, env, &candidate.purl, &candidate.entry) + .await + { + continue; + } if !quiet { println!( "Redownloaded {} ({})", @@ -759,6 +778,29 @@ impl VendoredBackend<'_> { } } +/// After repair rewrote an npm-family uuid dir: `false` (and a +/// `vendor_artifact_gitignored` failure) when git would still leave the +/// artifact out of the next commit, which `vendor` refuses up front. A git +/// that cannot answer is only a warning. +async fn npm_artifact_committable( + common: &GlobalArgs, + env: &mut Envelope, + purl: &str, + entry: &VendorEntry, +) -> bool { + match vendor::redownload::npm_artifact_commit_check(entry, &common.cwd).await { + Ok(None) => true, + Ok(Some(warning)) => { + record_warning(env, purl, &warning, common); + true + } + Err((code, detail)) => { + fail(env, common.json, purl, code, detail); + false + } + } +} + fn workspace_copy_issue(health: &ArtifactHealth) -> bool { health.is_workspace_copy_issue() } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index bd3377f85..efaacabd2 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -5130,3 +5130,151 @@ async fn in_process_vendor_state_attests_manifest_less() { .expect("manifest-less VEX tail panicked"); }); } + +// ───────────────────────────────────────────────────────────────────── +// 13. repair restores the npm `/` metadata (#1581) +// ───────────────────────────────────────────────────────────────────── + +const UUID_GITIGNORE: &str = + "!*\n**/node_modules/*/node_modules/\n**/node_modules/@*/*/node_modules/\n"; +const UUID_GITATTRIBUTES: &str = "* -text -filter -ident -working-tree-encoding\n"; + +fn uuid_dir(fx: &NpmFixture) -> PathBuf { + fx.root().join(format!(".socket/vendor/npm/{UUID}")) +} + +/// `git init` the fixture; `false` when git is not installed. +fn git_init(root: &Path) -> bool { + if socket_patch_core::utils::process::resolve_tool("git").is_none() { + return false; + } + assert!(std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["init", "-q"]) + .status() + .unwrap() + .success()); + true +} + +fn git_stdout(root: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn repair_cli(cwd: &Path) -> (i32, Value) { + let (code, stdout, stderr) = run_cli( + cwd, + &["repair", "--json", "--cwd", cwd.to_str().unwrap()], + &[], + ); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("repair --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +/// #1581: a project vendored before #1541 keeps the legacy `* -text` +/// `/.gitattributes`, so a repository `*.tgz filter=lfs` rule still +/// applies to the tarball. `repair` must upgrade it, both when the +/// artifact is healthy and when it redownloads an LFS pointer, or the +/// repaired tarball goes back into LFS on the next commit. +#[tokio::test] +async fn repair_upgrades_a_legacy_uuid_gitattributes() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + let has_git = git_init(fx.root()); + std::fs::write( + fx.root().join(".gitattributes"), + "*.tgz filter=lfs diff=lfs merge=lfs -text\n", + ) + .unwrap(); + let attrs = uuid_dir(&fx).join(".gitattributes"); + let tarball = std::fs::read(fx.tgz_path()).unwrap(); + + // Healthy artifact. + std::fs::write(&attrs, "* -text\n").unwrap(); + let (code, env) = repair_cli(fx.root()); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(std::fs::read_to_string(&attrs).unwrap(), UUID_GITATTRIBUTES); + + // The tarball checked out as an LFS pointer (no smudge). + std::fs::write(&attrs, "* -text\n").unwrap(); + std::fs::write( + fx.tgz_path(), + "version https://git-lfs.github.com/spec/v1\noid sha256:00\nsize 1\n", + ) + .unwrap(); + let (code, env) = repair_cli(fx.root()); + assert_eq!(code, 0, "{env:#}"); + find_event(&env, "rebuilt", None); + assert_eq!(std::fs::read(fx.tgz_path()).unwrap(), tarball); + assert_eq!(std::fs::read_to_string(&attrs).unwrap(), UUID_GITATTRIBUTES); + if has_git { + assert_eq!( + git_stdout(fx.root(), &["check-attr", "filter", "--", &rel_tgz()]), + format!("{}: filter: unset\n", rel_tgz()), + "the repository's LFS rule must not reach the vendored tarball" + ); + } +} + +/// #1581 (second symptom): when the whole `/` dir is lost, `repair` +/// redownloaded only the tarball. Without its `/.gitignore` (`!*`) a +/// stock Node.gitignore `*.tgz` rule ignores it, so "commit it" committed +/// nothing and every fresh clone failed. +#[tokio::test] +async fn repair_of_a_lost_uuid_dir_restores_its_gitignore_and_gitattributes() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + let has_git = git_init(fx.root()); + std::fs::write(fx.root().join(".gitignore"), "node_modules/\n*.tgz\n").unwrap(); + let tarball = std::fs::read(fx.tgz_path()).unwrap(); + std::fs::remove_dir_all(uuid_dir(&fx)).unwrap(); + + let (code, env) = repair_cli(fx.root()); + assert_eq!(code, 0, "{env:#}"); + find_event(&env, "rebuilt", None); + assert_eq!(std::fs::read(fx.tgz_path()).unwrap(), tarball); + assert_eq!( + std::fs::read_to_string(uuid_dir(&fx).join(".gitignore")).unwrap(), + UUID_GITIGNORE + ); + assert_eq!( + std::fs::read_to_string(uuid_dir(&fx).join(".gitattributes")).unwrap(), + UUID_GITATTRIBUTES + ); + if has_git { + assert_eq!( + git_stdout(fx.root(), &["check-ignore", "--no-index", &rel_tgz()]), + "", + "git must commit the redownloaded tarball" + ); + } +} + +/// #1581: a rule the `/.gitignore` cannot override (`.socket/`) still +/// ignores the redownloaded tarball. `repair` must say so with the same +/// `vendor_artifact_gitignored` code `vendor` refuses with, not report a +/// repair that the next commit drops. +#[tokio::test] +async fn repair_of_a_lost_uuid_dir_under_an_ignored_socket_dir_fails() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + if !git_init(fx.root()) { + return; + } + std::fs::write(fx.root().join(".gitignore"), "node_modules/\n.socket/\n").unwrap(); + std::fs::remove_dir_all(uuid_dir(&fx)).unwrap(); + + let (code, env) = repair_cli(fx.root()); + assert_eq!(code, 1, "{env:#}"); + let failed = find_event(&env, "failed", Some("vendor_artifact_gitignored")); + assert_eq!(failed["purl"], PURL, "{env:#}"); +} diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 5de8a7d12..9adea1486 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -381,8 +381,10 @@ pub(crate) async fn node_modules_holds_only_links(rel_abs: &Path) -> bool { } /// Rewrite `/.gitignore` and `/.gitattributes` when absent or -/// different; neither is part of the artifact. -pub(crate) async fn restore_uuid_metadata(uuid_dir: &Path) -> std::io::Result<()> { +/// different; neither is part of the artifact. `true` when either was +/// rewritten. +pub(crate) async fn restore_uuid_metadata(uuid_dir: &Path) -> std::io::Result { + let mut rewritten = false; for (name, want) in [ (GITIGNORE, UUID_GITIGNORE), (GITATTRIBUTES, UUID_GITATTRIBUTES), @@ -391,9 +393,10 @@ pub(crate) async fn restore_uuid_metadata(uuid_dir: &Path) -> std::io::Result<() let current = crate::utils::fs::read_regular_to_bytes(&path).await.ok(); if current.as_deref() != Some(want.as_bytes()) { atomic_write_bytes(&path, want.as_bytes()).await?; + rewritten = true; } } - Ok(()) + Ok(rewritten) } // ── gitignore probe ────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/redownload.rs b/crates/socket-patch-core/src/vendor/redownload.rs index 7358a3c6a..a17852426 100644 --- a/crates/socket-patch-core/src/vendor/redownload.rs +++ b/crates/socket-patch-core/src/vendor/redownload.rs @@ -364,14 +364,67 @@ pub async fn restore( return Err(error.to_string()); } } - if super::verify::is_vlt_dir_entry(entry) { - super::vlt_lock::restore_vlt_uuid_metadata(entry, root) - .await - .map_err(|e| e.to_string())?; - } + restore_npm_uuid_metadata(entry, root) + .await + .map_err(|e| format!("cannot restore the vendored dir's .gitignore: {e}"))?; Ok(warnings) } +/// Rewrite an npm-family entry's `/.gitignore` and +/// `/.gitattributes` when absent or changed: neither is part of the +/// artifact, so repairing them is no rebuild. Every npm flavor (npm, pnpm, +/// yarn, bun, vlt) commits its artifact under that uuid dir, and only these +/// two files keep a repository's `*.tgz` ignore or `filter=lfs` rule off it +/// (#1581). `Ok(true)` when either was rewritten; a no-op for other +/// ecosystems. +pub async fn restore_npm_uuid_metadata(entry: &VendorEntry, root: &Path) -> std::io::Result { + if entry.ecosystem != "npm" { + return Ok(false); + } + let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { + return Err(std::io::Error::other(format!( + "`{}` is not a canonical patch uuid", + entry.uuid + ))); + }; + super::npm_dir::restore_uuid_metadata(&root.join(uuid_dir)).await +} + +/// Would git commit an npm-family entry's artifact and its uuid-dir +/// metadata? Asked after repair rewrote them, since a rule the +/// `/.gitignore` cannot override (`.socket/`) still drops them from +/// the next commit. `Err((code, detail))` is the `vendor_artifact_gitignored` +/// refusal `vendor` gives; `Ok(Some(warning))` means git could not answer. +pub async fn npm_artifact_commit_check( + entry: &VendorEntry, + root: &Path, +) -> Result, (&'static str, String)> { + if entry.ecosystem != "npm" { + return Ok(None); + } + let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { + return Ok(None); + }; + let mut paths = vec![ + format!("{uuid_dir}/.gitignore"), + format!("{uuid_dir}/.gitattributes"), + ]; + if super::verify::artifact_is_file_shaped(&entry.artifact.path) { + paths.insert(0, entry.artifact.path.clone()); + } + match super::npm_dir::gitignore_probe(root, &paths).await { + Ok(Some(rules)) => Err(( + super::npm_dir::GITIGNORED, + super::npm_dir::gitignored_detail(&paths[0], &rules), + )), + Ok(None) => Ok(None), + Err(why) => Ok(Some(VendorWarning::new( + super::npm_dir::GITIGNORE_UNCHECKED, + super::npm_dir::gitignore_unchecked_detail(&paths[0], &why), + ))), + } +} + /// Stage the entry's whole recorded JVM tree under `stage_root` from the /// verified jar and freshly downloaded, checksum-verified upstream files, /// re-planned through the project's builds (both halves of a mixed root). @@ -706,6 +759,126 @@ mod tests { assert_eq!(tokio::fs::read(path).await.unwrap(), bytes); } + /// #1581: a redownloaded npm tarball gets its `/.gitignore` and + /// `.gitattributes` back, or a `*.tgz` ignore or `filter=lfs` rule + /// drops the repaired bytes from the next commit. + #[tokio::test] + async fn npm_redownload_restores_the_uuid_metadata() { + let root = tempfile::tempdir().unwrap(); + let bytes = tgz("package", b"original unpatched member"); + let entry = entry(&bytes); + let server = wiremock::MockServer::start().await; + mount_granted(&server, UUID, "example-1.0.0.tgz", &bytes).await; + let cfg = service_cfg(&server.uri(), VendorSource::Service, false); + restore(root.path(), &entry, &record(), &cfg).await.unwrap(); + let uuid_dir = root.path().join(format!(".socket/vendor/npm/{UUID}")); + assert_eq!( + tokio::fs::read_to_string(uuid_dir.join(".gitignore")) + .await + .unwrap(), + super::super::npm_dir::UUID_GITIGNORE + ); + assert_eq!( + tokio::fs::read_to_string(uuid_dir.join(".gitattributes")) + .await + .unwrap(), + super::super::npm_dir::UUID_GITATTRIBUTES + ); + } + + /// #1581: the pre-#1541 `* -text` file is upgraded, a current one is + /// left alone, and other ecosystems are not touched. + #[tokio::test] + async fn npm_uuid_metadata_upgrades_a_legacy_gitattributes() { + let root = tempfile::tempdir().unwrap(); + let mut entry = entry(b"tgz"); + let uuid_dir = root.path().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + tokio::fs::write( + uuid_dir.join(".gitignore"), + super::super::npm_dir::UUID_GITIGNORE, + ) + .await + .unwrap(); + tokio::fs::write( + uuid_dir.join(".gitattributes"), + super::super::npm_dir::LEGACY_UUID_GITATTRIBUTES, + ) + .await + .unwrap(); + assert!(restore_npm_uuid_metadata(&entry, root.path()) + .await + .unwrap()); + assert_eq!( + tokio::fs::read_to_string(uuid_dir.join(".gitattributes")) + .await + .unwrap(), + super::super::npm_dir::UUID_GITATTRIBUTES + ); + assert!(!restore_npm_uuid_metadata(&entry, root.path()) + .await + .unwrap()); + + entry.ecosystem = "cargo".into(); + tokio::fs::remove_dir_all(&uuid_dir).await.unwrap(); + assert!(!restore_npm_uuid_metadata(&entry, root.path()) + .await + .unwrap()); + assert!(!uuid_dir.exists()); + } + + /// #1581: after repair, a `.socket/` rule the `/.gitignore` + /// cannot override is the `vendor_artifact_gitignored` refusal, while a + /// file rule such as `*.tgz` is overridden. + #[tokio::test] + async fn npm_commit_check_refuses_only_an_unoverridable_rule() { + if crate::utils::process::resolve_tool("git").is_none() { + return; + } + let root = tempfile::tempdir().unwrap(); + let entry = entry(b"tgz"); + assert!(std::process::Command::new("git") + .arg("-C") + .arg(root.path()) + .args(["init", "-q"]) + .status() + .unwrap() + .success()); + let uuid_dir = root.path().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + tokio::fs::write(root.path().join(&entry.artifact.path), b"tgz") + .await + .unwrap(); + restore_npm_uuid_metadata(&entry, root.path()) + .await + .unwrap(); + + tokio::fs::write( + root.path().join(".gitignore"), + "*.tgz +", + ) + .await + .unwrap(); + assert!(matches!( + npm_artifact_commit_check(&entry, root.path()).await, + Ok(None) + )); + + tokio::fs::write( + root.path().join(".gitignore"), + ".socket/ +", + ) + .await + .unwrap(); + let (code, detail) = npm_artifact_commit_check(&entry, root.path()) + .await + .unwrap_err(); + assert_eq!(code, "vendor_artifact_gitignored"); + assert!(detail.contains(&entry.artifact.path), "{detail}"); + } + /// #664: repair never downloads into a linked eco dir, whose target /// may be another project's vendor store. #[cfg(unix)] diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index c0f39e713..3c1dc86e5 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -1443,22 +1443,6 @@ async fn preflight_package( Ok(()) } -/// Rewrite a vlt entry's `/.gitignore` and `/.gitattributes` -/// when absent or changed (health: neither is part of the -/// artifact, so repairing them is no rebuild). -pub async fn restore_vlt_uuid_metadata( - entry: &VendorEntry, - project_root: &Path, -) -> std::io::Result<()> { - let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { - return Err(std::io::Error::other(format!( - "`{}` is not a canonical patch uuid", - entry.uuid - ))); - }; - super::npm_dir::restore_uuid_metadata(&project_root.join(uuid_dir)).await -} - /// The importer package.json files of the project's canonical /// `vlt-lock.json`, project-relative: the root one plus every workspace /// importer its edges name. Just the root one when the lock is missing or @@ -3167,7 +3151,9 @@ mod tests { .root .join(format!(".socket/vendor/npm/{UUID}/.gitignore")); tokio::fs::remove_file(&gitignore).await.unwrap(); - restore_vlt_uuid_metadata(&entry, &fx.root).await.unwrap(); + crate::vendor::redownload::restore_npm_uuid_metadata(&entry, &fx.root) + .await + .unwrap(); assert_eq!( tokio::fs::read_to_string(&gitignore).await.unwrap(), super::super::npm_dir::UUID_GITIGNORE diff --git a/docs/ecosystems.md b/docs/ecosystems.md index deda930d5..e0e91d2b2 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -686,7 +686,8 @@ inside it, and a `.gitattributes` (`* -text -filter -ident -working-tree-encodin turns off EOL conversion and overrides the project's clean/smudge filters, such as a Git LFS `*.tgz` rule, so an `autocrlf` or LFS-less checkout stays byte-exact (#1541). A tarball an older release already committed as an LFS pointer stays one until it is re-added: -after the next vendored run, `git add --renormalize .socket/vendor/npm` and commit. The lock's node becomes a `file` node, the importer edges and the +after the next vendored run or `socket-patch repair`, `git add --renormalize +.socket/vendor/npm` and commit. The lock's node becomes a `file` node, the importer edges and the importers' `package.json` specs move to the `file:` path, and every moved entry is placed where vlt's own serializer puts it, so `vlt ci` keeps the lock byte-identical. Transitive targets are refused (`vendor_vlt_transitive_unsupported`: vlt silently reverts From 4221b275ae324ce7100e700353078bffffee90ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:09:39 +0000 Subject: [PATCH 3/5] Keep metadata-only repair a non-rebuild A rewritten npm uuid .gitignore or .gitattributes is not an artifact rebuild, as the vlt repair contract already says, so repair reports no rebuilt event for it. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/vendored_backend/repair.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 0e03f3f5f..21dd773bc 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -504,15 +504,6 @@ impl VendoredBackend<'_> { if !npm_artifact_committable(common, env, purl, &entry).await { continue; } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()) - .with_details(serde_json::json!({ - "path": entry.artifact.path, - "uuidMetadataRestored": true, - "artifactRebuilt": false, - })), - ); - repaired += 1; } Err(e) => { fail( From 1d7c6ade4ecd42f179524ff81072bd14a2cde8cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:18:54 +0000 Subject: [PATCH 4/5] Check repaired npm vendor dirs on every run Repair now asks git whether it would commit a healthy npm-family artifact on every run, not only when it rewrote the uuid metadata, so a .socket/ ignore rule keeps failing until it is fixed. It also restores the metadata before repairing bun workspace mirrors, skips a vendor dir reached through a symlink, and probes a vlt package dir as a directory. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/vendored_backend/repair.rs | 48 +++++++++---------- .../tests/in_process_vendor.rs | 6 +++ .../src/vendor/redownload.rs | 24 +++++++--- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 21dd773bc..15163d100 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -419,6 +419,29 @@ impl VendoredBackend<'_> { entry }; let health = check_vendored_artifact(&common.cwd, &entry, &record).await; + // An npm-family `/.gitignore` and `.gitattributes` are not + // part of the artifact: a missing, edited or pre-#1541 one is + // simply rewritten, and git must still commit the artifact with + // them (#1581). A broken artifact gets both after its redownload. + if !common.dry_run + && (health == ArtifactHealth::Healthy || workspace_copy_issue(&health)) + { + if let Err(e) = + vendor::redownload::restore_npm_uuid_metadata(&entry, &common.cwd).await + { + fail( + env, + common.json, + purl, + "vendor_artifact_unrepairable", + format!("cannot restore the vendored dir's git metadata: {e}"), + ); + continue; + } + if !npm_artifact_committable(common, env, purl, &entry).await { + continue; + } + } if health == ArtifactHealth::Healthy || workspace_copy_issue(&health) { let mut healed = entry.clone(); match repair_workspace_copies(&common.cwd, &mut healed, common.dry_run).await { @@ -492,31 +515,6 @@ impl VendoredBackend<'_> { continue; } } - // An npm-family `/.gitignore` and `.gitattributes` - // are not part of the artifact: a missing, edited or - // pre-#1541 one is simply rewritten (#1581). - if !common.dry_run { - match vendor::redownload::restore_npm_uuid_metadata(&entry, &common.cwd) - .await - { - Ok(false) => {} - Ok(true) => { - if !npm_artifact_committable(common, env, purl, &entry).await { - continue; - } - } - Err(e) => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("cannot restore the vendored dir's .gitignore: {e}"), - ); - continue; - } - } - } // Likewise a JVM entry's derived metadata and owned // `.gitattributes`: rewritten when missing, offline. if vendor::jvm::apply::is_jvm_entry(&entry) && !common.dry_run { diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index efaacabd2..0e82d87a5 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -5277,4 +5277,10 @@ async fn repair_of_a_lost_uuid_dir_under_an_ignored_socket_dir_fails() { assert_eq!(code, 1, "{env:#}"); let failed = find_event(&env, "failed", Some("vendor_artifact_gitignored")); assert_eq!(failed["purl"], PURL, "{env:#}"); + + // The artifact is now healthy on disk, but git still drops it: the + // next run must not go green with nothing fixed. + let (code, env) = repair_cli(fx.root()); + assert_eq!(code, 1, "{env:#}"); + find_event(&env, "failed", Some("vendor_artifact_gitignored")); } diff --git a/crates/socket-patch-core/src/vendor/redownload.rs b/crates/socket-patch-core/src/vendor/redownload.rs index a17852426..1c1a502ff 100644 --- a/crates/socket-patch-core/src/vendor/redownload.rs +++ b/crates/socket-patch-core/src/vendor/redownload.rs @@ -366,7 +366,7 @@ pub async fn restore( } restore_npm_uuid_metadata(entry, root) .await - .map_err(|e| format!("cannot restore the vendored dir's .gitignore: {e}"))?; + .map_err(|e| format!("cannot restore the vendored dir's git metadata: {e}"))?; Ok(warnings) } @@ -376,9 +376,12 @@ pub async fn restore( /// yarn, bun, vlt) commits its artifact under that uuid dir, and only these /// two files keep a repository's `*.tgz` ignore or `filter=lfs` rule off it /// (#1581). `Ok(true)` when either was rewritten; a no-op for other -/// ecosystems. +/// ecosystems, and for a vendor dir reached through a symlink, which may +/// be another project's store (see `path::vendor_dir_symlink`). pub async fn restore_npm_uuid_metadata(entry: &VendorEntry, root: &Path) -> std::io::Result { - if entry.ecosystem != "npm" { + if entry.ecosystem != "npm" + || super::path::vendor_dir_symlink(root, "npm", Some(&entry.uuid)).is_some() + { return Ok(false); } let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { @@ -405,13 +408,20 @@ pub async fn npm_artifact_commit_check( let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { return Ok(None); }; - let mut paths = vec![ + // A directory artifact (vlt) is probed as a directory, so a rule that + // ignores it whole is seen. + let artifact = if super::verify::is_vlt_dir_entry(entry) + || !super::verify::artifact_is_file_shaped(&entry.artifact.path) + { + format!("{}/", entry.artifact.path.trim_end_matches('/')) + } else { + entry.artifact.path.clone() + }; + let paths = [ + artifact, format!("{uuid_dir}/.gitignore"), format!("{uuid_dir}/.gitattributes"), ]; - if super::verify::artifact_is_file_shaped(&entry.artifact.path) { - paths.insert(0, entry.artifact.path.clone()); - } match super::npm_dir::gitignore_probe(root, &paths).await { Ok(Some(rules)) => Err(( super::npm_dir::GITIGNORED, From 4de77a31d8b696620516e2f1edabb7d81c243041 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:10:20 +0000 Subject: [PATCH 5/5] Skip the commit check for a symlinked npm vendor dir git check-ignore cannot answer past a symlinked .socket/vendor/npm, so every repair run on that layout warned vendor_artifact_gitignore_unchecked. vendor already refuses the layout, and repair already skips its metadata rewrite there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WMCn4ztc3DCiDDSBZDtjHd --- .../src/vendor/redownload.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/redownload.rs b/crates/socket-patch-core/src/vendor/redownload.rs index dd6acd775..e864b2564 100644 --- a/crates/socket-patch-core/src/vendor/redownload.rs +++ b/crates/socket-patch-core/src/vendor/redownload.rs @@ -411,7 +411,11 @@ pub async fn npm_artifact_commit_check( entry: &VendorEntry, root: &Path, ) -> Result, (&'static str, String)> { - if entry.ecosystem != "npm" { + // git cannot answer past a symlinked vendor dir, which `vendor` + // refuses anyway (`vendor_dir_symlink_unsupported`). + if entry.ecosystem != "npm" + || super::path::vendor_dir_symlink(root, "npm", Some(&entry.uuid)).is_some() + { return Ok(None); } let Some(uuid_dir) = super::path::vendor_uuid_dir_rel("npm", &entry.uuid) else { @@ -898,6 +902,42 @@ mod tests { assert!(detail.contains(&entry.artifact.path), "{detail}"); } + /// #664: through a linked eco dir repair neither writes the other + /// store's uuid metadata nor asks git, which cannot answer past a link. + #[cfg(unix)] + #[tokio::test] + async fn linked_vendor_dir_skips_uuid_metadata_and_commit_check() { + if crate::utils::process::resolve_tool("git").is_none() { + return; + } + let root = tempfile::tempdir().unwrap(); + assert!(std::process::Command::new("git") + .arg("-C") + .arg(root.path()) + .args(["init", "-q"]) + .status() + .unwrap() + .success()); + let shared = tempfile::tempdir().unwrap(); + let entry = entry(b"tgz"); + tokio::fs::create_dir_all(root.path().join(".socket/vendor")) + .await + .unwrap(); + std::os::unix::fs::symlink(shared.path(), root.path().join(".socket/vendor/npm")).unwrap(); + tokio::fs::create_dir_all(shared.path().join(UUID)) + .await + .unwrap(); + + assert!(!restore_npm_uuid_metadata(&entry, root.path()) + .await + .unwrap()); + assert!(!shared.path().join(UUID).join(".gitignore").exists()); + assert!(matches!( + npm_artifact_commit_check(&entry, root.path()).await, + Ok(None) + )); + } + /// #664: repair never downloads into a linked eco dir, whose target /// may be another project's vendor store. #[cfg(unix)]