diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..75e792d7e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a workspace exact-pin override (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview does not model the takeover yet (it still lists such a purl `would_vendor`). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -1216,7 +1216,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_copy_untaggable` | `failed` (error prefix) | vendor / scan / get `--mode vendored` (cargo, v5.0): the copy's `Cargo.toml` has no literal `[package] version` string that can be rewritten byte-exactly (or it names another version); nothing is swapped in. A dry run over an already-vendored copy reports the same failure; a patch-service crate that cannot be tagged fails with `vendor_prebuilt_required`. | | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_symlink_unsupported` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | -| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0289bf946..5034c2948 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -121,6 +121,114 @@ fn linked_vendor_dir_refusal(project_root: &Path, purl: &str, uuid: &str) -> Opt .map(|link| vendor::path::vendor_dir_symlink_detail(&link)) } +/// A wet hosted → vendored takeover held open until the backend's outcome +/// is known: the group-commit savepoint taken before the upstream restore, +/// and what the restore reported, recorded only once the restore stands. +struct TakeoverUndo { + savepoint: Option, + advisories: Vec, + vlt_targets: Vec, +} + +impl TakeoverUndo { + /// The purl is not vendored: roll the restore back in `group`'s + /// overlay, so the hosted pin stays and nothing of the restore is + /// reported. + fn abandon(self, group: Option<&GroupCommit>) { + if let (Some(savepoint), Some(group)) = (self.savepoint, group) { + group.rollback_to(savepoint); + } + } + + /// The restore stands: record its advisories and queue the vlt heal. + fn settle( + self, + env: &mut Envelope, + common: &GlobalArgs, + candidate: &str, + vlt_takeover_targets: &mut HashMap< + String, + Vec, + >, + ) { + if !self.vlt_targets.is_empty() { + vlt_takeover_targets.insert(candidate.to_string(), self.vlt_targets); + } + for advisory in &self.advisories { + record_warning(env, candidate, advisory, common); + } + } +} + +/// The dry-run twin of the wet takeover's rollback: the vendored backend's +/// refusal over the project as `restore` would leave it, or `None` when it +/// would vendor (or the restored project cannot be previewed). The +/// restored text is staged in a throwaway group commit that is never +/// committed, so nothing reaches the disk: a restore touching a file the +/// overlay does not capture, or a binary lock (no staged text), is not +/// previewed. +#[allow(clippy::too_many_arguments)] +async fn takeover_dry_refusal( + restore: &socket_patch_core::patch::redirect::upstream::RestoreOutcome, + purl: &str, + pkg_path: PackageSource<'_>, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + force: bool, + service: Option<&VendorServiceConfig>, + pipenv_version: &tokio::sync::OnceCell>, + installed_sites: &vendor::pypi::InstalledSiteListings, +) -> Option<(&'static str, String)> { + if restore.reverted_files.is_empty() + || !restore.reverted_files.iter().all(|f| { + socket_patch_core::utils::group_commit::captures(f) + && restore.staged_text.contains_key(f) + }) + { + return None; + } + let probe = GroupCommit::begin(project_root); + for (rel, text) in &restore.staged_text { + let path = project_root.join(rel); + let staged = match text { + Some(text) => { + socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode( + &path, + text.as_bytes(), + ) + .await + } + None => socket_patch_core::utils::fs::remove_file(&path).await, + }; + if staged.is_err() { + return None; + } + } + let outcome = Box::pin(dispatch_vendor_one( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + true, + force, + service, + pipenv_version, + installed_sites, + )) + .await; + drop(probe); + match outcome { + Some(VendorOutcome::Refused { code, detail }) if !refusal_is_benign(code) => { + Some((code, detail)) + } + _ => None, + } +} + /// 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 @@ -2579,6 +2687,11 @@ pub(crate) async fn vendor_records_reusing( // vendor detach the PRISTINE registry entry to record. A purl // whose upstream entry cannot be restored is REFUSED; the cargo // backend's `hosted_redirect_live` guard backstops the rest. + // A wet takeover whose restore stayed in the group commit's + // overlay: the point to roll back to when the backend below + // does not vendor the purl, and the advisories that only hold + // once it does (see `TakeoverUndo`). + let mut takeover_undo: Option = None; if let Some(pin) = hosted_pin_of(candidate) { let origins = crate::commands::rollback::patch_server_origins(common); let restore_opts = socket_patch_core::patch::redirect::upstream::RestoreOptions { @@ -2664,18 +2777,33 @@ pub(crate) async fn vendor_records_reusing( ) }) .unwrap_or_default(); + let savepoint = group.as_ref().map(GroupCommit::savepoint); let restore = socket_patch_core::patch::redirect::upstream::restore_upstream( &common.cwd, std::slice::from_ref(pin), &restore_opts, ) .await; + // Undoable only when every file the restore wrote is still + // in the overlay (a `.socket/gradle/hosted-index.tsv` is + // written straight to disk). + let savepoint = savepoint.filter(|_| { + restore + .reverted_files + .iter() + .all(|f| socket_patch_core::utils::group_commit::captures(f)) + }); let refusal = restore .refused() .map(|(_, why)| why.to_string()) .next() .or_else(|| restore.flush_error.clone()); if let Some(detail) = refusal { + // A flush that failed partway may have staged some of + // the restore: put the hosted wiring back. + if let (Some(savepoint), Some(group)) = (savepoint, group.as_ref()) { + group.rollback_to(savepoint); + } has_errors = true; env.record( PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( @@ -2690,15 +2818,41 @@ pub(crate) async fn vendor_records_reusing( ); continue; } - for (code, detail) in &restore.warnings { - record_warning( - env, - candidate, - &VendorWarning::new(code, detail.clone()), - common, - ); - } + let mut advisories: Vec = restore + .warnings + .iter() + .map(|(code, detail)| VendorWarning::new(code, detail.clone())) + .collect(); if common.dry_run { + // The refusal the backend would raise over the restored + // project, previewed here with the wet run's code (the + // wet run rolls the restore back on it, below). + if let Some((code, detail)) = takeover_dry_refusal( + &restore, + candidate, + pkg_source, + &common.cwd, + record, + sources, + &vendored_at, + force, + service, + &pipenv_version, + &installed_sites, + ) + .await + { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(code, detail.clone()), + ); + report_vendor_failure(common, candidate, &detail); + continue; + } + for advisory in &advisories { + record_warning(env, candidate, advisory, common); + } record_warning( env, candidate, @@ -2726,23 +2880,25 @@ pub(crate) async fn vendor_records_reusing( continue; } } else { - if !targets.is_empty() { - vlt_takeover_targets.insert(candidate.clone(), targets); - } - record_warning( - env, - candidate, - &VendorWarning::new( - "vendor_takeover_reverted_redirect", - format!( - "{} was hosted; restored its upstream registry entry ({}) \ - before vendoring (mode takeover)", - normalize_purl(candidate), - restore.reverted_files.join(", ") - ), + advisories.push(VendorWarning::new( + "vendor_takeover_reverted_redirect", + format!( + "{} was hosted; restored its upstream registry entry ({}) \ + before vendoring (mode takeover)", + normalize_purl(candidate), + restore.reverted_files.join(", ") ), - common, - ); + )); + let undo = TakeoverUndo { + savepoint, + advisories, + vlt_targets: targets, + }; + if undo.savepoint.is_some() { + takeover_undo = Some(undo); + } else { + undo.settle(env, common, candidate, &mut vlt_takeover_targets); + } } } @@ -2801,6 +2957,9 @@ pub(crate) async fn vendor_records_reusing( .with_error("vendor_redownload_failed", detail.clone()), ); report_vendor_failure(common, candidate, &detail); + if let Some(undo) = takeover_undo.take() { + undo.abandon(group.as_ref()); + } continue; } } @@ -2830,6 +2989,19 @@ pub(crate) async fn vendor_records_reusing( status.finish(); let vendored = matches!(&outcome, Some(VendorOutcome::Done { result, .. }) if result.success); + if let Some(undo) = takeover_undo.take() { + // A takeover the backend did not carry through keeps the + // hosted pin: its restore is rolled back in the overlay, so + // the purl is never left un-hosted AND unvendored (#853, + // #944). One the backend recorded keeps the restore. + let recorded = + matches!(&outcome, Some(VendorOutcome::Done { entry, .. }) if entry.is_some()); + if vendored || recorded || undo.savepoint.is_none() || group.is_none() { + undo.settle(env, common, candidate, &mut vlt_takeover_targets); + } else { + undo.abandon(group.as_ref()); + } + } match outcome { None => { diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs index 89cb4f4a7..cfe446022 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs @@ -496,18 +496,22 @@ async fn vlt_hosted_artifact_preflight_refuses_before_the_vendored_revert() { assert!(root.join(rel()).join("index.js").is_file()); } -/// A vendor that fails after the takeover's upstream restore was persisted -/// (here a patch-service artifact failing its integrity check) still heals -/// the hosted store copy against the restored registry pin: the lock no -/// longer pins anything hosted, so no later run could find it again. +/// A vendor that fails after the takeover's upstream restore (here a +/// patch-service artifact failing its integrity check) rolls the restore +/// back (#853, #944): the purl stays hosted-patched, the lock and the +/// hosted store copy are left as hosted mode wrote them, and nothing asks +/// for a reinstall. #[tokio::test(flavor = "multi_thread")] -async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { +async fn vlt_failed_vendor_after_the_takeover_revert_keeps_the_hosted_pin() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let server = MockServer::start().await; mock_api(&server).await; hosted_project(root, &server).await; seed_manifest(root); + let lock = hosted::read(root, "vlt-lock.json"); + let pkg = hosted::read(root, "package.json"); + let store = hosted::store_dir(root, TILDE_ID).exists(); let service = MockServer::start().await; hosted::mock_reference_at( @@ -536,27 +540,30 @@ async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { assert_eq!(code, 1, "{env:#}\n{stderr}"); let codes = all_codes(&env); assert!( - codes.contains(&"vendor_takeover_reverted_redirect".to_string()), - "{env:#}" + !codes.contains(&"vendor_takeover_reverted_redirect".to_string()), + "the restore is rolled back, never reported: {env:#}" ); assert!( detail_of(&env, "apply_failed").contains("integrity"), "{env:#}" ); + assert!( + !codes.contains(&"redirect_vlt_reinstall_required".to_string()), + "{env:#}" + ); assert_eq!( - detail_of(&env, "redirect_vlt_reinstall_required"), - "restored registry pins for 1 packages; removed the patched installed copies, so \ - node_modules is incomplete until you run `vlt install` (or `vlt ci`)" + hosted::read(root, "vlt-lock.json"), + lock, + "the hosted pin stays" ); - assert_eq!(hosted::read(root, "vlt-lock.json"), registry_lock()); - assert_eq!(hosted::read(root, "package.json"), PACKAGE_JSON); + assert_eq!(hosted::read(root, "package.json"), pkg); assert!(vendor_entry(root).is_none()); assert_no_redirect_ledger(root); - assert!( - !hosted::store_dir(root, TILDE_ID).exists(), - "the hosted store copy is invalidated" + assert_eq!( + hosted::store_dir(root, TILDE_ID).exists(), + store, + "the hosted store copy is left alone" ); - assert!(!root.join("node_modules/.vlt-lock.json").exists()); } /// An optional hosted pin taken over by `scan --mode vendored`: the diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs new file mode 100644 index 000000000..1c009aed6 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -0,0 +1,451 @@ +//! Hermetic hosted → vendored takeover tests through the built binary for +//! pnpm projects whose shape the pnpm vendored backend refuses although +//! hosted mode accepts it (#853): a `catalog:` dependency +//! (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` +//! (`vendor_lockfile_crlf_unsupported`) and a user exact-pin override in +//! `pnpm-workspace.yaml` (`vendor_override_conflict`). +//! +//! `scan`/`get --mode vendored` over such a hosted pin used to commit the +//! upstream restore FIRST and only then reach the backend's refusal, so +//! the run failed with the hosted pin already gone and the project went +//! back to installing the unpatched registry release. A refused takeover +//! must leave the hosted wiring byte-for-byte in place; a plain dependency +//! still takes over. +//! +//! The API and the npm registry are wiremock; no pnpm binary is needed. +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +#[path = "common/hermetic.rs"] +mod hermetic; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The project shapes under test. +#[derive(Clone, Copy, Debug)] +enum Shape { + /// `"left-pad": "catalog:"` resolved through the default catalog. + Catalog, + /// A plain dependency whose lock is converted to CRLF after hosting. + Crlf, + /// A plain dependency plus a user `overrides: { left-pad: 1.3.0 }`. + Override, + /// A plain dependency: the control both modes accept. + Plain, +} + +/// package.json, pnpm-workspace.yaml, the installed (unpatched) copy and +/// the pristine lockfileVersion 9.0 lock pnpm writes for `shape`. +fn write_pnpm_project(root: &Path, shape: Shape) { + let spec = match shape { + Shape::Catalog => "catalog:", + _ => VERSION, + }; + std::fs::write( + root.join("package.json"), + format!( + r#"{{"name":"c","version":"1.0.0","private":true,"dependencies":{{"{NAME}":"{spec}"}}}}"# + ), + ) + .unwrap(); + let workspace = match shape { + Shape::Catalog => format!("packages:\n - .\ncatalog:\n {NAME}: {VERSION}\n"), + Shape::Override => format!("overrides:\n {NAME}: {VERSION}\n"), + Shape::Crlf | Shape::Plain => String::new(), + }; + if !workspace.is_empty() { + std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); + } + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{NAME}","version":"{VERSION}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + + let mut lock = String::from( + "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\n\n", + ); + match shape { + Shape::Catalog => lock.push_str(&format!( + "catalogs:\n default:\n {NAME}:\n specifier: {VERSION}\n version: {VERSION}\n\n" + )), + Shape::Override => lock.push_str(&format!("overrides:\n {NAME}: {VERSION}\n\n")), + Shape::Crlf | Shape::Plain => {} + } + let specifier = match shape { + Shape::Catalog => "'catalog:'".to_string(), + _ => VERSION.to_string(), + }; + lock.push_str(&format!( + "importers:\n\n .:\n dependencies:\n {NAME}:\n specifier: {specifier}\n version: {VERSION}\n\n\ + packages:\n\n {NAME}@{VERSION}:\n resolution: {{integrity: {UPSTREAM_SHA512}}}\n\n\ + snapshots:\n\n {NAME}@{VERSION}: {{}}\n" + )); + std::fs::write(root.join("pnpm-lock.yaml"), lock).unwrap(); +} + +fn patch_record() -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "pnpm takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +fn patch_view() -> Value { + let mut view = patch_record(); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + view +} + +/// The hosted-mode API (discovery + by-package + grant + view) for the one +/// patch over `PURL`, plus the npm registry's version document the +/// upstream restore re-resolves the pristine entry from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "pnpm takeover fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the +/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { + let mut cmd = hermetic::binary_command(); + cmd.current_dir(cwd); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NPM_REGISTRY", registry); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// `scan --mode ` (or, with `get`, `get --mode `) +/// against the mock API. +fn run_mode(cwd: &Path, api: &str, command: &str, mode: &str, extra: &[&str]) -> (i32, Value) { + let mut args = vec![command]; + if command == "get" { + args.push(PURL); + } + args.extend([ + "--mode", + mode, + "--json", + "--yes", + "--api-url", + api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + ]); + let fixture = (mode == "vendored").then(|| prebuilt_common::Server::view(patch_view())); + if let Some(fixture) = &fixture { + args.extend(["--vendor-url", &fixture.uri]); + } + args.extend_from_slice(extra); + run_json(cwd, api, &args) +} + +/// The vendor events: top-level for `vendor`, under `vendor` for the +/// `scan`/`get` envelopes that embed the vendor step. +fn events(envelope: &Value) -> Vec { + envelope["events"] + .as_array() + .or_else(|| envelope["vendor"]["events"].as_array()) + .cloned() + .unwrap_or_default() +} + +fn has_event_code(envelope: &Value, code: &str) -> bool { + events(envelope).iter().any(|e| e["errorCode"] == code) + || envelope.to_string().contains(&format!("\"{code}\"")) +} + +/// Every file hosted mode or the takeover may write. +const WIRING: &[&str] = &[ + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "package.json", + ".npmrc", +]; + +fn snapshot(root: &Path) -> Vec<(&'static str, Option>)> { + WIRING + .iter() + .map(|f| (*f, std::fs::read(root.join(f)).ok())) + .collect() +} + +/// The hosted project: pristine lock, then a real `scan --mode hosted` +/// (and, for [`Shape::Crlf`], the lock converted to CRLF as a +/// `core.autocrlf` checkout would leave it). Returns the hosted wiring. +fn host_project(root: &Path, api: &str, shape: Shape) -> Vec<(&'static str, Option>)> { + write_pnpm_project(root, shape); + let (code, env) = run_mode(root, api, "scan", "hosted", &[]); + assert_eq!(code, 0, "hosted scan must succeed for {shape:?}: {env:#}"); + let lock_path = root.join("pnpm-lock.yaml"); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "hosted mode must pin the {shape:?} lock entry:\n{lock}\n{env:#}" + ); + if matches!(shape, Shape::Crlf) { + std::fs::write(&lock_path, lock.replace('\n', "\r\n")).unwrap(); + } + snapshot(root) +} + +/// A refused takeover leaves every byte of the hosted wiring in place and +/// writes no vendored state. +fn assert_still_hosted(root: &Path, hosted: &[(&'static str, Option>)], env: &Value) { + for ((file, before), (_, now)) in hosted.iter().zip(snapshot(root)) { + assert_eq!( + before.as_deref().map(String::from_utf8_lossy), + now.as_deref().map(String::from_utf8_lossy), + "{file} must keep the hosted wiring byte-for-byte: {env:#}" + ); + } + assert!( + !root.join(".socket/vendor/npm").exists(), + "a refused run must not leave a vendored artifact: {env:#}" + ); +} + +/// The vendored run over a hosted pin the backend refuses: failed with the +/// backend's own `code`, and never reported as un-hosted. +fn assert_refused(env: &Value, exit: i32, code: &str) { + assert_eq!(exit, 1, "the refusal fails the run: {env:#}"); + let failed = events(env) + .into_iter() + .find(|e| e["action"] == "failed" && e["errorCode"] == code) + .unwrap_or_else(|| panic!("expected a failed `{code}` event: {env:#}")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + !has_event_code(env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); +} + +async fn refused_takeover_keeps_hosted_pin(shape: Shape, command: &str, code: &str) { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted = host_project(root, &server.uri(), shape); + + // The dry run writes nothing. + let (_, env) = run_mode(root, &server.uri(), command, "vendored", &["--dry-run"]); + assert_still_hosted(root, &hosted, &env); + + let (exit, env) = run_mode(root, &server.uri(), command, "vendored", &[]); + assert_refused(&env, exit, code); + assert_still_hosted(root, &hosted, &env); +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// #853: a `catalog:` dependency. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Catalog, "scan", "vendor_lock_entry_unsupported") + .await; +} + +/// #853: `get --mode vendored`, same catalog shape. +#[tokio::test(flavor = "multi_thread")] +async fn get_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Catalog, "get", "vendor_lock_entry_unsupported").await; +} + +/// #853: a CRLF `pnpm-lock.yaml` (a `core.autocrlf` checkout). +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Crlf, "scan", "vendor_lockfile_crlf_unsupported") + .await; +} + +/// #853: a user exact-pin override in `pnpm-workspace.yaml`. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_workspace_override_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Override, "scan", "vendor_override_conflict").await; +} + +/// Control: a plain dependency is supported by both modes, so the takeover +/// still restores the registry entry and vendors it. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::Plain); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert_eq!(exit, 0, "{env:#}"); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(exit, 0, "the plain takeover must succeed: {env:#}"); + assert!( + has_event_code(&env, "vendor_takeover_reverted_redirect"), + "{env:#}" + ); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!(!lock.contains(HOSTED_URL), "{lock}"); + assert!( + lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the lock must point at the vendored artifact:\n{lock}" + ); +} + +/// `vendor --dry-run` over the hosted pin previews the backend's refusal of +/// the RESTORED project (staged in memory, never written) with the wet +/// run's code, instead of promising the takeover; the wet `vendor` then +/// keeps the hosted pin. +#[tokio::test(flavor = "multi_thread")] +async fn vendor_dry_run_over_hosted_pnpm_catalog_dep_previews_the_refusal() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::Catalog); + let manifest = json!({ "patches": { PURL: patch_record() } }); + std::fs::create_dir_all(root.join(".socket/blobs")).unwrap(); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + root.join(".socket/blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); + let hosted = snapshot(root); + let vendor = |extra: &[&str]| { + let mut args = vec!["vendor", "--json", "--cwd", root.to_str().unwrap()]; + args.extend_from_slice(extra); + run_json(root, &server.uri(), &args) + }; + + let (exit, env) = vendor(&["--dry-run"]); + assert_still_hosted(root, &hosted, &env); + assert_refused(&env, exit, "vendor_lock_entry_unsupported"); + assert!( + !has_event_code(&env, "vendor_would_revert_redirect"), + "the refused takeover is not promised: {env:#}" + ); + + let (exit, env) = vendor(&[]); + assert_refused(&env, exit, "vendor_lock_entry_unsupported"); + assert_still_hosted(root, &hosted, &env); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs new file mode 100644 index 000000000..175c414ef --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs @@ -0,0 +1,414 @@ +//! Hermetic hosted → vendored takeover tests through the built binary for +//! PyPI projects whose shape a vendored backend refuses although hosted +//! mode accepts it (#944): a uv project whose `pyproject.toml` carries an +//! inline `[tool.uv] sources = { … }` table, and a `uv pip compile +//! --universal` requirements.txt whose package is split by markers across +//! two exact pins (#928). +//! +//! `scan --mode vendored` over such a hosted pin used to commit the +//! upstream restore FIRST and only then reach the backend's refusal, so the +//! run failed with the hosted pin already gone: six was left neither +//! hosted nor vendored, and the next `uv sync` installed the unpatched +//! release. The package must stay patched in one mode or the other. +//! +//! The patch API, the hosted wheel and PyPI's JSON API are wiremock; no +//! Python toolchain is needed. + +#[path = "common/hermetic.rs"] +mod hermetic; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::io::Write as _; +use std::path::Path; + +use base64::Engine as _; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6c"; +const PURL: &str = "pkg:pypi/six@1.16.0"; +const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl"; +const ORIG: &[u8] = b"# six\nVERSION = '1.16.0'\n"; +const PATCHED: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 1\n"; +const WHEEL_SHA: &str = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"; +const SDIST_SHA: &str = "1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"; +const WHEEL_URL: &str = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl"; +const SDIST_URL: &str = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The hosted wheel: a pure-Python wheel carrying the patched module. +fn hosted_wheel() -> Vec { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, content) in [ + ("six.py", PATCHED), + ( + "six-1.16.0.dist-info/METADATA", + b"Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\n".as_slice(), + ), + ( + "six-1.16.0.dist-info/WHEEL", + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n" + .as_slice(), + ), + ( + "six-1.16.0.dist-info/RECORD", + b"six.py,,\nsix-1.16.0.dist-info/METADATA,,\nsix-1.16.0.dist-info/WHEEL,,\nsix-1.16.0.dist-info/RECORD,,\n" + .as_slice(), + ), + ] { + zip.start_file(name, opts).unwrap(); + zip.write_all(content).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +fn patch_view() -> Value { + json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "six.py": { + "beforeHash": compute_git_sha256_from_bytes(ORIG), + "afterHash": compute_git_sha256_from_bytes(PATCHED), + "blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED), + }}, + "vulnerabilities": {}, + "description": "pypi hosted → vendored takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// The patch API (discovery, grant, view), the hosted wheel, and PyPI's +/// JSON API document the upstream restore re-derives six's files from. +/// Returns the hosted wheel's URL. +async fn mount_api(server: &MockServer) -> String { + let wheel = hosted_wheel(); + let sha = hex::encode(Sha256::digest(&wheel)); + let route = + format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{WHEEL}"); + let hosted_url = format!("{}{route}", server.uri()); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ "purl": PURL, "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", "cveIds": [], "ghsaIds": [], + "severity": "high", "title": "pypi takeover fixture" + }]}], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { UUID: { + "status": "granted", "url": hosted_url, "purl": PURL, + "artifacts": [{ "kind": "tarball", "url": hosted_url, + "integrity": { "sha256": sha } }], + "registryOverride": null + }} + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(wheel)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/pypi/six/1.16.0/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "urls": [ + { "filename": WHEEL, "url": WHEEL_URL, "digests": { "sha256": WHEEL_SHA }, + "size": 11053, "upload_time_iso_8601": "2021-05-05T14:18:17.237Z" }, + { "filename": "six-1.16.0.tar.gz", "url": SDIST_URL, + "digests": { "sha256": SDIST_SHA }, + "size": 34041, "upload_time_iso_8601": "2021-05-05T14:18:18.379Z" }, + ]}))) + .mount(server) + .await; + hosted_url +} + +const UV_LOCK: &str = r#"version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "attrs" }, + { name = "six" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", index = "https://pypi.org/simple" }, + { name = "six", specifier = "==1.16.0" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "SDIST_URL", hash = "sha256:SDIST_SHA", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "WHEEL_URL", hash = "sha256:WHEEL_SHA", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + +/// #944 trigger 1: a uv project whose `[tool.uv]` names its sources as an +/// inline table, which the uv vendored backend refuses and hosted mode +/// grows in place. Returns its wiring files. +fn stage_uv_inline_sources(root: &Path) -> &'static [&'static str] { + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\n\ + dependencies = [\"six==1.16.0\", \"attrs\"]\n\n\ + [tool.uv]\nsources = { attrs = { index = \"pypi\" } }\n\n\ + [[tool.uv.index]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple\"\n", + ) + .unwrap(); + std::fs::write( + root.join("uv.lock"), + UV_LOCK + .replace("SDIST_URL", SDIST_URL) + .replace("WHEEL_URL", WHEEL_URL) + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + &["uv.lock", "pyproject.toml"] +} + +/// #944 trigger 2 (#928's shape): `uv pip compile --universal` output that +/// pins six to 1.16.0 below Python 3.12 and to 1.17.0 from it. +fn stage_requirements_marker_split(root: &Path) -> &'static [&'static str] { + std::fs::write( + root.join("requirements.txt"), + "six==1.16.0 ; python_full_version < '3.12'\n\ + six==1.17.0 ; python_full_version >= '3.12'\n", + ) + .unwrap(); + &["requirements.txt"] +} + +/// `.socket/manifest.json` plus the after-hash blob, from which the +/// prebuilt fixture server builds the vendored wheel. +fn stage_manifest(root: &Path) { + let after = compute_git_sha256_from_bytes(PATCHED); + let manifest = json!({ "patches": { PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "six.py": { + "beforeHash": compute_git_sha256_from_bytes(ORIG), + "afterHash": after, + }}, + "vulnerabilities": {}, + "description": "pypi hosted → vendored takeover fixture", + "license": "MIT", + "tier": "free" + }}}); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap(); +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// The built binary with every ambient `SOCKET_*` var scrubbed, PyPI's JSON +/// API pointed at the mock, and a `VIRTUAL_ENV` holding the unpatched six +/// so the installed-tree probes stay off the host's Python. `vendored` runs +/// get the prebuilt fixture server, which builds the vendored wheel from +/// that installed copy. +fn run_scan(root: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> (i32, Value) { + let venv = root.join("../venv"); + let site = venv.join(if cfg!(windows) { + "Lib/site-packages" + } else { + "lib/python3.11/site-packages" + }); + let info = site.join("six-1.16.0.dist-info"); + std::fs::create_dir_all(&info).unwrap(); + std::fs::write(site.join("six.py"), ORIG).unwrap(); + std::fs::write( + info.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\n", + ) + .unwrap(); + std::fs::write(info.join("RECORD"), "six.py,,\n").unwrap(); + let uri = server.uri(); + let mut cmd = hermetic::binary_command(); + cmd.args([ + "scan", + "--mode", + mode, + "--json", + "--yes", + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake-token", + "--patch-server-url", + &uri, + ]) + .args(extra) + .arg("--cwd") + .arg(root) + .current_dir(root); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_PYPI_JSON_API", format!("{uri}/pypi")) + .env("VIRTUAL_ENV", &venv) + .env("PIPENV_IGNORE_VIRTUALENVS", "0"); + let fixture = (mode == "vendored").then(|| { + prebuilt_common::Server::project_with_env(root, &[("VIRTUAL_ENV", venv.to_str().unwrap())]) + }); + if let Some(fixture) = &fixture { + cmd.arg("--vendor-url").arg(&fixture.uri); + } + let out = cmd.output().expect("spawn socket-patch"); + drop(fixture); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let env = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +fn project() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + (tmp, root) +} + +fn snapshot(root: &Path, files: &[&str]) -> Vec { + files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect() +} + +/// Host the staged project, then run `scan --mode vendored` (dry run +/// first) over it. Returns `(hosted wiring, exit, envelope)`. +async fn host_then_vendor(root: &Path, files: &[&str]) -> (Vec, i32, Value) { + let server = MockServer::start().await; + let hosted_url = mount_api(&server).await; + let (code, env) = run_scan(root, &server, "hosted", &[]); + assert_eq!(code, 0, "hosted scan: {env:#}"); + let hosted = snapshot(root, files); + assert!( + hosted.iter().any(|t| t.contains(&hosted_url)), + "hosted mode must pin six: {hosted:#?}\n{env:#}" + ); + + stage_manifest(root); + let (_, env) = run_scan(root, &server, "vendored", &["--dry-run"]); + assert_eq!( + snapshot(root, files), + hosted, + "the dry run writes nothing: {env:#}" + ); + + let (exit, env) = run_scan(root, &server, "vendored", &[]); + (hosted, exit, env) +} + +fn has_code(env: &Value, code: &str) -> bool { + env.to_string().contains(&format!("\"{code}\"")) +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// #944: a uv project with an inline `[tool.uv] sources` table, which the +/// uv vendored backend refuses after the takeover restored the PyPI entry. +/// Whatever refuses the purl after the restore (here the run can also stop +/// at the prebuilt download), the hosted pin in `pyproject.toml` and +/// `uv.lock` must stay byte-for-byte. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_uv_inline_sources_keeps_the_hosted_pin() { + let (_tmp, root) = project(); + let files = stage_uv_inline_sources(&root); + let (hosted, exit, env) = host_then_vendor(&root, files).await; + assert_eq!(exit, 1, "the refusal fails the run: {env:#}"); + assert!( + !has_code(&env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); + assert_eq!( + snapshot(&root, files), + hosted, + "the hosted pin stays byte-for-byte: {env:#}" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); +} + +/// #944 (#928's shape): whatever the requirements backend decides about a +/// marker-split pin, six is never left un-hosted and unvendored: either the +/// takeover vendors it, or it fails and the hosted line stays as hosted +/// mode wrote it. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_marker_split_requirements_never_unpatches() { + let (_tmp, root) = project(); + let files = stage_requirements_marker_split(&root); + let (hosted, exit, env) = host_then_vendor(&root, files).await; + let now = snapshot(&root, files); + if exit == 0 { + assert!( + now[0].contains(&format!(".socket/vendor/pypi/{UUID}/")), + "a successful takeover wires the vendored wheel:\n{}\n{env:#}", + now[0] + ); + } else { + assert!( + !has_code(&env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); + assert_eq!(now, hosted, "the hosted pin stays byte-for-byte: {env:#}"); + } +} diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index 7b93ada11..1003c37c6 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -88,6 +88,7 @@ const LEDGERS: [&str; 2] = [ ".socket/vendor/redirect-state.json", ]; +#[derive(Clone)] struct Captured { /// The file's bytes, `None` once removed. bytes: Option, @@ -108,6 +109,19 @@ enum Content { type Render = fn(&(dyn Any + Send + Sync)) -> std::io::Result>; +impl Clone for Content { + fn clone(&self) -> Self { + match self { + Content::Bytes(bytes) => Content::Bytes(bytes.clone()), + Content::Value { value, render, .. } => Content::Value { + value: Arc::clone(value), + render: *render, + rendered: OnceLock::new(), + }, + } + } +} + impl Content { fn bytes(&self) -> std::io::Result> { match self { @@ -715,6 +729,88 @@ impl Drop for GroupCommit { } } +/// The captured project files of an open [`GroupCommit`] at one point of +/// the run (see [`GroupCommit::savepoint`]). The two ledgers are not part +/// of it: the vendor loop owns their in-memory value and re-saves it +/// itself. +pub struct Savepoint { + files: BTreeMap, + after_commit: usize, + dirs_after_commit: usize, +} + +impl GroupCommit { + /// The captured state of every project file (the ledgers aside) right + /// now, for [`Self::rollback_to`]. A step that writes several commit + /// points and must be all-or-nothing (the hosted → vendored takeover: + /// restore the upstream entry, then vendor) takes one first, and rolls + /// back to it when a later part of the step refuses: nothing has + /// reached the disk yet, so the commit then never sees the abandoned + /// writes. + pub fn savepoint(&self) -> Savepoint { + let files = self + .overlay + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(rel, _)| !is_ledger(rel)) + .map(|(rel, captured)| (rel.clone(), captured.clone())) + .collect(); + Savepoint { + files, + after_commit: self + .overlay + .after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + dirs_after_commit: self + .overlay + .dirs_after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + } + } + + /// Put every captured project file (the ledgers aside) back to its + /// state at `savepoint`: a file first captured since then is dropped + /// from the overlay (it reads from disk again), and the removals queued + /// since then are forgotten. + pub fn rollback_to(&self, savepoint: Savepoint) { + let mut files = self + .overlay + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + files.retain(|rel, _| is_ledger(rel) || savepoint.files.contains_key(rel)); + files.extend(savepoint.files); + drop(files); + self.overlay + .after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .truncate(savepoint.after_commit); + self.overlay + .dirs_after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .truncate(savepoint.dirs_after_commit); + } +} + +/// Whether a write of the project-relative `rel` under an open +/// [`GroupCommit`] is captured (held in memory until the commit) rather +/// than written straight to disk. A caller that relies on +/// [`GroupCommit::rollback_to`] (or on dropping a throwaway group) to undo +/// a write checks every path first: an uncaptured one would already be on +/// disk. +pub fn captures(rel: &str) -> bool { + let path = Path::new(rel); + path.components().all(|c| matches!(c, Component::Normal(_))) && is_captured(path) +} + fn is_ledger(rel: &Path) -> bool { LEDGERS.contains(&rel_string(rel).as_str()) } @@ -1162,6 +1258,66 @@ mod tests { } } + #[test] + fn captures_only_root_relative_commit_points() { + assert!(captures("pnpm-lock.yaml")); + assert!(captures("packages/a/package.json")); + assert!(!captures(".socket/gradle/hosted-index.tsv")); + assert!(!captures("../pnpm-lock.yaml")); + assert!(!captures("/abs/pnpm-lock.yaml")); + assert!(!captures("")); + } + + /// The hosted → vendored takeover's undo (#853, #944): writes made + /// after a savepoint are forgotten, earlier ones kept, the ledger left + /// alone, and the commit then writes only what survived. + #[tokio::test] + async fn rollback_to_savepoint_forgets_later_project_writes_only() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = root.join("pnpm-lock.yaml"); + let ws = root.join("pnpm-workspace.yaml"); + let npmrc = root.join(".npmrc"); + let ledger = root.join(".socket/vendor/state.json"); + std::fs::create_dir_all(ledger.parent().unwrap()).unwrap(); + std::fs::write(&lock, b"hosted").unwrap(); + std::fs::write(&ws, b"trustLockfile: true\n").unwrap(); + std::fs::write(&ledger, b"{}").unwrap(); + let group = GroupCommit::begin(root); + super::super::fs::atomic_write_bytes(&npmrc, b"earlier") + .await + .unwrap(); + let savepoint = group.savepoint(); + super::super::fs::atomic_write_bytes_preserving_mode(&lock, b"upstream") + .await + .unwrap(); + super::super::fs::atomic_write_bytes(&npmrc, b"later") + .await + .unwrap(); + super::super::fs::remove_file(&ws).await.unwrap(); + super::super::fs::atomic_write_bytes(&ledger, b"{\"entries\":{}}") + .await + .unwrap(); + let tree = root.join("packages/gone"); + remove_dir_after_commit(&tree).await; + group.rollback_to(savepoint); + let read = |p: &Path| { + let p = p.to_path_buf(); + async move { super::super::fs::read_regular_to_bytes(&p).await.unwrap() } + }; + assert_eq!(read(&lock).await, b"hosted"); + assert_eq!(read(&ws).await, b"trustLockfile: true\n"); + assert_eq!(read(&npmrc).await, b"earlier"); + assert_eq!(read(&ledger).await, b"{\"entries\":{}}", "ledger kept"); + assert!(group.overlay.dirs_after_commit.lock().unwrap().is_empty()); + let mut changed = group.commit().await.unwrap(); + changed.sort(); + assert_eq!(changed, [".npmrc", ".socket/vendor/state.json"]); + assert_eq!(std::fs::read(&lock).unwrap(), b"hosted"); + assert_eq!(std::fs::read(&ws).unwrap(), b"trustLockfile: true\n"); + assert_eq!(std::fs::read(&npmrc).unwrap(), b"earlier"); + } + #[tokio::test] async fn reads_see_the_runs_writes_and_nothing_reaches_disk_until_commit() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/testing/vlt-coverage.json b/docs/testing/vlt-coverage.json index ad42fcc42..051f286a6 100644 --- a/docs/testing/vlt-coverage.json +++ b/docs/testing/vlt-coverage.json @@ -148,7 +148,6 @@ ], "redirect_vlt_reinstall_required: hosted to vendored takeover": [ "vlt_hosted_then_vendored_takeover_keeps_an_optional_hosted_copy", - "vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store", "vlt_pinned_matrix_hosted_then_vendored_optional_takeover" ], "vendor_vlt_reinstall_required: optional dependency rewired or still linked upstream": [