Skip to content

Commit fce6fe6

Browse files
Harden vendored-mode trust in patch-service artifacts (#249)
* fix(vendor): refresh the ledger fingerprint when a wired gem/maven/nuget artifact is rebuilt When the hot path found the committed artifact missing or stale, it rebuilt it but returned no entry, so the ledger kept the previous fingerprint: the gem file inventory, the maven/nuget sha256, and the nuget lock pin. A rebuild from the other source (service vs local) then produced bytes the ledger did not describe. VEX and verify reported tamper, repair of a service-vendored maven/nuget entry could fail, --revert left packages.lock.json pinned to the patched contentHash, and a service-sourced rebuild was labelled already_vendored. The rebuild branches now return a refreshed entry built from the new bytes or tree, with no wiring of their own. For nuget the entry carries only the re-pinned lock record, with original: None. carry_forward_wiring (same uuid) re-attaches the first run's records and fills in the true pre-vendor hash. The CLI does not record such an entry when the ledger has no previous one, because it would carry no wiring and --revert would delete the artifact while the project still points at it. repair carries the repaired entry's wiring forward before persisting, and emits vendor_inventory_refreshed when the backend's refreshed inventory differs from the recorded one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): verify a served maven/nuget archive carries the patch before accepting it service_archive_copy (the Tier-A maven .jar / nuget .nupkg path) accepted any archive that matched its SRI. The bytes were written verbatim and every patched file was reported AlreadyPatched, so a served archive without the patch was committed as patched. The hot path then saw a stale artifact on every later run and rebuilt it. The helper now takes the PatchRecord and requires every patched member to hash to its afterHash (zip_bytes_match_after_hashes, the check the hot path already uses) before returning Used. A mismatch is a service miss: auto falls back to the local build with vendor_prebuilt_layout_mismatch, and service refuses with vendor_prebuilt_required. The vendor_prebuilt_downloaded advisory is only pushed for accepted bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): refuse a prebuilt artifact that fails integrity under auto too ServiceArtifact documents IntegrityMismatch as always a hard error, but golang, composer, pypi, npm and gem (the .gem and the stub gemspec) mapped it through their miss policy. Under auto they warned and built the package locally, so a sign of tampering became a quiet fallback. Under service they refused with the generic vendor_prebuilt_required code. These arms now refuse in every mode, as cargo already did: golang, composer, pypi and gem return vendor_prebuilt_integrity_mismatch, and npm fails the package with the integrity detail. The npm test that pinned the old fallback is replaced by one that expects the refusal. The service-mode tests for these ecosystems now expect the specific code. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): refuse --vendor-source=service when no API client is configured Every backend's service helper treats !service_enabled() as "build locally", and service_enabled() is false without a client. The service-only policy was enforced by common::service_offline_conflict, which checked only --offline. So a VendorServiceConfig with source: Service and client: None built the artifact locally in every backend, contradicting service's fail-closed promise. The CLI always passes a client, so only library callers could reach this. The gate every backend already calls at its entry point now also refuses that combination (vendor_prebuilt_required), before any service consultation or write. Regression tests cover all eight backends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): route the cargo wired-copy rebuild through --vendor-source The in-sync hot path rebuilt a missing/stale crate copy with copy_and_patch directly, so `--vendor-source=service` with --offline or no API client rebuilt locally and reported success, and online service mode never consulted the patch service. Refuse via service_offline_conflict first and prefer cargo_service_copy, falling back to the local build only where the policy allows, as composer and gem already do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): verify a served pypi wheel / npm tarball carries the patch The service SRI proves only that the download is intact. A wheel or tarball whose patched members still held the original bytes was written as-is, reported as already patched, and pinned in the lockfile. Check each patched member against its afterHash before using the artifact: auto builds locally with vendor_prebuilt_layout_mismatch, service refuses (pypi: vendor_prebuilt_required; npm fails the package). Adds read_archive_bytes_to_map for the in-memory tarball check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(vendor): only record a rebuilt entry over a same-uuid predecessor A wiring-less refreshed entry relies on carry_forward_wiring, which re-attaches wiring only from a same-uuid ledger entry. Over an entry from another patch uuid (the run that wired this uuid never saved), the guard let it through, saving an entry --revert could not unwire and sweeping the other uuid's dir. Leave the ledger as it is instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 15a6ba6 commit fce6fe6

17 files changed

Lines changed: 1730 additions & 194 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,59 @@ into the new version's section — see docs/releasing.md.
639639
contract; previously the entry was deleted, stranding a live ledger
640640
entry with no backing record. An all-kept run exits 1 `partialFailure`
641641
with `summary.removed: 0` (never `not_found` — the identifier matched).
642+
- **Rebuilding a missing gem, maven or nuget vendored artifact now updates
643+
the ledger.** When `vendor` / `scan --vendor` found a wired project whose
644+
committed artifact was missing or broken, it rebuilt the artifact but kept
645+
the old fingerprint in `.socket/vendor/state.json` (the gem file
646+
inventory, the maven/nuget `sha256`, and the nuget `packages.lock.json`
647+
pin). If the rebuild came from the other source (the patch service instead
648+
of a local build, or the reverse), the new bytes no longer matched the
649+
ledger. VEX and verification then reported the artifact as tampered,
650+
`repair` could fail, and `vendor --revert` left `packages.lock.json`
651+
pinned to the patched `contentHash`. A rebuild from the patch service was
652+
also reported as `already_vendored` instead of `applied`. The rebuild now
653+
records the new fingerprint and keeps the entry's original wiring records,
654+
so revert still restores the pre-vendor files. If `state.json` has no
655+
entry for the package, or only an entry from another patch uuid, the
656+
rebuild still runs but the ledger is left as it is, because the run has no
657+
pre-vendor originals to record.
658+
- **A prebuilt maven `.jar`, nuget `.nupkg`, pypi wheel or npm tarball from
659+
the patch service must now contain the patched files.** Checking its integrity hash only showed that
660+
the download was intact, not that the archive carried the patch. The
661+
archive was still written as-is and every file was reported as already
662+
patched, so an unpatched archive could be committed and then rebuilt on
663+
every run. Each patched file inside the archive is now checked against the
664+
patch's expected hash before the archive is used. On a mismatch, `auto`
665+
builds the archive locally and warns `vendor_prebuilt_layout_mismatch`,
666+
and `--vendor-source=service` refuses with `vendor_prebuilt_required` (npm
667+
fails the package with the detail).
668+
- **A prebuilt artifact that fails its integrity check is always refused.**
669+
Under the default `--vendor-source=auto`, npm, pypi, golang, composer and
670+
gem (both the `.gem` and its stub gemspec) printed a warning and built the
671+
package locally when the downloaded bytes did not match the integrity the
672+
patch service reported. Bytes that fail verification may have been
673+
tampered with, so these ecosystems now refuse the package in every mode,
674+
as cargo, maven and nuget already did. The refusal code is
675+
`vendor_prebuilt_integrity_mismatch` (npm fails the package with the
676+
integrity detail). Under `--vendor-source=service`, golang, composer, gem
677+
and pypi now report `vendor_prebuilt_integrity_mismatch` instead of
678+
`vendor_prebuilt_required`.
679+
- **`service` vendor source without an API client is refused.** This affects
680+
`socket-patch-core` callers that pass a `VendorServiceConfig` with
681+
`source: Service` and no `client` (the CLI always configures a client).
682+
Every backend used to build the artifact locally in that case, even though
683+
`service` promises that only the patch service's artifact is used. They now
684+
refuse with `vendor_prebuilt_required` before doing any work, the same way
685+
`--offline` is already refused.
686+
- **Rebuilding a missing cargo vendored copy now honours
687+
`--vendor-source`.** When a wired project's committed crate copy was
688+
missing or stale, `vendor` always rebuilt it locally from the installed
689+
source. Under `--vendor-source=service` it did so even with `--offline`
690+
or without an API client, and reported success. The rebuild now uses the
691+
patch service's prebuilt crate like a fresh vendor does, so `service`
692+
mode refuses (`vendor_service_offline_conflict` / `vendor_prebuilt_required`)
693+
when the service cannot be used, and `auto` still builds locally when it
694+
has no prebuilt crate.
642695

643696
### Changed
644697

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,12 +472,14 @@ per service outcome:
472472
| Service outcome | `auto` | `service` |
473473
|---|---|---|
474474
| granted/reused, integrity ok | **use service** | **use service** |
475-
| integrity mismatch | cargo/maven/nuget: **refuse** (`vendor_prebuilt_integrity_mismatch`) — tampered bytes never fall back; other ecosystems (to be aligned): local build + `vendor_prebuilt_integrity_mismatch` | refuse (cargo/maven/nuget: `vendor_prebuilt_integrity_mismatch`; others: `vendor_prebuilt_required`) |
475+
| integrity mismatch (including the gem stub gemspec) | **refuse** (`vendor_prebuilt_integrity_mismatch`; npm: the package fails with the integrity detail). Tampered bytes never fall back to a local build | refuse (same) |
476+
| integrity ok, but the archive does not carry the patched files (a member at a recorded path fails its `afterHash`; checked for cargo/golang/composer/gem after extraction, and for maven/nuget/pypi/npm before the archive is written; npm under `service` fails the package with the detail) | local build + `vendor_prebuilt_layout_mismatch` | refuse (`vendor_prebuilt_required`) |
476477
| still building (`pending_build` / serve 408) | local build + `vendor_prebuilt_pending` | refuse |
477478
| not built / withdrawn / not found / no usable artifact | local build (quiet) | refuse |
478479
| gem stub gemspec missing / invalid | local build + `vendor_prebuilt_stub_missing` / `vendor_prebuilt_stub_invalid` (invalid + gem not installed: refuse `vendor_prebuilt_stub_invalid` — no stub source exists) | refuse (`vendor_prebuilt_required` / `vendor_prebuilt_stub_invalid`) |
479480
| 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse |
480481
| `--offline` | local build | refuse (`vendor_service_offline_conflict`) |
482+
| no API client configured (library callers of the vendor engine; the CLI always configures one) | local build | refuse (`vendor_prebuilt_required`) |
481483

482484
**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `<copy>.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive (previously both were torn down), while a missing copy still drops the dangling directive.
483485

@@ -1123,7 +1125,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
11231125
| `vendor_fetch_unverifiable` | `skipped` (warning) | vendor: the lockfile records no usable integrity for the missing package; nothing was fetched (fail-closed) and the `package_not_installed` skip follows. |
11241126
| `vendor_artifact_missing` | `skipped` (warning) / `failed` | vendor: the committed artifact is gone — the registry resolution is recovered from the ledger and the artifact rebuilt (warning); repair `--offline` with no local source surfaces it as the per-entry failure instead. |
11251127
| `vendor_artifact_corrupt` | `failed` | repair `--offline`: the committed artifact fails verification (member afterHashes or the ledger's whole-file sha256) and no local source can rebuild it. Online repairs rebuild instead. |
1126-
| `vendor_artifact_rebuilt` | `skipped` (warning) | vendor / scan `--vendor`: a wired-but-missing/stale artifact was rebuilt in place; lockfiles and the ledger entry untouched. (Under `repair` the `rebuilt` event carries this signal.) |
1128+
| `vendor_artifact_rebuilt` | `skipped` (warning) | vendor / scan `--vendor`: a wired-but-missing/stale artifact was rebuilt in place. The lockfiles are untouched, except that nuget re-pins `packages.lock.json` to the rebuilt bytes. gem/maven/nuget: the package's event is `applied` (also for a rebuild from the patch service), and the ledger entry's artifact fingerprint (gem `fileInventory`, maven/nuget `sha256` + `size`, and the nuget lock pin) is refreshed to the rebuilt bytes, and its wiring records are kept unchanged, so `--revert` still restores the pre-vendor files. A rebuild whose ledger has no entry for the package, or only one from another patch uuid, records none. cargo/composer/gem rebuilds honour `--vendor-source` like a fresh vendor (`service` downloads the prebuilt artifact and refuses when it cannot). Other ecosystems leave the ledger entry untouched. (Under `repair` the `rebuilt` event carries this signal.) |
11271129
| `vendor_artifact_rebuild_failed` | `failed` | repair: the rebuild ran but the result failed verification against the recorded fingerprint (e.g. an edited state.json sha); the unverifiable artifact was removed. |
11281130
| `vendor_artifact_unrepairable` | `failed` | repair: no verifiable pristine source exists (not installed + lockfile rewired + no recoverable ledger fragment), the wheel is platform-locked with no installed copy, or the ledger entry itself cannot be trusted. |
11291131
| `vendor_uuid_mismatch` | `skipped` | repair: the manifest's patch uuid moved past the vendored artifact — a re-vendor (`vendor` / `scan --vendor`) is pending; repair does not cross patch generations. |

‎crates/socket-patch-cli/src/commands/repair_vendor.rs‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,30 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
15411541
// fingerprint computed from the rebuilt bytes.
15421542
let from_backend = entry.is_some();
15431543
let mut check_entry = entry.unwrap_or_else(|| c.entry.clone());
1544+
// An artifact-only rebuild hands back a refreshed entry with
1545+
// no wiring of its own: re-attach the repaired entry's
1546+
// records (a reconstructed entry is not in the ledger yet,
1547+
// so the persist below has nothing to carry them from).
1548+
if from_backend {
1549+
vendor::carry_forward_wiring(&c.entry, &mut check_entry);
1550+
}
1551+
// The backend's refreshed entry already re-inventoried the
1552+
// member-verified rebuild; a changed inventory is the same
1553+
// provenance flip the post-verify refresh below reports.
1554+
if from_backend
1555+
&& c.entry.artifact.file_inventory.is_some()
1556+
&& check_entry.artifact.file_inventory != c.entry.artifact.file_inventory
1557+
{
1558+
record_warning(
1559+
env,
1560+
&c.purl,
1561+
&VendorWarning::new(
1562+
"vendor_inventory_refreshed",
1563+
INVENTORY_REFRESHED_DETAIL,
1564+
),
1565+
common,
1566+
);
1567+
}
15441568
if !from_backend && c.reconstructed {
15451569
fill_artifact_fingerprint(&common.cwd, &mut check_entry).await;
15461570
}
@@ -1588,13 +1612,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
15881612
&c.purl,
15891613
&VendorWarning::new(
15901614
"vendor_inventory_refreshed",
1591-
"the rebuilt artifact's patched files verify but its \
1592-
tree differs from the recorded file inventory (the \
1593-
entry was likely vendored from the patch service's \
1594-
prebuilt artifact; repair rebuilds locally); the \
1595-
inventory was refreshed from the verified rebuild — \
1596-
run `socket-patch vendor` to restore the \
1597-
service-built tree",
1615+
INVENTORY_REFRESHED_DETAIL,
15981616
),
15991617
common,
16001618
);
@@ -1660,6 +1678,15 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
16601678
rebuilt
16611679
}
16621680

1681+
/// Detail of the `vendor_inventory_refreshed` advisory.
1682+
const INVENTORY_REFRESHED_DETAIL: &str = "the rebuilt artifact's patched files verify but its \
1683+
tree differs from the recorded file inventory (the \
1684+
entry was likely vendored from the patch service's \
1685+
prebuilt artifact; repair rebuilds locally); the \
1686+
inventory was refreshed from the verified rebuild — \
1687+
run `socket-patch vendor` to restore the \
1688+
service-built tree";
1689+
16631690
/// Compute and record the artifact fingerprint on a re-synthesized ledger
16641691
/// entry: sha256 + size for file-shaped artifacts, the whole-tree file
16651692
/// inventory for dir-shaped ones. An uninventoriable dir stays `None` —

‎crates/socket-patch-cli/src/commands/vendor.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1781,6 +1781,23 @@ pub(crate) async fn vendor_records(
17811781
record_warning(env, candidate, w, common);
17821782
}
17831783
}
1784+
// An artifact-only rebuild hands back a refreshed
1785+
// fingerprint with no wiring of its own: it relies on
1786+
// the ledger entry it replaces for the pre-vendor
1787+
// originals, and `carry_forward_wiring` re-attaches them
1788+
// only from a SAME-uuid predecessor. With no such entry
1789+
// (none at all, or one from another patch generation),
1790+
// recording it would give `--revert` an entry that
1791+
// deletes the artifact yet cannot unwire the project —
1792+
// leave the ledger as is.
1793+
let rebuilt = warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt");
1794+
let entry = entry.filter(|e| {
1795+
!rebuilt
1796+
|| state
1797+
.entries
1798+
.get(candidate.as_str())
1799+
.is_some_and(|prev| prev.uuid == e.uuid)
1800+
});
17841801
if let Some(entry) = entry {
17851802
if let Some(flavor) = entry.flavor.as_deref() {
17861803
wired_flavors.insert(flavor.to_string());

‎crates/socket-patch-cli/tests/in_process_vendor.rs‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2234,6 +2234,129 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() {
22342234
assert!(!fx.root().join(".socket/vendor").exists());
22352235
}
22362236

2237+
/// A wired gem whose committed copy went missing is rebuilt artifact-only,
2238+
/// and the ledger entry the run persists must still describe it: the
2239+
/// refreshed fingerprint (inventory) verifies against the rebuilt tree and
2240+
/// the first run's pair-edit records ride along, so `vendor --revert` still
2241+
/// byte-restores both files.
2242+
#[tokio::test]
2243+
async fn scan_vendor_gem_artifact_rebuild_keeps_ledger_verifiable_and_revertable() {
2244+
let mock = wiremock::MockServer::start().await;
2245+
mount_gem_patch_api(&mock, GEM_PURL).await;
2246+
let fx = gem_fixture();
2247+
let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2248+
assert_eq!(code, 0, "first vendor: {env:#}");
2249+
let state1: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
2250+
2251+
std::fs::remove_file(fx.vendored_lib()).unwrap();
2252+
let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2253+
assert_eq!(code, 0, "rebuild run: {env2:#}");
2254+
assert_eq!(std::fs::read(fx.vendored_lib()).unwrap(), GEM_PATCHED);
2255+
let state2: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
2256+
let entry2 = &state2["entries"][GEM_PURL];
2257+
assert_eq!(
2258+
entry2["wiring"], state1["entries"][GEM_PURL]["wiring"],
2259+
"the pair-edit revert records survive the artifact-only rebuild"
2260+
);
2261+
assert!(
2262+
entry2["artifact"]["fileInventory"].is_object(),
2263+
"the rebuilt tree is inventoried: {entry2:#}"
2264+
);
2265+
2266+
// `repair` re-checks every ledger fingerprint: nothing to rebuild.
2267+
let root = fx.root().to_str().unwrap();
2268+
let (code, stdout, stderr) = run_cli(
2269+
fx.root(),
2270+
&["repair", "--json", "--dry-run", "--offline", "--cwd", root],
2271+
&[],
2272+
);
2273+
assert_eq!(code, 0, "repair --dry-run: {stdout}\n{stderr}");
2274+
assert!(
2275+
!stdout.contains("wouldRebuild"),
2276+
"the persisted fingerprint must verify: {stdout}"
2277+
);
2278+
2279+
let (code, renv) = vendor_cli(fx.root(), &["--revert"]);
2280+
assert_eq!(code, 0, "revert: {renv:#}");
2281+
assert_eq!(
2282+
std::fs::read(fx.gemfile_path()).unwrap(),
2283+
GEM_GEMFILE.as_bytes()
2284+
);
2285+
assert_eq!(std::fs::read(fx.lock_path()).unwrap(), GEM_LOCK.as_bytes());
2286+
}
2287+
2288+
/// The same artifact-only rebuild with NO ledger entry to refresh (the
2289+
/// state file was lost) must not invent one: the refreshed entry carries
2290+
/// no wiring of its own, so recording it would give `vendor --revert` an
2291+
/// entry that deletes the copy while the Gemfile still points at it.
2292+
#[tokio::test]
2293+
async fn scan_vendor_gem_artifact_rebuild_without_ledger_entry_records_none() {
2294+
let mock = wiremock::MockServer::start().await;
2295+
mount_gem_patch_api(&mock, GEM_PURL).await;
2296+
let fx = gem_fixture();
2297+
let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2298+
assert_eq!(code, 0, "first vendor: {env:#}");
2299+
2300+
std::fs::remove_file(fx.state_path()).unwrap();
2301+
std::fs::remove_file(fx.vendored_lib()).unwrap();
2302+
let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2303+
assert_eq!(code, 0, "rebuild run: {env2:#}");
2304+
assert_eq!(
2305+
std::fs::read(fx.vendored_lib()).unwrap(),
2306+
GEM_PATCHED,
2307+
"the copy is still rebuilt"
2308+
);
2309+
let recorded = std::fs::read(fx.state_path())
2310+
.ok()
2311+
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
2312+
.map(|s| !s["entries"][GEM_PURL].is_null())
2313+
.unwrap_or(false);
2314+
assert!(!recorded, "no wiring-less ledger entry invented: {env2:#}");
2315+
}
2316+
2317+
/// The same rebuild when the ledger entry belongs to ANOTHER patch
2318+
/// generation (the run that wired this uuid never saved its entry): the
2319+
/// refreshed entry cannot inherit that entry's wiring, so recording it would
2320+
/// leave `vendor --revert` unable to unwire the Gemfile. The ledger keeps
2321+
/// the other-uuid entry, wiring intact.
2322+
#[tokio::test]
2323+
async fn scan_vendor_gem_artifact_rebuild_over_other_uuid_entry_keeps_ledger() {
2324+
let mock = wiremock::MockServer::start().await;
2325+
mount_gem_patch_api(&mock, GEM_PURL).await;
2326+
let fx = gem_fixture();
2327+
let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2328+
assert_eq!(code, 0, "first vendor: {env:#}");
2329+
2330+
let mut state: Value =
2331+
serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
2332+
let other = "99999999-9999-4999-8999-999999999999";
2333+
state["entries"][GEM_PURL]["uuid"] = Value::String(other.to_string());
2334+
let wiring = state["entries"][GEM_PURL]["wiring"].clone();
2335+
assert!(
2336+
wiring.as_array().is_some_and(|w| !w.is_empty()),
2337+
"fixture entry is wired: {state:#}"
2338+
);
2339+
std::fs::write(fx.state_path(), serde_json::to_vec_pretty(&state).unwrap()).unwrap();
2340+
std::fs::remove_file(fx.vendored_lib()).unwrap();
2341+
2342+
let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
2343+
assert_eq!(code, 0, "rebuild run: {env2:#}");
2344+
assert_eq!(
2345+
std::fs::read(fx.vendored_lib()).unwrap(),
2346+
GEM_PATCHED,
2347+
"the copy is still rebuilt"
2348+
);
2349+
let after: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
2350+
assert_eq!(
2351+
after["entries"][GEM_PURL]["uuid"], other,
2352+
"the other-uuid entry is not replaced: {env2:#}"
2353+
);
2354+
assert_eq!(
2355+
after["entries"][GEM_PURL]["wiring"], wiring,
2356+
"its wiring survives: {env2:#}"
2357+
);
2358+
}
2359+
22372360
// ─────────────────────────────────────────────────────────────────────
22382361
// hosted → vendored mode conversion (takeover reconciliation, pnpm v9)
22392362
// ─────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)