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
17 changes: 14 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,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 --vendor` too: offline vendored staging is `vendor --offline`'s job.

The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --apply/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 (the diff strategy self-disables on a wrong base; archive/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. `--strict` turns that case into a hard error. `--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 --apply/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 (the diff strategy self-disables on a wrong base; archive/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. 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).

## Per-subcommand arguments

Expand Down Expand Up @@ -601,6 +601,17 @@ the purl its own `package.json` names, so `apply`, `rollback` and `vex` cover `n
beside any plain `node_modules/left-pad` copy. Only real package dirs count: a link is a dependency
edge, never an alias copy of its own.

### Links under `.socket/` and write containment (v5.0)

socket-patch creates `.socket/` and everything under it itself and never writes a symlink or junction there, so a linked level below the project root is never its own. The rules below share one check (core `utils::containment`); each refusal names the linked path and carries the substring `is a symlink`. Levels at or above the project path (a symlinked home directory, `/tmp -> /private/tmp`) are never checked.

- **Blob and diff cache writes** (agent-mode `get`, `scan --apply`, `apply` and `repair` downloads, and `rollback`'s before-blob fetch): a linked `.socket/blobs` or `.socket/diffs`, or a linked `.socket/blobs/<hash>` entry, is refused before anything is written. That blob is reported failed (a `get` patch fails as a whole, and its own new blobs are unwound); nothing is written at the link's target. A project that pointed `.socket/blobs` at a shared cache must replace the link with a real directory.
- **Inline blobs in `get`**: a patch view's `blobContent` / `beforeBlobContent` must hash (git-sha256) to the `afterHash` / `beforeHash` it is stored under, or the patch fails with `content hash mismatch: content hashes to <actual>` before anything is written, the same rule a downloaded blob is held to. An existing blob that already verifies is never rewritten; every blob is staged and renamed into place, never truncated in place.
- **Ledgers**: the vendored ledger (`.socket/vendor/state.json`) and the pre-v5 hosted redirect ledger are refused when `.socket` itself, a directory below it or the ledger file is a link (`vendor_dir_symlink_unsupported` for the vendored flows), so a hosted run that still has to update a pre-v5 redirect ledger fails under a linked `.socket`.
- **Agent-mode apply and rollback outside the install tree**: `apply` and `rollback` (dry run included) refuse a package whose written directories resolve outside the install tree they were found in: a Composer path repository (`vendor/<ns>/<name>` linked to first-party source), a `flit install --symlink` / editable-by-link package, or a package directory a package manager links into `site-packages` from its own prefix (Homebrew's Cellar, a Nix store path). The per-package error carries `outside the install tree`. For `apply` the remedy is to patch that source directly or install the package as a copy; for `rollback` it is to restore that source from version control, since socket-patch does not write into a tree it does not own.

Not covered: the agent-mode blob cache and manifest are checked from `.socket` down, so a `.socket` that is itself a link still redirects those writes (deciding that at lock time is deferred).

## Vendor command contract

`vendor` is `apply`'s committable sibling: instead of patching installed packages in place
Expand Down Expand Up @@ -1283,7 +1294,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. |
| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. |
| `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. |
| `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket/vendor`, `.socket/vendor/<eco>` or the patch's `<uuid>` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. |
| `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket` itself (#887), `.socket/vendor`, `.socket/vendor/<eco>` or the patch's `<uuid>` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. |
| `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. |
| `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. |
Expand All @@ -1306,7 +1317,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `vendor_artifact_redownload_failed` | `failed` | repair: download unavailable, integrity mismatch, or downloaded bytes/inventory differ from the ledger. Existing files are preserved. |
| `vendor_artifact_unrepairable` | `failed` | repair: the ledger identity or patch record cannot be trusted or recovered. |
| `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. |
| `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. `--strict` turns this case into a `failed` event instead. |
| `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. This includes a file the patch adds (empty beforeHash) that already exists with other content. `--strict` turns this case into a `failed` event instead. |
| `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). |
| `redirect_pypi_stale_install` | `redirect.warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. |
| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed archive in bundler's cache dir — `vendor/cache` unless `cache_path` moves it) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. |
Expand Down
23 changes: 22 additions & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1806,7 +1806,7 @@ fn print_verbose_verification(results: &[ApplyResult]) {
println!(" current: {h}");
}
if let Some(ref h) = f.expected_hash {
println!(" expected: {h}");
println!(" expected: {}", expected_hash_label(h));
}
if let Some(ref h) = f.target_hash {
println!(" target: {h}");
Expand All @@ -1815,6 +1815,18 @@ fn print_verbose_verification(results: &[ApplyResult]) {
}
}

/// The verbose `expected:` value: a new-file collision carries an empty
/// expected hash (the patch adds the file, so there is no beforeHash; see
/// core `mark_new_file_collision`), shown as `(new file)` instead of a
/// blank line.
fn expected_hash_label(hash: &str) -> &str {
if hash.is_empty() {
"(new file)"
} else {
hash
}
}

/// One gem-env fallback-home copy the fan-out skipped best-effort (a
/// bundle-store copy applied; this shared-home copy mismatched or failed
/// to write). Carries what the warning must name: the package, the copy's
Expand Down Expand Up @@ -3108,6 +3120,15 @@ mod tests {
AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
};

/// A new-file collision's empty expected hash reads `(new file)` in the
/// verbose summary; a real hash is shown as is.
#[test]
fn expected_hash_label_names_a_new_file_collision() {
assert_eq!(expected_hash_label(""), "(new file)");
let h = "a".repeat(64);
assert_eq!(expected_hash_label(&h), h);
}

/// Build a successful `ApplyResult` with one patched file and one
/// verified file. Used as the base for action-routing tests.
fn sample_applied(status: VerifyStatus) -> ApplyResult {
Expand Down
Loading
Loading