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 `==
): ` 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:?}"),