Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo

`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job.

The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage).
The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendoring is unaffected: it commits the patch server's verified artifact and never reads the installed bytes.

## Per-subcommand arguments

Expand All @@ -89,7 +89,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments
|---|---|---|---|
| `apply` | `--force` / `-f` | — | Bypass beforeHash check |
| `apply` | `--check` | — | Read-only audit that every in-scope (`--ecosystems`) manifest patch is in place, for CI / GitHub-App auditing (v5.0: previously Go-only, which passed on any unpatched non-Go tree). Local Go patches: the committed `.socket/go-patches/` copies and `go.mod` `replace` directives match the manifest (`go_redirect_drift`). Every other patch: each installed copy hashes to the record's `afterHash` — the `vex` verifier over the `vex` copy lookup; the copies are narrowed by `apply`'s own rules: a release variant (a qualified purl such as `?artifact_id=` / `?platform=`) is judged only on the copies holding its distribution, matched against every variant of its base as `apply` matches them, and an installed copy that holds none of them is drift of the base purl (`no_matching_variant`, the copy `apply` fails with "no matching variant found"; a Gradle / Ivy cache dir is exempt, as in `apply`); for gem, once a bundle-store copy exists the `gem env` fallback-home copies are not judged (`apply` treats them as best-effort). Drift is a `failed` event per patch with `errorCode` `not_applied` (still unpatched), `hash_mismatch` (neither the original nor the patched bytes), `file_not_found` or `no_matching_variant`, status `partialFailure`, exit 1, and the human `Error: Patches are OUT OF SYNC:` report (printed even under `--silent`), which ends with the remedy ``Run `socket-patch apply <scope>` to regenerate them.``: `<scope>` repeats the check's own `--cwd`, `--manifest-path`, `-g` / `--global-prefix` and `--ecosystems` (each shell-quoted when needed), so running it verbatim heals the tree that was checked (v5.0, #1219). In sync is exit 0: `Patches are in sync (N checked).` and, under `--json`, a `skipped` event per verified patch (`errorCode: already_patched`). A patch with no installed copy is skipped as `apply` skips it (`package_not_installed`; the human line adds `M not installed, skipped`). Vendor-owned patches are excluded (`vendor --check` audits them). Lock-free, fetch-free, offline-safe; it never writes. An unreadable manifest is drift (`manifest_unreadable`, exit 1) |
| `vendor` | `--force` / `-f` | — | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) |
| `vendor` | `--force` / `-f` | — | Bypass the installed-variant probe for multi-release ecosystems. That is its only effect: vendoring commits the patch server's verified artifact and never reads the installed files' content, so a missing or mismatched installed file vendors the same with or without `--force` (no warning) |
| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") |
| `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. |
| `vendor` | `--local-repo <path>` | — | With `--check`, also inspect suffixed Maven jar/POM copies in this cache for conflicts. |
Expand Down Expand Up @@ -1343,7 +1343,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. |
| `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. pnpm exception: a user exact pin equal to the vendored version, under the bare `<name>` or the `<name>@<version>` key, in package.json `pnpm.overrides` or pnpm-workspace.yaml `overrides:` (#854; there a quoted `'1.3.0'` or a trailing `# comment` is the same pin), is taken over in place (its value becomes the vendored `file:` spec under the user's own key on every override surface) and `vendor --revert` restores it byte-for-byte. A range, another version, a `>` selector, several same-name keys, or package.json and pnpm-workspace.yaml pinning under different keys still refuse; the detail names the file that carries the override. |
| `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. |
| `vendor_content_mismatch_overwritten` | `skipped` (warning) | vendor: a staged file matched NEITHER beforeHash nor afterHash (patch built against different bytes, or local edits); the stage was overwritten with the verified patched content and the vendor succeeded. |
| `vendor_content_mismatch_overwritten` | — | **No longer emitted** (service-only vendoring): `vendor` commits the patch server's verified artifact without reading the installed bytes, so it has no mismatch to report. Kept so consumers that match on it know it will not appear. |
| `vendor_vlt_transitive_unsupported` | `failed` | vendor (vlt): the target has an inbound edge from another package in `vlt-lock.json` (the detail names it); vendored mode rewires only direct dependencies of the root or a workspace member, because vlt silently reverts transitive lock surgery. Remedy: `--mode hosted`. Refused before any download or write, dry runs included (`would_refuse`). |
| `vendor_vlt_lock_out_of_sync` | `failed` | vendor (vlt): an importer's `package.json` is missing, unparseable, or declares a spec for the dependency that differs from the lock's importer edge. Remedy: `vlt install` first. Refused before any write. |
| `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. |
Expand Down
10 changes: 5 additions & 5 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ pub struct VendorArgs {
#[command(flatten)]
pub common: GlobalArgs,

/// Tolerate missing patch-target files in the staged copy (skip them
/// instead of failing) and bypass the variant probe for multi-release
/// ecosystems. Not needed for a beforeHash mismatch: vendoring always
/// overwrites mismatched content with the verified patched bytes and
/// warns (`vendor_content_mismatch_overwritten`).
/// Bypass the installed-variant probe for multi-release ecosystems
/// (vendor every recorded release variant, not just the one whose
/// bytes match the installed copy). Vendoring never reads the
/// installed files' content: it commits the patch server's verified
/// artifact, so a missing or locally edited file needs no flag.
#[arg(
short = 'f',
long,
Expand Down
18 changes: 18 additions & 0 deletions crates/socket-patch-cli/tests/help_text_hygiene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,21 @@ fn short_help_lists_about_eight_options_and_long_help_lists_all() {
"{long}"
);
}

/// #923: service-only vendoring never reads the installed files, so
/// `vendor --force` only bypasses the installed-variant probe. Its help
/// must not promise a missing-file tolerance or a mismatch warning that
/// no vendored backend implements.
#[test]
fn vendor_force_help_describes_only_the_variant_probe_bypass() {
let help = long_help(&["vendor"]);
let force = help
.split("--force")
.nth(1)
.and_then(|rest| rest.split("\n -").next())
.expect("vendor --help documents --force");
assert!(force.contains("variant probe"), "{force}");
for stale in ["Tolerate missing", "vendor_content_mismatch_overwritten"] {
assert!(!force.contains(stale), "stale {stale:?} in: {force}");
}
}
7 changes: 4 additions & 3 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2880,9 +2880,10 @@ fn vendor_after_in_place_apply_emits_applied_event() {

/// Installed content matching NEITHER hash (a patch built against different
/// bytes than the installed artifact — the flatted@3.3.1 case) still vendors:
/// the stage is overwritten with the verified patched content, the run exits
/// 0 with an `applied` event, and the overwrite surfaces as a
/// `vendor_content_mismatch_overwritten` warning event.
/// vendoring commits the server's verified artifact without reading the
/// installed bytes, so the run exits 0 with an `applied` event and the
/// `vendor_prebuilt_downloaded` advisory, and the installed tree is left
/// untouched.
#[test]
fn mismatched_install_does_not_change_the_server_artifact() {
let fx = npm_fixture();
Expand Down
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/tests/scan_vendor_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1407,8 +1407,8 @@ async fn scan_vendor_prune_reconciles_unwired_entry_on_an_empty_crawl() {

/// Interactive (non-JSON) `scan --mode vendored` pre-verifies patch baselines:
/// installed content matching NEITHER hash is annotated before vendoring
/// starts, and the run still vendors (auto-force) with the
/// `vendor_content_mismatch_overwritten` warning on stderr.
/// starts, and the run still vendors the server's verified artifact (no
/// mismatch warning: vendoring never reads the installed bytes).
#[tokio::test]
async fn scan_vendor_annotates_mismatched_baseline_and_vendors_anyway() {
let mock = MockServer::start().await;
Expand Down
50 changes: 26 additions & 24 deletions crates/socket-patch-core/src/vendor/npm_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2322,32 +2322,34 @@ mod tests {
);
}

/// `vendor --force` keeps its missing-file tolerance (strict superset
/// of the auto-force policy).
/// Vendoring ignores the installed copy's content: a missing patch
/// target vendors the same with or without `force` (#923).
#[tokio::test]
async fn vendor_force_still_skips_missing_files() {
let fx = fixture().await;
tokio::fs::remove_file(fx.installed().join("index.js"))
.await
.unwrap();
async fn vendor_ignores_missing_installed_files_with_or_without_force() {
for force in [false, true] {
let fx = fixture().await;
tokio::fs::remove_file(fx.installed().join("index.js"))
.await
.unwrap();

let blobs = fx.root().join(".socket/blobs");
let sources = PatchSources::blobs_only(&blobs);
let outcome = crate::vendor::test_support::vendor_npm(
&fx.purl(),
&fx.installed(),
fx.root(),
&fx.record,
&sources,
"2026-06-09T00:00:00Z",
false,
/*force=*/ true,
None,
)
.await;
let (result, entry, _) = expect_done(outcome);
assert!(result.success, "{:?}", result.error);
assert!(entry.is_some());
let blobs = fx.root().join(".socket/blobs");
let sources = PatchSources::blobs_only(&blobs);
let outcome = crate::vendor::test_support::vendor_npm(
&fx.purl(),
&fx.installed(),
fx.root(),
&fx.record,
&sources,
"2026-06-09T00:00:00Z",
false,
force,
None,
)
.await;
let (result, entry, _) = expect_done(outcome);
assert!(result.success, "force={force}: {:?}", result.error);
assert!(entry.is_some(), "force={force}");
}
}

/// A package already patched IN PLACE by `apply` vendors cleanly: the
Expand Down
Loading