diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1bab28023..508840608 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -831,7 +831,7 @@ to **six flavors**. | 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 | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` redownloads on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` redownloads through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | -| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record and its own dependency edges dropped and the hoisting trees re-derived with Bun's hoister — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where that is not exact (the records' dependencies resolve to different packages, or the lock's hoisting is not one the codec reproduces) both are rewired with `vendor_bun_lockb_duplicate_records`. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | +| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record and its own dependency edges dropped and the hoisting trees re-derived with Bun's hoister — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where that is not exact (the records' dependencies resolve to different packages, or the lock's hoisting is not one the codec reproduces) both are rewired with `vendor_bun_lockb_duplicate_records`. In a workspace the tarball is also written to each member's `.socket/vendor/npm//` (Bun 0.5.9–1.3 resolve a local tarball relative to the declaring member), with the same `/.gitignore` and `/.gitattributes`; a member uuid dir git ignores as a directory (a member's `.socket/` rule) refuses `vendor_artifact_gitignored` before any write (#1116). Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index e235315b5..a50d1ea6d 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -163,6 +163,7 @@ impl NpmLockBackend for BunBinaryBackend { bundled, user_tarballs, } = preflight_package(&project, root, coords, &leaf)?; + refuse_ignored_mirrors(root, &mirrors).await?; warnings.extend(super::bun_lock::default_trust_warning( project.manifest.as_deref(), None, @@ -280,18 +281,27 @@ impl NpmLockBackend for BunBinaryBackend { original: None, new: Some(serde_json::Value::String(staged.packed.sha256_hex.clone())), }); - if before.as_deref() == Some(artifact.as_slice()) { - continue; - } - if let Err(e) = tokio::fs::create_dir_all(path.parent().expect("mirror parent")).await { - undo_mirrors(&mirror_backups).await; - return Err(format!("cannot create workspace vendor directory: {e}")); + if before.as_deref() != Some(artifact.as_slice()) { + if let Err(e) = + tokio::fs::create_dir_all(path.parent().expect("mirror parent")).await + { + undo_mirrors(&mirror_backups).await; + return Err(format!("cannot create workspace vendor directory: {e}")); + } + if let Err(e) = atomic_write_bytes_preserving_mode(&path, &artifact).await { + undo_mirrors(&mirror_backups).await; + return Err(format!("cannot write workspace tarball {rel}: {e}")); + } + mirror_backups.push((path, before)); } - if let Err(e) = atomic_write_bytes_preserving_mode(&path, &artifact).await { + if let Err(e) = restore_mirror_metadata(root, rel).await { undo_mirrors(&mirror_backups).await; - return Err(format!("cannot write workspace tarball {rel}: {e}")); + return Err(e); } - mirror_backups.push((path, before)); + } + if let Err(e) = probe_mirrors(root, &mirrors, warnings).await { + undo_mirrors(&mirror_backups).await; + return Err(e); } if !lock_changed && mirror_backups.is_empty() { return Ok(None); @@ -620,7 +630,8 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - Ok(Some(path)) } Ok(_) => Err("workspace tarball has changed; left alone".into()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + // Gone already: its `/.gitignore` still goes. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Some(path)), Err(e) => Err(format!("cannot read workspace tarball: {e}")), } })(); @@ -921,9 +932,178 @@ pub(super) fn validate_mirror_path(root: &Path, rel: &str) -> Result/.socket/vendor/npm/`, the directory a workspace mirror +/// path (as [`validate_mirror_path`] accepts it) lives in. +fn mirror_uuid_dir_rel(rel: &str) -> Option { + let (workspace, artifact) = rel.rsplit_once("/.socket/vendor/npm/")?; + let (uuid, _) = artifact.split_once('/')?; + Some(format!("{workspace}/.socket/vendor/npm/{uuid}")) +} + +/// The member uuid dirs (`/.socket/vendor/npm/`) a +/// `bun.lockb` workspace mirrors `uuid`'s tarball into, as the backend's +/// pre-flight lists them; empty when no readable binary lock drives the +/// installs. +pub(crate) fn workspace_mirror_dirs(root: &Path, uuid: &str) -> Vec { + if super::lock_inventory::bun_text_lock_drives(&super::lock_inventory::ProjectView::Disk(root)) + { + return Vec::new(); + } + read_regular_to_bytes_sync(&root.join(LOCK)) + .map_err(|e| e.to_string()) + .and_then(|bytes| BunLockb::parse(&bytes)) + .and_then(|lock| lock.workspace_paths()) + .map(|paths| { + paths + .iter() + .map(|ws| format!("{}/.socket/vendor/npm/{uuid}", ws.trim_start_matches("./"))) + .collect() + }) + .unwrap_or_default() +} + +/// Refuse `vendor_artifact_gitignored` before anything is written when git +/// ignores a workspace mirror's uuid dir as a directory (a member's +/// `.socket/` rule, a root `packages/*/.socket/`): no `.gitignore` inside +/// it can re-include the mirror, so it would never reach the commit and a +/// fresh checkout's older Bun cannot install from it (#1116). File rules +/// such as `*.tgz` are left to the mirror's own `/.gitignore`. +async fn refuse_ignored_mirrors( + root: &Path, + mirrors: &[(String, String)], +) -> Result<(), Box> { + let dirs: Vec = mirrors + .iter() + .filter_map(|(_, rel)| mirror_uuid_dir_rel(rel)) + .map(|dir| format!("{dir}/")) + .collect(); + if dirs.is_empty() { + return Ok(()); + } + match super::npm_dir::gitignored(root, &dirs).await { + Some(rules) => Err(Box::new(refused( + super::npm_dir::GITIGNORED, + super::npm_dir::gitignored_detail(&first_ignored(&rules, &dirs[0]), &rules), + ))), + None => Ok(()), + } +} + +/// Write the root artifact's `/.gitignore` and `/.gitattributes` +/// (#837) beside the workspace mirror `rel`; returns that uuid dir. +pub(super) async fn restore_mirror_metadata(root: &Path, rel: &str) -> Result { + let dir_rel = mirror_uuid_dir_rel(rel).ok_or("invalid workspace tarball path")?; + super::npm_dir::restore_uuid_metadata(&root.join(&dir_rel)) + .await + .map_err(|e| format!("cannot write {dir_rel}/.gitignore: {e}"))?; + Ok(dir_rel) +} + +/// Ask git, in one probe, whether it would commit every written workspace +/// mirror and the `/.gitignore` and `/.gitattributes` beside +/// it ([`restore_mirror_metadata`], which overrides file rules such as +/// Node.gitignore's `*.tgz`, #1116). Still ignored is an error (the caller +/// unwinds); git failing to answer is only a warning. +async fn probe_mirrors( + root: &Path, + mirrors: &[(String, String)], + warnings: &mut Vec, +) -> Result<(), String> { + let mut probe = Vec::new(); + for (_, rel) in mirrors { + let dir_rel = mirror_uuid_dir_rel(rel).ok_or("invalid workspace tarball path")?; + probe.push(rel.clone()); + probe.push(format!("{dir_rel}/.gitignore")); + probe.push(format!("{dir_rel}/.gitattributes")); + } + if probe.is_empty() { + return Ok(()); + } + match super::npm_dir::gitignore_probe(root, &probe).await { + Ok(Some(rules)) => Err(format!( + "{}: {}", + super::npm_dir::GITIGNORED, + super::npm_dir::gitignored_detail(&first_ignored(&rules, &probe[0]), &rules) + )), + Ok(None) => Ok(()), + Err(why) => { + warnings.push(VendorWarning::new( + super::npm_dir::GITIGNORE_UNCHECKED, + super::npm_dir::gitignore_unchecked_detail(&probe[0], &why), + )); + Ok(()) + } + } +} + +/// The first path [`super::npm_dir::gitignored`]'s rules name +/// (`::\t; ...`), without a trailing `/`; +/// `fallback` when they name none. +pub(super) fn first_ignored(rules: &str, fallback: &str) -> String { + rules + .split("; ") + .next() + .and_then(|rule| rule.rsplit_once('\t')) + .map(|(_, path)| path.trim_end_matches('/').to_string()) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| fallback.trim_end_matches('/').to_string()) +} + +/// The `/.gitignore` and `/.gitattributes` beside a workspace +/// mirror that go with it on removal: only ours, byte for byte, and only +/// when nothing but the mirror itself (and the scope directory holding it) +/// is left in that uuid dir, so the member's `.socket/` can still be pruned. +fn mirror_metadata(path: &Path) -> Vec { + // `/.socket/vendor/npm/`: the ancestor under `vendor/npm`. + let Some(dir) = path.ancestors().skip(1).find(|dir| { + let npm = dir.parent(); + npm.and_then(Path::file_name).is_some_and(|n| n == "npm") + && npm + .and_then(Path::parent) + .and_then(Path::file_name) + .is_some_and(|n| n == "vendor") + }) else { + return Vec::new(); + }; + let ours = [ + (".gitignore", super::npm_dir::UUID_GITIGNORE), + (".gitattributes", super::npm_dir::UUID_GITATTRIBUTES), + ]; + let mut metadata = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + for entry in entries.flatten() { + let child = entry.path(); + if path.starts_with(&child) { + // The mirror, or the `@scope/` directory holding only it. + if child != path + && std::fs::read_dir(&child).map_or(true, |mut it| { + it.any(|e| e.map_or(true, |e| e.path() != path)) + }) + { + return Vec::new(); + } + continue; + } + let name = entry.file_name(); + match ours.iter().find(|(n, _)| name == *n) { + Some((_, want)) + if read_regular_to_bytes_sync(&child).is_ok_and(|b| b == want.as_bytes()) => + { + metadata.push(child) + } + _ => return Vec::new(), + } + } + metadata +} + +/// Remove a reverted workspace tarball, if still there (with its `/.gitignore` and +/// `.gitattributes`, see [`mirror_metadata`]) and prune its emptied +/// parents through the workspace's `.socket/`, or queue all of them for +/// after the commit of a staged hosted takeover (see +/// `group_commit::defer_removal`). pub(super) async fn remove_mirror(path: &Path) -> std::io::Result<()> { let bound = path .ancestors() @@ -931,10 +1111,28 @@ pub(super) async fn remove_mirror(path: &Path) -> std::io::Result<()> { .take(5) .find(|dir| dir.file_name().is_some_and(|name| name == ".socket")) .unwrap_or(path); - if crate::utils::group_commit::defer_removal(path, bound) { - return Ok(()); + let metadata = mirror_metadata(path); + // A tarball already gone (deleted by hand) still takes its metadata. + let present = tokio::fs::symlink_metadata(path).await.is_ok(); + let queue: Vec<&Path> = present + .then_some(path) + .into_iter() + .chain(metadata.iter().map(PathBuf::as_path)) + .collect(); + if let Some((first, rest)) = queue.split_first() { + if crate::utils::group_commit::defer_removal(first, bound) { + for file in rest { + crate::utils::group_commit::defer_removal(file, bound); + } + return Ok(()); + } + } + if present { + tokio::fs::remove_file(path).await?; + } + for file in &metadata { + let _ = tokio::fs::remove_file(file).await; } - tokio::fs::remove_file(path).await?; prune_mirror_parents(path).await; Ok(()) } @@ -959,7 +1157,11 @@ pub(super) async fn undo_mirrors(backups: &[(PathBuf, Option>)]) { if let Some(bytes) = before { let _ = atomic_write_bytes_preserving_mode(path, bytes).await; } else { + let metadata = mirror_metadata(path); let _ = tokio::fs::remove_file(path).await; + for file in &metadata { + let _ = tokio::fs::remove_file(file).await; + } prune_mirror_parents(path).await; } } @@ -1445,6 +1647,168 @@ mod rebuild_tests { } } + // ── git-ignored workspace mirrors (#1116) ───────────────────────────── + + fn mirror_rels(entry: &VendorEntry) -> Vec { + entry + .wiring + .iter() + .filter(|r| r.kind == MIRROR_KIND) + .map(|r| r.file.clone()) + .collect() + } + + /// Node.gitignore's `*.tgz` ignores every tarball, the member mirrors + /// included: each mirror's uuid dir gets the root artifact's `!*` + /// `.gitignore`, so git commits it (and `repair` puts it back). A + /// revert takes that metadata with the mirror, pruning the member's + /// `.socket/`. + #[tokio::test] + async fn a_tgz_rule_still_commits_every_workspace_mirror() { + let fx = flip_fixture().await; + let root = fx.root(); + if ts::git_project(root, "node_modules\n*.tgz\n").is_none() { + return; + } + assert_eq!( + super::super::npm_common::npm_tarball_gitignore_preflight(root, UUID).await, + None, + "a file rule does not stop a takeover" + ); + let (result, entry, warnings) = ts::expect_done(flip_run(&fx, None).await); + assert!(result.success, "{result:?}"); + assert!( + !ts::has_warning(&warnings, super::super::npm_dir::GITIGNORE_UNCHECKED), + "{warnings:?}" + ); + let entry = entry.expect("vendoring wires the lock"); + let mirrors = mirror_rels(&entry); + assert!(!mirrors.is_empty(), "fixture wires workspace mirrors"); + assert_eq!( + super::super::npm_dir::gitignored(root, &mirrors).await, + None, + "git commits every mirror" + ); + let gitignore = |rel: &str| Path::new(rel).parent().unwrap().join(".gitignore"); + for rel in &mirrors { + assert_eq!( + std::fs::read_to_string(root.join(gitignore(rel))).unwrap(), + super::super::npm_dir::UUID_GITIGNORE, + "{rel}" + ); + } + + for rel in &mirrors { + std::fs::remove_file(root.join(gitignore(rel))).unwrap(); + } + super::super::bun_workspace::repair(root, &entry, false) + .await + .unwrap(); + assert_eq!( + super::super::npm_dir::gitignored(root, &mirrors).await, + None, + "repair restores the mirrors' .gitignore" + ); + + // A mirror deleted by hand still takes its metadata on revert. + std::fs::remove_file(root.join(&mirrors[0])).unwrap(); + ts::persist(root, PURL, entry.clone()).await; + let outcome = revert(&entry, root, RevertOpts::new(false)).await; + assert!(outcome.success, "{outcome:?}"); + assert_eq!(std::fs::read(root.join(LOCK)).unwrap(), ORIGINAL); + for rel in &mirrors { + let member = rel.split("/.socket/").next().unwrap(); + assert!( + !root.join(member).join(".socket").exists(), + "{member}/.socket is pruned with its mirror" + ); + } + } + + /// A rule that ignores a member's `.socket/` as a directory cannot be + /// overridden from inside it, so vendoring refuses + /// `vendor_artifact_gitignored` before writing anything, dry run + /// included, rather than commit a lock whose member tarball never + /// reaches the repository. + #[tokio::test] + async fn an_ignored_member_vendor_dir_refuses_before_any_write() { + let workspaces = BunLockb::parse(ORIGINAL) + .unwrap() + .workspace_paths() + .unwrap(); + let member = workspaces[0].trim_start_matches("./").to_string(); + let shapes = [ + ( + "member .socket/", + format!("{member}/.gitignore"), + ".socket/\n", + ), + ( + "root member glob", + ".gitignore".into(), + "node_modules\n*/**/.socket/\n", + ), + ]; + for (label, file, rules) in &shapes { + for dry_run in [true, false] { + let fx = flip_fixture().await; + let root = fx.root(); + if ts::git_project(root, "node_modules\n").is_none() { + return; + } + std::fs::create_dir_all(root.join(&file).parent().unwrap()).unwrap(); + std::fs::write(root.join(file), rules).unwrap(); + // The hosted takeover's pre-flight (run before it restores + // the registry entry) refuses the same project. + let takeover = + super::super::npm_common::npm_tarball_gitignore_preflight(root, UUID).await; + assert_eq!( + takeover.map(|(code, _)| code), + Some("vendor_artifact_gitignored"), + "{label}: takeover pre-flight" + ); + let blobs = root.join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_bun( + PURL, + &fx.installed(), + root, + &fx.record, + &PatchSources::blobs_only(&blobs), + "", + dry_run, + false, + None, + ) + .await; + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, "vendor_artifact_gitignored", "{label}: {detail}"); + assert!(detail.contains(&member), "{label}: {detail}"); + } + other => { + panic!("{label} (dry run {dry_run}): expected a refusal, got {other:?}") + } + } + assert_eq!(std::fs::read(root.join(LOCK)).unwrap(), ORIGINAL, "{label}"); + assert!(!root.join(".socket/vendor").exists(), "{label}"); + assert!(!root.join(&member).join(".socket").exists(), "{label}"); + } + } + } + + /// A refusal names the path git reports as ignored, not the first one + /// probed: with members `a` and `b`, only `b` may be ignored. + #[test] + fn first_ignored_names_the_path_git_reports() { + let rules = "packages/b/.gitignore:1:.socket/\tpackages/b/.socket/vendor/npm/u/; \ + .gitignore:2:x\tpackages/c/.socket/vendor/npm/u/"; + assert_eq!( + first_ignored(rules, "packages/a/.socket/vendor/npm/u/"), + "packages/b/.socket/vendor/npm/u" + ); + assert_eq!(first_ignored("", "packages/a/x/"), "packages/a/x"); + } + // ── bun.lockb migrated to bun.lock (#784) ───────────────────────────── const MIGRATED_LOCKB: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); diff --git a/crates/socket-patch-core/src/vendor/bun_workspace.rs b/crates/socket-patch-core/src/vendor/bun_workspace.rs index d1c1af2f4..dde1060cf 100644 --- a/crates/socket-patch-core/src/vendor/bun_workspace.rs +++ b/crates/socket-patch-core/src/vendor/bun_workspace.rs @@ -135,6 +135,14 @@ pub(super) async fn repair( } written.push((path.clone(), before.clone())); } + // Every mirror carries the root artifact's `/.gitignore`, so + // a `*.tgz` rule cannot keep a rebuilt one out of the commit (#1116). + for rec in &wiring { + if let Err(e) = super::bun_binary::restore_mirror_metadata(root, &rec.file).await { + undo_mirrors(&written).await; + return Err(e); + } + } } Ok((wiring, !updates.is_empty())) } @@ -169,7 +177,8 @@ pub(super) async fn cleanup(root: &Path, entry: &VendorEntry, dry_run: bool) -> paths.push(path) } Ok(_) => return Err("workspace tarball changed; superseded artifacts kept".into()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + // Gone already: its `/.gitignore` still goes. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => paths.push(path), Err(e) => return Err(format!("cannot read workspace tarball: {e}")), } } diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 6d22dddb0..48de7a329 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -223,10 +223,26 @@ pub async fn npm_tarball_gitignore_preflight( uuid: &str, ) -> Option<(&'static str, String)> { let uuid_dir_rel = vendor_uuid_dir_rel("npm", uuid)?; - let rules = npm_dir::gitignored(project_root, &[format!("{uuid_dir_rel}/")]).await?; + if let Some(rules) = npm_dir::gitignored(project_root, &[format!("{uuid_dir_rel}/")]).await { + return Some(( + npm_dir::GITIGNORED, + npm_dir::gitignored_detail(&uuid_dir_rel, &rules), + )); + } + // A `bun.lockb` workspace also mirrors the tarball into each member + // (#1116), which the vendoring's own pre-flight probes the same way. + let mirrors = super::bun_binary::workspace_mirror_dirs(project_root, uuid); + let probe: Vec = mirrors.iter().map(|dir| format!("{dir}/")).collect(); + if probe.is_empty() { + return None; + } + let rules = npm_dir::gitignored(project_root, &probe).await?; Some(( npm_dir::GITIGNORED, - npm_dir::gitignored_detail(&uuid_dir_rel, &rules), + npm_dir::gitignored_detail( + &super::bun_binary::first_ignored(&rules, &mirrors[0]), + &rules, + ), )) }