diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e2bc9b3d9..f7ff7b5d5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -310,7 +310,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. * **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: any package link in a `node_modules` root, such as a pnpm workspace member's link into the root `.pnpm` store (or npm's linked `.store`), alias links included (`packages/a/node_modules/lp` for `"lp": "npm:left-pad@1.3.0"`, matched by its target's own `package.json` name@version), and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. -`scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): an npm-family package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure (#954); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation") — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). +`scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): the package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure, in every ecosystem (#954, #1235); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation") — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). @@ -1535,7 +1535,7 @@ command's integration tests check its output. | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--mode vendored`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | -| `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` | `skipped` | vendor / scan / get `--mode vendored` (npm family, #954): the package is already vendored at another patch uuid, and the patch service has no prebuilt artifact for the selected (superseding) uuid — still building (`pending_build`) / not served (`build_failed`, `not_found`, `withdrawn`, no usable artifact). Nothing is touched: the older patch's artifact, wiring and ledger entry stay in force, the detail names both uuids (`kept the vendored patch : prebuilt artifact is still building for patch `), and the run does not fail (exit 0), the way hosted mode keeps its pin and lists the upgrade in `redirect.skipped[]`. A re-run picks the new patch up once the service serves it. A package with no vendored patch to keep still fails (`failed`, error `prebuilt artifact is still building` / `prebuilt artifact unavailable: `), as does a request/transport failure. | +| `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` | `skipped` | vendor / scan / get `--mode vendored` (every ecosystem, #954, #1235): the package is already vendored at another patch uuid, and the patch service has no prebuilt artifact for the selected (superseding) uuid — still building (`pending_build`) / not served (`build_failed`, `not_found`, `withdrawn`, no usable artifact). Nothing is touched: the older patch's artifact, wiring and ledger entry stay in force, the detail names both uuids (`kept the vendored patch : prebuilt is still building for patch `, where `` is the backend's artifact kind: `artifact` for the npm family, `.gem`, `crate`, `module zip`, `dist zip`, `wheel`, `.jar`, `.nupkg`, …), and the run does not fail (exit 0), the way hosted mode keeps its pin and lists the upgrade in `redirect.skipped[]`. A re-run picks the new patch up once the service serves it. A package with no vendored patch to keep still fails (`failed`, error `prebuilt is still building` / `prebuilt unavailable: `; outside the npm family under `vendor_prebuilt_required`), as does a request/transport failure. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | | `vendor_ledger_missing` | `failed` (artifact-level: `uuid` + `details.{ecosystem,path}`, no purl) | repair (v5.0) and `vendor --check`: a lockfile references `.socket/vendor///` but the vendor ledger has no entry for it; repair no longer rebuilds ledger entries from lockfiles. Recovery: restore `.socket/vendor/state.json` from version control and re-run `repair`, or `git checkout -- ` and re-vendor. | diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 6dedfaf7e..38de030ed 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -117,14 +117,15 @@ pub struct VendorArgs { fn refusal_is_benign(code: &str) -> bool { matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") // An older vendored patch kept in force (see [`keep_older_vendored_patch`]). - || matches!(code, vendor::VENDOR_PREBUILT_PENDING | vendor::VENDOR_PREBUILT_UNAVAILABLE) + || vendor::is_unserved(code) || socket_patch_core::vendor::jvm::sbt_gate::SKIP_CODES.contains(&code) } -/// #954: the patch service has no artifact for `uuid` yet (still building) -/// or at all (`build_failed`, `not_found`, …) — the backend's failed `Done` -/// carries [`vendor::VENDOR_PREBUILT_PENDING`] / -/// [`vendor::VENDOR_PREBUILT_UNAVAILABLE`] — while the ledger already holds +/// #954, #1235: the patch service has no artifact for `uuid` yet (still +/// building) or at all (`build_failed`, `not_found`, …) — the backend's +/// refusal carries [`vendor::VENDOR_PREBUILT_PENDING`] / +/// [`vendor::VENDOR_PREBUILT_UNAVAILABLE`] as its code (an npm-family failed +/// `Done` as a warning) — while the ledger already holds /// `purl` vendored at another patch. The backend touched nothing, so that /// older vendoring is still in force: like hosted mode, which keeps its pin /// and skips the upgrade, the package is a benign skip under the unserved @@ -133,8 +134,9 @@ fn refusal_is_benign(code: &str) -> bool { /// (the [`Discovery::vendor_entry_live`] verdict `vendor --check` and `vex` /// use) is in force: once a relock dropped its `.socket/vendor/` reference /// the package is patched in neither mode, so the unserved upgrade stays a -/// failure. Any other outcome passes through, a failure minus the unserved -/// marker (its error already says it). +/// failure. Any other outcome passes through: an unserved refusal as the +/// `vendor_prebuilt_required` failure it always was, a failed `Done` minus +/// the unserved marker (its error already says it). /// /// [`Discovery::vendor_entry_live`]: socket_patch_core::vex::discover::Discovery::vendor_entry_live async fn keep_older_vendored_patch( @@ -144,49 +146,57 @@ async fn keep_older_vendored_patch( uuid: &str, common: &GlobalArgs, ) -> Option { - let Some(VendorOutcome::Done { - result, - entry, - mut warnings, - }) = outcome - else { - return outcome; - }; - if !result.success { - if let Some(i) = warnings.iter().position(|w| { - matches!( - w.code, - vendor::VENDOR_PREBUILT_PENDING | vendor::VENDOR_PREBUILT_UNAVAILABLE - ) - }) { - let unserved = warnings.remove(i); - if let Some(kept) = lookup_entry(&state.entries, purl).filter(|e| e.uuid != uuid) { - let root = common.project_root(); - if !crate::commands::discover_wiring(common, &root) - .await - .vendor_entry_live(&root, kept) - .await - { - return Some(VendorOutcome::Done { - result, - entry, - warnings, - }); - } - return Some(VendorOutcome::Refused { - code: unserved.code, - detail: format!( - "kept the vendored patch {}: {} for patch {uuid}", - kept.uuid, unserved.detail - ), + let (code, detail, failed_done) = match outcome { + Some(VendorOutcome::Refused { code, detail }) if vendor::is_unserved(code) => { + (code, detail, None) + } + Some(VendorOutcome::Done { + result, + entry, + mut warnings, + }) if !result.success => { + let Some(i) = warnings.iter().position(|w| vendor::is_unserved(w.code)) else { + return Some(VendorOutcome::Done { + result, + entry, + warnings, }); - } + }; + let unserved = warnings.remove(i); + ( + unserved.code, + unserved.detail, + Some((result, entry, warnings)), + ) + } + other => return other, + }; + if let Some(kept) = lookup_entry(&state.entries, purl).filter(|e| e.uuid != uuid) { + let root = common.project_root(); + if crate::commands::discover_wiring(common, &root) + .await + .vendor_entry_live(&root, kept) + .await + { + return Some(VendorOutcome::Refused { + code, + detail: format!( + "kept the vendored patch {}: {detail} for patch {uuid}", + kept.uuid + ), + }); } } - Some(VendorOutcome::Done { - result, - entry, - warnings, + Some(match failed_done { + Some((result, entry, warnings)) => VendorOutcome::Done { + result, + entry, + warnings, + }, + None => VendorOutcome::Refused { + code: vendor::VENDOR_PREBUILT_REQUIRED, + detail, + }, }) } @@ -299,6 +309,10 @@ async fn takeover_dry_refusal( .await; drop(probe); match outcome { + // A takeover has no older vendored patch to keep in force. + Some(VendorOutcome::Refused { code, detail }) if vendor::is_unserved(code) => { + Some((vendor::VENDOR_PREBUILT_REQUIRED, detail)) + } Some(VendorOutcome::Refused { code, detail }) if !refusal_is_benign(code) => { Some((code, detail)) } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index a95289285..897acb27e 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3514,13 +3514,25 @@ fn gem_fixture() -> GemFixture { /// [`GEM_PURL_QUALIFIED`]); the batch response's outer package purl stays the /// bare purl the crawler requested, mirroring production. async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { + mount_gem_patch_api_at(mock, patch_purl, GEM_UUID, None).await; +} + +/// [`mount_gem_patch_api`] serving patch `uuid`. With `unserved` set, the +/// patch service answers that status (`pending_build`, `build_failed`, …) +/// for the patch instead of serving its prebuilt `.gem`. +async fn mount_gem_patch_api_at( + mock: &wiremock::MockServer, + patch_purl: &str, + uuid: &str, + unserved: Option<&str>, +) { use base64::Engine as _; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, ResponseTemplate}; let fx = gem_fixture(); let record: socket_patch_core::manifest::schema::PatchRecord = serde_json::from_value(json!({ - "uuid": GEM_UUID, "exportedAt": "2026-01-01T00:00:00Z", + "uuid": uuid, "exportedAt": "2026-01-01T00:00:00Z", "files": { "lib/demo_gem.rb": { "beforeHash": compute_git_sha256_from_bytes(GEM_ORIG), "afterHash": compute_git_sha256_from_bytes(GEM_PATCHED) } }, "vulnerabilities": {}, "description": "gem vendor patch", "license": "MIT", "tier": "free" })).unwrap(); @@ -3532,14 +3544,29 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { blobs_path: fx.root(), mem_blobs: Some(&blobs), }; - prebuilt_common::mount_record( - mock, - patch_purl, - &record, - fx.installed_lib().parent().unwrap().parent().unwrap(), - &sources, - ) - .await; + match unserved { + None => { + prebuilt_common::mount_record( + mock, + patch_purl, + &record, + fx.installed_lib().parent().unwrap().parent().unwrap(), + &sources, + ) + .await + } + Some(status) => { + Mock::given(method("POST")) + .and(path_regex(r"/(patch|patches)/package$")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "results": { uuid: { "status": status } } })), + ) + .with_priority(1) + .mount(mock) + .await + } + } const ORG_SLUG: &str = "test-org"; /// The exact percent-encoded by-package path segment for the BARE purl — /// the spelling the crawler synthesizes and the CLI queries with (the @@ -3555,7 +3582,7 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { "packages": [{ "purl": GEM_PURL, "patches": [{ - "uuid": GEM_UUID, + "uuid": uuid, "purl": patch_purl, "tier": "free", "cveIds": ["CVE-2026-0002"], @@ -3574,7 +3601,7 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { ))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "patches": [{ - "uuid": GEM_UUID, + "uuid": uuid, "purl": patch_purl, "publishedAt": "2026-01-01T00:00:00Z", "description": "gem vendor patch", @@ -3587,9 +3614,9 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { .mount(mock) .await; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{GEM_UUID}"))) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "uuid": GEM_UUID, + "uuid": uuid, "purl": patch_purl, "publishedAt": "2026-01-01T00:00:00Z", "files": { @@ -3674,6 +3701,101 @@ async fn scan_vendor_gem_refuses_a_gitignored_ledger() { } } +/// A gem `scan --mode vendored` run at patch A, then the bytes of the +/// files that wiring owns: Gemfile, lock, ledger and the vendored lib. +async fn gem_vendored_at_a() -> (GemFixture, [Vec; 4]) { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL).await; + let fx = gem_fixture(); + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(code, 0, "vendoring patch A must succeed: {env:#}"); + let wired = gem_wired_bytes(&fx); + (fx, wired) +} + +fn gem_wired_bytes(fx: &GemFixture) -> [Vec; 4] { + [ + std::fs::read(fx.gemfile_path()).unwrap(), + std::fs::read(fx.lock_path()).unwrap(), + std::fs::read(fx.state_path()).unwrap(), + std::fs::read(fx.vendored_lib()).unwrap(), + ] +} + +/// The superseding patch of the demo gem in the #1235 tests. +const GEM_UUID2: &str = "36363636-3636-4636-8636-363636363636"; + +/// #1235: the #954 skip holds for every vendored backend, not only the npm +/// family. A gem vendored at patch A, re-scanned once the API offers a +/// superseding patch B whose prebuilt `.gem` the service is still building +/// (`pending_build`) or cannot serve (`build_failed`, `not_found`), keeps A +/// in force: one `skipped` event under the unserved code naming both +/// patches, exit 0, and the Gemfile, lock, ledger and artifact untouched. +#[tokio::test] +async fn scan_vendor_gem_keeps_the_older_patch_while_the_superseding_one_is_unserved() { + for (status, code) in [ + ("pending_build", "vendor_prebuilt_pending"), + ("build_failed", "vendor_prebuilt_unavailable"), + ("not_found", "vendor_prebuilt_unavailable"), + ] { + let (fx, wired) = gem_vendored_at_a().await; + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api_at(&mock, GEM_PURL, GEM_UUID2, Some(status)).await; + let (exit, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(exit, 0, "{status}: the older patch is kept: {env:#}"); + let skipped = find_event(&env, "skipped", Some(code)); + assert_eq!(skipped["purl"], GEM_PURL, "{status}: {skipped}"); + assert!( + skipped.to_string().contains(GEM_UUID) && skipped.to_string().contains(GEM_UUID2), + "{status}: names the kept and the unserved patch: {skipped}" + ); + assert_eq!(env["summary"]["failed"], 0, "{status}: {env:#}"); + assert_eq!(gem_wired_bytes(&fx), wired, "{status}: wiring untouched"); + } +} + +/// The #1235 skip keeps only an older vendoring that is still wired: once +/// the Gemfile pair no longer points at `.socket/vendor/` the gem is patched +/// in neither mode, so the unserved upgrade still fails with the stable +/// `vendor_prebuilt_required` code, and no unserved marker leaks out. +#[tokio::test] +async fn scan_vendor_gem_unserved_upgrade_fails_when_the_old_patch_is_unwired() { + let (fx, _) = gem_vendored_at_a().await; + std::fs::write(fx.gemfile_path(), GEM_GEMFILE).unwrap(); + std::fs::write(fx.lock_path(), GEM_LOCK).unwrap(); + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api_at(&mock, GEM_PURL, GEM_UUID2, Some("pending_build")).await; + let (exit, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(exit, 1, "an unwired older patch is not kept: {env:#}"); + let failed = find_event(&env, "failed", Some("vendor_prebuilt_required")); + assert_eq!(failed["purl"], GEM_PURL); + assert!(failed.to_string().contains("still building"), "{failed}"); + assert!( + events(&env) + .iter() + .all(|e| e["errorCode"] != "vendor_prebuilt_pending"), + "no unserved marker leaks out of a real failure: {env:#}" + ); +} + +/// A first gem vendor whose `.gem` is still building has no older patch to +/// keep: it fails with the stable `vendor_prebuilt_required` code (#1235). +#[tokio::test] +async fn scan_vendor_gem_unserved_first_vendor_still_fails() { + let fx = gem_fixture(); + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api_at(&mock, GEM_PURL, GEM_UUID, Some("pending_build")).await; + let (exit, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(exit, 1, "{env:#}"); + let failed = find_event(&env, "failed", Some("vendor_prebuilt_required")); + assert!(failed.to_string().contains("still building"), "{failed}"); + assert_eq!( + std::fs::read_to_string(fx.gemfile_path()).unwrap(), + GEM_GEMFILE + ); + assert_eq!(std::fs::read_to_string(fx.lock_path()).unwrap(), GEM_LOCK); +} + /// `scan --mode vendored` end to end on a gem project: discover → download → /// vendor lands the gem pair edit (Gemfile pin + `path:`, lock PATH section /// + `(= …)!` DEPENDENCIES pin) and the patched artifact dir, keyed in the diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 48ed75522..95e27e8d5 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -4247,7 +4247,7 @@ mod tests { )), ) .await; - let detail = expect_refused(outcome, "vendor_prebuilt_required"); + let detail = expect_refused(outcome, "vendor_prebuilt_pending"); assert!(detail.contains("still building"), "{detail}"); assert!(!root.join(".socket/vendor").exists(), "no vendor debris"); assert!(!root.join(".cargo").exists(), "nothing wired"); @@ -4286,7 +4286,7 @@ mod tests { )), ) .await; - let detail = expect_refused(outcome, "vendor_prebuilt_required"); + let detail = expect_refused(outcome, "vendor_prebuilt_unavailable"); assert!( detail.contains("prebuilt crate unavailable: not_found"), "{detail}" diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 64d424584..5a23aa60d 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -3000,7 +3000,7 @@ mod tests { ) .await, ); - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_pending"); assert!(detail.contains("still building"), "{detail}"); assert!(!root.join(".socket").exists(), "nothing written"); assert_eq!( @@ -3053,7 +3053,7 @@ mod tests { ) .await, ); - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_unavailable"); assert!(detail.contains("unavailable"), "{detail}"); assert!(!root.join(".socket").exists(), "nothing written"); } diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 04fc21e60..e568ee977 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -6697,7 +6697,7 @@ mod tests { let (code, detail) = unwrap_refused(run_vendor_service(&root, &blobs, &installed, &record, &cfg).await); - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_pending"); assert!(detail.contains("still building"), "{detail}"); assert!(!root.join(".socket").exists()); assert_eq!( @@ -6734,7 +6734,7 @@ mod tests { let (code, detail) = unwrap_refused(run_vendor_service(&root, &blobs, &installed, &record, &cfg).await); - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_unavailable"); assert!(detail.contains("unavailable"), "{detail}"); assert!(!root.join(".socket").exists()); } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index cbe6cafb0..f4f5d2c84 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -2290,7 +2290,7 @@ mod tests { Some(&go_service_cfg(&server.uri(), VendorSource::Service, false)), ) .await; - let detail = expect_refused(outcome, "vendor_prebuilt_required"); + let detail = expect_refused(outcome, "vendor_prebuilt_pending"); assert!(detail.contains("still building"), "{detail}"); assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); assert_eq!( @@ -2324,7 +2324,7 @@ mod tests { Some(&go_service_cfg(&server.uri(), VendorSource::Service, false)), ) .await; - let detail = expect_refused(outcome, "vendor_prebuilt_required"); + let detail = expect_refused(outcome, "vendor_prebuilt_unavailable"); assert!( detail.contains("prebuilt module zip unavailable: not_found"), "{detail}" diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index b2ab62ebd..27a0c83d6 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -679,17 +679,28 @@ fn harvest_zip_blobs(path: &Path, wanted: &[(String, String)]) -> HashMap bool { + matches!(code, VENDOR_PREBUILT_PENDING | VENDOR_PREBUILT_UNAVAILABLE) +} /// The result of one backend `vendor_*` call. // diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 213ace3d4..b6252a5d1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -4891,7 +4891,7 @@ mod tests { ) .await; let (code, detail) = unwrap_refused(outcome); - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_unavailable"); assert!(detail.contains("unavailable"), "{detail}"); assert!( !root.join(".socket").exists(), diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index e7d72eacc..0a43c1269 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -10594,7 +10594,7 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hash = "sha256:upstre let VendorOutcome::Refused { code, detail } = outcome else { panic!("expected Refused, got {outcome:?}"); }; - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_pending"); assert!(detail.contains("still building"), "{detail}"); assert!(!fx.root.join(".socket").exists()); assert_eq!(read_requirements(&fx).await, "six==1.16.0\n"); @@ -10644,7 +10644,7 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hash = "sha256:upstre let VendorOutcome::Refused { code, detail } = outcome else { panic!("expected Refused, got {outcome:?}"); }; - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_unavailable"); assert!(detail.contains("unavailable: not_found"), "{detail}"); assert!(!fx.root.join(".socket").exists()); } diff --git a/crates/socket-patch-core/src/vendor/redownload.rs b/crates/socket-patch-core/src/vendor/redownload.rs index e864b2564..cb58d9833 100644 --- a/crates/socket-patch-core/src/vendor/redownload.rs +++ b/crates/socket-patch-core/src/vendor/redownload.rs @@ -15,6 +15,10 @@ use super::{VendorOutcome, VendorServiceConfig, VendorWarning}; fn detail(outcome: VendorOutcome) -> String { match outcome { + // A restore has no older patch to keep: unserved is the plain miss. + VendorOutcome::Refused { code, detail } if super::is_unserved(code) => { + format!("{}: {detail}", super::VENDOR_PREBUILT_REQUIRED) + } VendorOutcome::Refused { code, detail } => format!("{code}: {detail}"), VendorOutcome::Done { result, .. } => result .error diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index b4b111ec8..2749ec56a 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -208,11 +208,14 @@ impl ServicePolicy<'_> { }) } - /// npm must distinguish unserved patches from failures so the vendor loop - /// can keep an older vendored patch of the same package (#954). + /// Every backend distinguishes unserved patches from failures so the + /// vendor loop can keep an older vendored patch of the same package + /// (#954, #1235): a refusal carries the unserved `code` itself, npm's + /// failed `Done` carries it as a warning. The vendor loop reports a + /// refusal with no older patch to keep as `vendor_prebuilt_required`. fn unserved(&self, code: &'static str, detail: String) -> Box { match self { - Self::Refused => self.miss(detail), + Self::Refused => Box::new(refused(code, detail)), Self::Failure(purl) => { let warning = VendorWarning::new(code, detail.clone()); Box::new(super::common::done( @@ -660,7 +663,7 @@ mod tests { { Err(outcome) => match *outcome { VendorOutcome::Refused { code, detail } => { - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_pending"); assert!(detail.contains("is still building"), "{detail}"); } other => panic!("expected Refused, got {other:?}"), @@ -688,7 +691,7 @@ mod tests { { Err(outcome) => match *outcome { VendorOutcome::Refused { code, detail } => { - assert_eq!(code, "vendor_prebuilt_required"); + assert_eq!(code, "vendor_prebuilt_unavailable"); assert_eq!(detail, "prebuilt .jar unavailable: not_found"); } other => panic!("expected Refused, got {other:?}"),