Skip to content
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,59 @@ into the new version's section β€” see docs/releasing.md.
contract; previously the entry was deleted, stranding a live ledger
entry with no backing record. An all-kept run exits 1 `partialFailure`
with `summary.removed: 0` (never `not_found` β€” the identifier matched).
- **Rebuilding a missing gem, maven or nuget vendored artifact now updates
the ledger.** When `vendor` / `scan --vendor` found a wired project whose
committed artifact was missing or broken, it rebuilt the artifact but kept
the old fingerprint in `.socket/vendor/state.json` (the gem file
inventory, the maven/nuget `sha256`, and the nuget `packages.lock.json`
pin). If the rebuild came from the other source (the patch service instead
of a local build, or the reverse), the new bytes no longer matched the
ledger. VEX and verification then reported the artifact as tampered,
`repair` could fail, and `vendor --revert` left `packages.lock.json`
pinned to the patched `contentHash`. A rebuild from the patch service was
also reported as `already_vendored` instead of `applied`. The rebuild now
records the new fingerprint and keeps the entry's original wiring records,
so revert still restores the pre-vendor files. If `state.json` has no
entry for the package, or only an entry from another patch uuid, the
rebuild still runs but the ledger is left as it is, because the run has no
pre-vendor originals to record.
- **A prebuilt maven `.jar`, nuget `.nupkg`, pypi wheel or npm tarball from
the patch service must now contain the patched files.** Checking its integrity hash only showed that
the download was intact, not that the archive carried the patch. The
archive was still written as-is and every file was reported as already
patched, so an unpatched archive could be committed and then rebuilt on
every run. Each patched file inside the archive is now checked against the
patch's expected hash before the archive is used. On a mismatch, `auto`
builds the archive locally and warns `vendor_prebuilt_layout_mismatch`,
and `--vendor-source=service` refuses with `vendor_prebuilt_required` (npm
fails the package with the detail).
- **A prebuilt artifact that fails its integrity check is always refused.**
Under the default `--vendor-source=auto`, npm, pypi, golang, composer and
gem (both the `.gem` and its stub gemspec) printed a warning and built the
package locally when the downloaded bytes did not match the integrity the
patch service reported. Bytes that fail verification may have been
tampered with, so these ecosystems now refuse the package in every mode,
as cargo, maven and nuget already did. The refusal code is
`vendor_prebuilt_integrity_mismatch` (npm fails the package with the
integrity detail). Under `--vendor-source=service`, golang, composer, gem
and pypi now report `vendor_prebuilt_integrity_mismatch` instead of
`vendor_prebuilt_required`.
- **`service` vendor source without an API client is refused.** This affects
`socket-patch-core` callers that pass a `VendorServiceConfig` with
`source: Service` and no `client` (the CLI always configures a client).
Every backend used to build the artifact locally in that case, even though
`service` promises that only the patch service's artifact is used. They now
refuse with `vendor_prebuilt_required` before doing any work, the same way
`--offline` is already refused.
- **Rebuilding a missing cargo vendored copy now honours
`--vendor-source`.** When a wired project's committed crate copy was
missing or stale, `vendor` always rebuilt it locally from the installed
source. Under `--vendor-source=service` it did so even with `--offline`
or without an API client, and reported success. The rebuild now uses the
patch service's prebuilt crate like a fresh vendor does, so `service`
mode refuses (`vendor_service_offline_conflict` / `vendor_prebuilt_required`)
when the service cannot be used, and `auto` still builds locally when it
has no prebuilt crate.

### Changed

Expand Down
6 changes: 4 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,12 +472,14 @@ per service outcome:
| Service outcome | `auto` | `service` |
|---|---|---|
| granted/reused, integrity ok | **use service** | **use service** |
| 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`) |
| 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) |
| 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`) |
| still building (`pending_build` / serve 408) | local build + `vendor_prebuilt_pending` | refuse |
| not built / withdrawn / not found / no usable artifact | local build (quiet) | refuse |
| 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`) |
| 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse |
| `--offline` | local build | refuse (`vendor_service_offline_conflict`) |
| no API client configured (library callers of the vendor engine; the CLI always configures one) | local build | refuse (`vendor_prebuilt_required`) |

**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.

Expand Down Expand Up @@ -1123,7 +1125,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `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. |
| `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. |
| `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. |
| `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.) |
| `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.) |
| `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. |
| `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. |
| `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. |
Expand Down
41 changes: 34 additions & 7 deletions crates/socket-patch-cli/src/commands/repair_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,30 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
// fingerprint computed from the rebuilt bytes.
let from_backend = entry.is_some();
let mut check_entry = entry.unwrap_or_else(|| c.entry.clone());
// An artifact-only rebuild hands back a refreshed entry with
// no wiring of its own: re-attach the repaired entry's
// records (a reconstructed entry is not in the ledger yet,
// so the persist below has nothing to carry them from).
if from_backend {
vendor::carry_forward_wiring(&c.entry, &mut check_entry);
}
// The backend's refreshed entry already re-inventoried the
// member-verified rebuild; a changed inventory is the same
// provenance flip the post-verify refresh below reports.
if from_backend
&& c.entry.artifact.file_inventory.is_some()
&& check_entry.artifact.file_inventory != c.entry.artifact.file_inventory
{
record_warning(
env,
&c.purl,
&VendorWarning::new(
"vendor_inventory_refreshed",
INVENTORY_REFRESHED_DETAIL,
),
common,
);
}
if !from_backend && c.reconstructed {
fill_artifact_fingerprint(&common.cwd, &mut check_entry).await;
}
Expand Down Expand Up @@ -1588,13 +1612,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
&c.purl,
&VendorWarning::new(
"vendor_inventory_refreshed",
"the rebuilt artifact's patched files verify but its \
tree differs from the recorded file inventory (the \
entry was likely vendored from the patch service's \
prebuilt artifact; repair rebuilds locally); the \
inventory was refreshed from the verified rebuild β€” \
run `socket-patch vendor` to restore the \
service-built tree",
INVENTORY_REFRESHED_DETAIL,
),
common,
);
Expand Down Expand Up @@ -1660,6 +1678,15 @@ pub(crate) async fn repair_vendored_artifacts_with_references(
rebuilt
}

/// Detail of the `vendor_inventory_refreshed` advisory.
const INVENTORY_REFRESHED_DETAIL: &str = "the rebuilt artifact's patched files verify but its \
tree differs from the recorded file inventory (the \
entry was likely vendored from the patch service's \
prebuilt artifact; repair rebuilds locally); the \
inventory was refreshed from the verified rebuild β€” \
run `socket-patch vendor` to restore the \
service-built tree";

/// Compute and record the artifact fingerprint on a re-synthesized ledger
/// entry: sha256 + size for file-shaped artifacts, the whole-tree file
/// inventory for dir-shaped ones. An uninventoriable dir stays `None` β€”
Expand Down
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,23 @@ pub(crate) async fn vendor_records(
record_warning(env, candidate, w, common);
}
}
// An artifact-only rebuild hands back a refreshed
// fingerprint with no wiring of its own: it relies on
// the ledger entry it replaces for the pre-vendor
// originals, and `carry_forward_wiring` re-attaches them
// only from a SAME-uuid predecessor. With no such entry
// (none at all, or one from another patch generation),
// recording it would give `--revert` an entry that
// deletes the artifact yet cannot unwire the project β€”
// leave the ledger as is.
let rebuilt = warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt");
let entry = entry.filter(|e| {
!rebuilt
|| state
.entries
.get(candidate.as_str())
.is_some_and(|prev| prev.uuid == e.uuid)
});
if let Some(entry) = entry {
if let Some(flavor) = entry.flavor.as_deref() {
wired_flavors.insert(flavor.to_string());
Expand Down
123 changes: 123 additions & 0 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,129 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() {
assert!(!fx.root().join(".socket/vendor").exists());
}

/// A wired gem whose committed copy went missing is rebuilt artifact-only,
/// and the ledger entry the run persists must still describe it: the
/// refreshed fingerprint (inventory) verifies against the rebuilt tree and
/// the first run's pair-edit records ride along, so `vendor --revert` still
/// byte-restores both files.
#[tokio::test]
async fn scan_vendor_gem_artifact_rebuild_keeps_ledger_verifiable_and_revertable() {
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, "first vendor: {env:#}");
let state1: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();

std::fs::remove_file(fx.vendored_lib()).unwrap();
let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
assert_eq!(code, 0, "rebuild run: {env2:#}");
assert_eq!(std::fs::read(fx.vendored_lib()).unwrap(), GEM_PATCHED);
let state2: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
let entry2 = &state2["entries"][GEM_PURL];
assert_eq!(
entry2["wiring"], state1["entries"][GEM_PURL]["wiring"],
"the pair-edit revert records survive the artifact-only rebuild"
);
assert!(
entry2["artifact"]["fileInventory"].is_object(),
"the rebuilt tree is inventoried: {entry2:#}"
);

// `repair` re-checks every ledger fingerprint: nothing to rebuild.
let root = fx.root().to_str().unwrap();
let (code, stdout, stderr) = run_cli(
fx.root(),
&["repair", "--json", "--dry-run", "--offline", "--cwd", root],
&[],
);
assert_eq!(code, 0, "repair --dry-run: {stdout}\n{stderr}");
assert!(
!stdout.contains("wouldRebuild"),
"the persisted fingerprint must verify: {stdout}"
);

let (code, renv) = vendor_cli(fx.root(), &["--revert"]);
assert_eq!(code, 0, "revert: {renv:#}");
assert_eq!(
std::fs::read(fx.gemfile_path()).unwrap(),
GEM_GEMFILE.as_bytes()
);
assert_eq!(std::fs::read(fx.lock_path()).unwrap(), GEM_LOCK.as_bytes());
}

/// The same artifact-only rebuild with NO ledger entry to refresh (the
/// state file was lost) must not invent one: the refreshed entry carries
/// no wiring of its own, so recording it would give `vendor --revert` an
/// entry that deletes the copy while the Gemfile still points at it.
#[tokio::test]
async fn scan_vendor_gem_artifact_rebuild_without_ledger_entry_records_none() {
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, "first vendor: {env:#}");

std::fs::remove_file(fx.state_path()).unwrap();
std::fs::remove_file(fx.vendored_lib()).unwrap();
let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
assert_eq!(code, 0, "rebuild run: {env2:#}");
assert_eq!(
std::fs::read(fx.vendored_lib()).unwrap(),
GEM_PATCHED,
"the copy is still rebuilt"
);
let recorded = std::fs::read(fx.state_path())
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.map(|s| !s["entries"][GEM_PURL].is_null())
.unwrap_or(false);
assert!(!recorded, "no wiring-less ledger entry invented: {env2:#}");
}

/// The same rebuild when the ledger entry belongs to ANOTHER patch
/// generation (the run that wired this uuid never saved its entry): the
/// refreshed entry cannot inherit that entry's wiring, so recording it would
/// leave `vendor --revert` unable to unwire the Gemfile. The ledger keeps
/// the other-uuid entry, wiring intact.
#[tokio::test]
async fn scan_vendor_gem_artifact_rebuild_over_other_uuid_entry_keeps_ledger() {
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, "first vendor: {env:#}");

let mut state: Value =
serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
let other = "99999999-9999-4999-8999-999999999999";
state["entries"][GEM_PURL]["uuid"] = Value::String(other.to_string());
let wiring = state["entries"][GEM_PURL]["wiring"].clone();
assert!(
wiring.as_array().is_some_and(|w| !w.is_empty()),
"fixture entry is wired: {state:#}"
);
std::fs::write(fx.state_path(), serde_json::to_vec_pretty(&state).unwrap()).unwrap();
std::fs::remove_file(fx.vendored_lib()).unwrap();

let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]);
assert_eq!(code, 0, "rebuild run: {env2:#}");
assert_eq!(
std::fs::read(fx.vendored_lib()).unwrap(),
GEM_PATCHED,
"the copy is still rebuilt"
);
let after: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap();
assert_eq!(
after["entries"][GEM_PURL]["uuid"], other,
"the other-uuid entry is not replaced: {env2:#}"
);
assert_eq!(
after["entries"][GEM_PURL]["wiring"], wiring,
"its wiring survives: {env2:#}"
);
}

// ─────────────────────────────────────────────────────────────────────
// hosted β†’ vendored mode conversion (takeover reconciliation, pnpm v9)
// ─────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading