Repository navigation
Fix the defects found by the 5.1.0 full review: do no harm, Bundler hosted mode, VEX, monorepo scans - #2052
Conversation
…king `rollback` and `remove` restore a hosted cargo pin by looking its crate up in the crates.io sparse index. The index path was cut from the lock's `name` at byte offsets 1, 2 and 4, so a `[[package]]` on a hosted source named `""` or `"éa"` panicked (exit 101, "byte index 1 is not a char boundary") and no restore could run on that checkout. crates_index_path now returns None unless the name is non-empty ASCII letters, digits, `-` and `_` (crates.io's alphabet), and cargo_cksum refuses the pin with `"éa" is not a crates.io crate name` before any request, offline or not. The slices are then only taken from ASCII. Checking the rest of the cargo restore for the same input class found a second panic: a v1 lock that holds the same hosted block twice beside a crates.io twin (cargo refuses such a lock, TOML parses it) queued the one `[metadata]` checksum line for removal twice, and the second `replace_range` ran past the end of the text. Equal splice ranges are now applied once. Tests (each fails on the old code with the panic): cargo_cksum_refuses_a_name_crates_io_cannot_hold, restore_refuses_a_hosted_block_named_like_no_crate, restore_merge_drops_a_shared_metadata_line_once. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The NuGet restore decides which source a restored id resolves from by
scoring each `<package pattern="…*"/>` of the config. pattern_score cut
the id at the prefix's byte length, guarded only by the id being at
least that long. NuGet ids admit Unicode letters, so an id such as
`Ünicode.Pkg` beside a mapping `A*` panicked `rollback` ("byte index 1
is not a char boundary").
The prefix is now taken with `str::get`, so a length that falls inside a
character is a pattern that does not match. A prefix cannot equal the
id's bytes up to the middle of a character, so no match is lost.
Test: pattern_scores_never_split_a_character_of_the_id (panics on the
old code).
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`scan --prune`/`--sync` under `--global` or `--global-prefix` judged the
manifest against the global crawl alone. The manifest is still
`<cwd>/.socket/manifest.json`, so every record of the project read as
uninstalled: the run emptied the manifest and deleted `.socket/blobs/`,
exit 0. The project's `apply` then had nothing to re-apply and `rollback`
nothing to restore from. A global prune now also keeps every entry the
`--cwd` project accounts for, its installed packages and the ones only
its lockfiles list, which is what the project's own prune keeps. It
removes only entries neither scope has.
Two more ways the same GC destroyed data it should have kept:
* When the pruned manifest could not be written, the sweep still ran
against the pruned in-memory copy and deleted the blobs the manifest on
disk still names. A failed write now prunes and sweeps nothing, and
`manifest_write_failed` says so.
* The blob, diff and package sweeps followed a symlinked `.socket/`
level. A checkout can commit `.socket/packages -> ../../elsewhere`, and
`repair`, `remove`, `rollback` and `scan --prune` then deleted every
regular file there as an orphan; the dry run listed them too. The sweep
refuses a linked level with the containment refusal every other writer
and deleter of `.socket/` uses, reported as `cleanup_failed`.
Found by the 2026-11-10 full review. The first was reproduced with the
5.1.0 binary: a project with one installed, patched package, scanned
with `--global-prefix <dir> --sync`, was left with `{"patches": {}}` and
no blobs. Each case has a test that fails without the change:
scan_global_prune_keeps_the_projects_entries,
scan_prune_keeps_blobs_when_the_manifest_cannot_be_rewritten and
sweep_refuses_a_linked_artifact_dir_and_spares_its_target.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…m names First-contact fixes from the 2026-11-10 usability review. None changes what a command does. * Help wraps to the terminal, capped at 100 columns. Lines ran to 564 characters in `--help` and 501 in the short `-h` summaries, which broke the two-column layout in an 80-column terminal. A new test bounds every help page's line width. * Root help defines the three patch modes. It used "hosted", "vendored" and "agent-mode" without saying what they are; the only definitions were under `--mode` in `scan --help`. * `--ecosystems` and socket.yml `patches.ecosystems` accept the names users reach for. The CLI prints "Found 5 packages (5 go)" and "(61 python)" but rejected `-e go` and `-e python`, and socket.yml answered `go` with "did you mean `gem`?". Both now resolve through one `Ecosystem::from_name`: the slugs in any case, the display names, and common registry, language and package-manager names. The value is normalized to the slug at parse time, so later comparisons are unchanged. `--ecosystems` help lists the slugs. * The npm launcher explains a missing platform package. With `npm install --omit=optional` it printed `spawnSync .../socket-patch-darwin-arm64 ENOENT`; it now names the package, why npm skipped it and how to get the binary. An unsupported platform lists the supported ones. * README no longer opens by saying it documents a prerelease that may differ from what the install commands fetch. 5.0.0 shipped on 2026-10-12, and the note is copied into the npm and crates.io pages. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`repair` only checked that each referenced blob existed. A blob whose
bytes changed on disk (a `core.autocrlf` checkout of a committed
`.socket/blobs/`, a truncated file) read as "present": repair said "All
blobs are present locally" and every `apply` failed on it with
"Hash verification failed before patch. Expected …, Got …", which names
neither the file nor a remedy (NT-D4, PE-18). And `repair --offline`
with missing blobs printed a warning, said "Repair complete." and exited
0 (`--json`: `success`, no event), while the next `apply --offline`
failed and told the user to run `repair` (CA-D05).
repair now re-hashes every referenced blob it finds (afterHash and
beforeHash) against its git-sha256 name and treats a mismatch like a
missing blob: downloaded again and renamed over the bad entry, which a
failed download leaves as it was. Offline nothing can be fetched, so
each missing or corrupt blob is a `failed` event
(`offline_missing_sources` / `blob_corrupt`, `details.hash`), an
`Error:` list on stderr that `--silent` does not mute, a closing
"Repair finished with errors" line and exit 1, as the contract's
`--offline` rule ("fail loudly when a required local source is
missing") already said.
apply and rollback keep hashing a blob only when they are about to
write its bytes. The refusal is now typed, so the caller that fed the
writer a cached blob reports "blob .socket/blobs/<hash> is corrupt …
Run `socket-patch repair`" without hashing anything again.
The repair suites' fixtures wrote placeholder bytes under a made-up
hash; they now use the real hash of their content. Verified with the
core unit tests (corrupt-blob detection, the apply and rollback
messages), repair's unit tests and the `repair`, `e2e_safety_cow` and
`in_process_remove_repair_lifecycle` binaries.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… one Hosted rollback rebuilds a uv.lock entry from PyPI in the shape another registry package of the lock shows: which registry it names, and which artifact fields this uv records. A project with one dependency has no such package, so `socket-patch rollback` failed with "no sibling registry package shows the registry and artifact fields the lock records" and left pyproject.toml and uv.lock hosted. The hosted scan wires such a project without complaint, so the only way back was `git checkout`. A `[[package]]` lock (uv 0.2.35 and later, script locks included) says what a sibling would in its header. With no sibling, the entry is now rebuilt on PyPI's default index with the fields uv writes at the lock's `revision`: `url`, `hash` and `size` with no revision and at revision 1, those plus `upload-time` at revisions 2 and 3. The run reports it with an `upstream_uv_entry_rebuilt` warning naming the entry, the fields and the `uv lock --check` to run. It still refuses, with nothing written, when the revision is newer than 3 (its fields are unknown here) and when a uv setting or environment variable names a package index, since the entry's registry is then not known to be PyPI. A pylock or a uv 0.2 `[[distribution]]` lock with no sibling refuses as before: nothing in them tells their writers' shapes apart. A refused uv pin's remedy now names every file the hosted rewrite edited. Discovery lists only the lock, so the advice was `git checkout -- uv.lock`, which leaves the `[tool.uv.sources]` URL in pyproject.toml (or the script) for the next `uv lock` to wire back in. The paired file is named when it carries the pin's patch. Verified with real uv. A `uv init` + `uv add "urllib3==1.26.18"` project taken through the real hosted rewriter: the 5.1.0 binary refuses it as reported; with this change rollback exits 0, both files are byte-identical to what uv wrote, `uv lock --check` passes and `uv sync --frozen` installs urllib3 1.26.18. The same cycle is byte-identical on uv 0.2.37, 0.3.5, 0.4.30, 0.5.31, 0.6.14, 0.7.0, 0.8.0, 0.8.4, 0.9.30, 0.10.0, 0.11.19 and 0.12.17. uv 0.6.15-0.6.17 spell the revision 2 timestamp `upload_time`; their entry comes back as `upload-time`, which uv 0.6.17 accepts (`lock --check` and `sync --frozen`), as 0.7.0 and later accept `upload_time`. A script lock with one dependency round-trips too (`uv lock --check --script`). The real-uv e2e driver accepted either a restore or a refusal for its one-dependency lanes. It now requires the project, constraints and script lanes to restore byte for byte on every `[[package]]` lock (not byte-exact on 0.6.15-0.6.17). `e2e_redirect_uv_build` passes those lanes on the 16 uv releases of the CI matrix, 0.1.45 through 0.12.17. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
socket-patch spawns package managers and their version-manager shims, interpreters, git, and for a gem's native rebuild the gem's own extconf.rb and the compiler. CI usually exports SOCKET_API_TOKEN for the run, often on a repository the CLI does not control, and every one of those children inherited it: the spawn seam in utils::process removed nothing, and the gem build dropped only RUBYOPT, RUBYLIB and BUNDLE*. utils::process now owns one list, CHILD_ENV_SCRUB: the Socket token under each name a Socket tool reads it from, socket-cli's inline config and forward proxy, the accepted peer aliases, every flag-bound variable of the CLI and the Socket-specific registry override URLs (a URL can carry credentials in its userinfo). command_for removes them when a command is made, and output_within / build_output_within remove them again at the spawn, because a caller may rebuild the environment from the ambient one afterwards (the PDM site probe does env_clear().envs(..)). Names are exact, so HTTP_PROXY, HTTPS_PROXY, NO_PROXY, NPM_TOKEN and any other variable a package manager or an .npmrc expansion needs still reach the child. The two production commands made outside command_for (the staged binary's --version self-check and the git check-ignore pipe) now go through it. Nested apply from get/scan runs in-process, so no socket-patch child depends on these variables; the self-check only parses --version. Tests, each failing before the change: - utils::process: a child run by output_within and by build_output_within with the token set on its command does not see it and keeps NPM_TOKEN / HTTPS_PROXY / NO_PROXY; command_for removes the list from the inherited environment; the list covers both columns of PEER_ENV_ALIASES and holds only SOCKET_* names. - child_deadline_guard: production code makes a Command only in utils::process (found update/download.rs and vendor/npm_dir.rs). - args: every env binding clap declares on any subcommand, and both ARG_ENV_VARS lists, are on the scrub list, so a new token-bearing binding cannot be forgotten. - e2e_gem_native_ext_build: with SOCKET_API_TOKEN, SOCKET_CLI_API_TOKEN and SOCKET_SECURITY_API_TOKEN in the CLI's environment, the Ruby that rebuilds a gem's extension sees none of them (a stand-in ruby on PATH records its environment), and still sees NPM_TOKEN. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`partition_purls` silently dropped every manifest key without a known ecosystem: a purl type a newer socket-patch recorded (`pkg:swift/…`), a known type in another case (`pkg:NPM/…`), a key that is not a purl. So `apply` printed "No patches to apply." and exited 0, `apply --check` passed with the records unverified, `rollback` counted them in its prompt and then did nothing, and `remove` dropped their records without restoring anything, while `list` showed them all (CA-D03). One classification (`unsupported_keys`) now feeds every command that partitions the manifest: - An unsupported ecosystem is a `skipped` / `unsupported_ecosystem` event per record, one `warnings[]` entry and one stderr warning naming them (with the lowercase spelling for a mis-cased type). It never changes the exit: a newer CLI's ecosystem in a committed manifest must not fail an older CLI's CI run, the same reason the scan prune exempts such records. `apply --check` no longer says "in sync" over them; its line counts them as not checked. `rollback` keeps them out of the prompt and keeps their records. - A key that is not a purl at all can never be matched by any version: `apply` fails it by name (the other patches still apply), `apply --check` reports drift (`invalid_purl`) and `rollback` a failed entry. - `remove` refuses either kind before any mutation, since it cannot restore what the record patched; `--skip-rollback` still forgets it. Nothing is reported under `--ecosystems`, which scopes a run to ecosystems this CLI knows. Each new test failed before the change: four in the `apply` binary, one each in `rollback` and `remove`. Those three binaries and the unit tests for apply, rollback, remove and ecosystem_dispatch pass. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
scan and get could report a clean result for a question nobody answered.
On the token-less path a rejected `POST /patch/batch` (400/404/405)
degraded to one GET per package, and every per-package error that was
not a throttle was dropped as "no patches": a mistyped SOCKET_PROXY_URL,
a captive portal, a forward proxy given as --proxy-url or a service that
500s every lookup all ended in "No patches available for installed
packages.", exit 0, `status: success`. Separately, every 404 on a JSON
route read as "not found", so a mistyped --org (the API's 404
"Organization not found") or a host that 404s everything made `get`
print "No patches found" and exit 0.
The client now tells the service's answer from another host's by its
shape, not its status. The service writes errors as
`{"error":{"message":…}}` (API) or `{"error":…,"message":…}` (proxy):
- A 404 is "not found" only with the service's error body, and not when
that body says the organization or route is missing. A 404 page from
another host, a 4xx the service did not write and a 200 that is not
the expected JSON are errors naming the host that answered and the
setting to check (--proxy-url / SOCKET_PROXY_URL, or --api-url and the
org slug). A 404 with no body fails a search unless the service has
already answered this client's batch query (scan's detail queries); a
patch view keeps reading it as "no such patch".
- The service refusing a request's content (its own 400) is the new
`ApiError::InvalidRequest`. It is the one per-package outcome that
still counts as "nothing for this purl".
- The batch POST degrades on a 400 only in the service's shape. A 400
another host wrote fails at once instead of fanning out one GET per
package to the same wrong host.
- On the per-package path a lookup that got no answer is returned in
`BatchSearchResponse::unanswered`; when no lookup was answered the
call fails with the first error. This covers a throttle that outlasted
its retries (it used to fail the whole batch), the proxy's "Patch API
is not configured" 503 (it used to read as no patches) and a lookup
task that died.
scan uses the answered packages and reports the rest: a
`patch_lookup_failed` warning naming them, a `failed` event each,
`partialFailure`, exit 1 in every mode. A scan that found no patch but
could not check every package no longer prints "No patches available".
The in-memory hosted engine counts unanswered purls as failed purls.
The documented legacy fallback (the proxy's 400 "Unsupported endpoint"
followed by healthy per-package answers) and its honest empty answers
are unchanged. CLI_CONTRACT.md states the new reporting.
The new wiremock tests in core tests/proxy_batch_e2e.rs and CLI
tests/scan/scan_proxy_fallback_e2e.rs fail on the previous code (the
client returned an empty Ok; scan exited 0 with `status: success`). The
5.1.0 binary run against a stub answering the API's "Organization not
found" printed "No patches found for CVE" / "No patch found with UUID"
and exited 0; this build exits 1 naming the slug. The existing API
suites and the scan/get suites that mock the service pass.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… --global `gem env gemdir` / `gem env gempath` and the Python probes (`python3 --version`, then the `site` query) ran in whatever directory the CLI was started from. A version manager's shim (rbenv, pyenv, asdf, mise) picks the interpreter from that directory's `.ruby-version` / `.python-version` / `.tool-versions`, so `socket-patch scan --cwd proj` run from elsewhere crawled another Ruby's gem homes or another Python's site-packages, and under `--global` the project the shell happened to be in steered a global scan. The npm family and Composer already ran their global probes from the neutral directory. SystemCommandRunner, which inherited the process cwd, is replaced by ProbeRunner, which always names the directory: ProbeRunner::project(cwd) runs from the project and ProbeRunner::global() from neutral_probe_dir (what GlobalProbeRunner did; it now delegates). The Ruby crawler asks `gem env` through project(--cwd) for the Bundler fallback homes, the stale-install homes and the unused system homes, and through global() for --global. The Python crawler asks through project(--cwd) for a project that installs into the system Python, and through global() for --global. The two memos are keyed on the directory the probe runs in instead of the process cwd (EnvKeyedMemo::get_or_run takes it), so an answer for one project is not reused for another. Tests: global_probe_spawn_e2e gains four cases with a fake `gem` / `python3` that logs the directory it runs from: under --global with the process inside a project every probe runs from the home directory, and for a project with the process started elsewhere every probe runs from --cwd. All four failed before the change (the probes ran from the process cwd). utils::process covers ProbeRunner's directories and the memo asking again from another directory. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… patch `remove` and `rollback` drop the manifest record of a patch whose package the crawl did not find (the documented "long-uninstalled" contract) and keep its beforeHash blobs out of that run's sweep. remove then said the "revert data … was kept" and, under `--json`, that "a later rollback/repair can still restore". Neither is true: the file to hash map lived in the dropped record, so `rollback <pkg>` answers "No patch found", `repair` restores no installed file, and the next sweep collects the blobs. If the miss was a copy the crawl did not reach (another --cwd, a layout it does not know), that copy stays patched and tracked by nothing, and the message sent the user the wrong way (CA-D06). The record removal and the blob retention are both spelled out in the contract and pinned by tests, so they stay. What changes is what the commands say: - remove's warning and its `rollback_not_installed` event now state that nothing was restored and the record is gone, name the remedy that works (reinstall the package), and describe the kept blobs as what they are: usable only by a rollback after the patch is recorded again, and only until the next blob cleanup. - rollback's not-installed warning adds one line for the entries whose record it drops, and its `package_not_installed` reason says nothing was restored. `remove_not_installed_keeps_before_blob_and_warns` now also asserts the reason and that a follow-up `rollback <purl>` finds no patch; it failed on the old text. The remove, rollback and cli_remove_silent binaries and the CLI unit tests pass. Keeping the record instead (so a later rollback from the right scope can still restore) would change documented behavior and is left for the owner to decide. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A `workspaces` / `packages:` / cargo `members` glob is a project file,
and every matcher that reads one could be made to hang or crash by it:
- segment_glob_matches tried every way each `*` can end, so
`build/*a*a*a*a*a*a*a*a*a*a*b` plus one 60-character directory name
hung `scan` (about 10^11 steps). It is now the two-pointer wildcard
match with one backtrack point, pattern length x name length at most,
with no call per character and no allocation per step.
- path_glob_matches did the same per `**` segment: twenty `**` against a
forty-deep path took about 10^15 steps. It now fills a table from the
last segment back, one component match per (segment, component).
- The cargo / pnpm member walk (expand_glob, and the ProjectView one)
reached one directory along every way to split the levels between two
`**`: six `**` over a sixteen-deep chain listed directories 100,946
times, and a truncated answer came back for `**/**/**` over forty
levels. A walk with two or more `**` now remembers the (directory,
segments left) pairs it has been through; consecutive `**` read as one.
- Brace expansion recursed per group and copied the pattern per level:
`{a,b}` 4,000 times over overflowed the stack, and 100,000 times would
build gigabytes first. It now keeps its own stack, pairs braces in one
pass (a scan per `{` was quadratic), and stops at a megabyte of text
as it already did at 4,096 alternatives. A zero-padded sequence wider
than 65,535 (`{0...01..2}`) panicked in `format!`; it is padded by hand.
- The extglob matcher is memoized but still cubic in the name's length
and recursive per pattern character: sixty `*(a|aa)` against a
255-character name take seconds per brace alternative (hours behind
twelve brace sets), and 2,000 `*` after a group overflowed the stack.
One pattern now gets about a million matcher steps against one path
and 512 levels of depth; a brace alternative with groups is read only
up to 4,096 characters and 64 groups. Past any of these the pattern is
unreadable, which fails closed the way a picomatch shape this matcher
cannot read already did: it lists every directory and, as a `!`
pattern, excludes none. A segment without a `(` goes to the linear
matcher in every dialect, so it is read whatever its length.
- cargo's is_member_pattern looked for a `]` after every `[`: 40,000
unclosed `[` took 11.6 s. The first `[` decides.
Semantics are unchanged for every pattern inside the bounds. The old
backtracking component matcher, path matcher and recursive brace
expansion stay in the tests as oracles: 40,000 seeded pattern/name
pairs, 40,000 pattern/path pairs over every dot / extglob / POSIX
reading and 20,000 brace patterns agree, as do 20,000 segments for the
member-pattern check. The step bound is twenty times what the costliest
ordinary group pattern in the tests takes against a 240-character name.
Each new test failed on the old code: the two timing tests and the view
walk timed out at 60 s, the listing count was 100,946 against a bound of
119, and the padded sequence panicked "Formatting argument out of
range". The stack overflows were confirmed by running the old code on
the same patterns; they abort the process, so they were not left in as
failing tests.
CLI_CONTRACT.md says which group patterns are not worked out.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A Hatch `matrix` is a product of its variables' values, and its names were generated in full for any `pyproject.toml` or `hatch.toml` the scanner met, before any environment directory was looked at: five variables of sixteen values held a million names (408 MB), and a few more variables are an out-of-memory kill. The product is now walked one combination at a time, in Hatch's order, and stops after 1,024 for the whole project file (every env and matrix table counted together). Each combination is a virtualenv Hatch has to build on disk, and real matrices are a few dozen, so the cap is far above any project. A name longer than 255 bytes names no directory and is not built, which also keeps a `matrix-name-format` that repeats its placeholders from multiplying a long value into gigabytes; such a combination still counts, so the walk always ends. The names are only used for the shared `~/.virtualenvs` layout (the other layouts list the project's own directory). When that layout is in use and the matrices were cut, a warning (once, muted by --silent, like the Bun global-dir one) names the file and the directory and says the environments past the cap were not scanned. An env whose matrix was cut to nothing is still not looked for under its own name, and a `hatch-test` matrix still counts as the project's own. Tests: shared_virtualenvs_dir_reads_a_matrix_up_to_the_cap failed on the old code (it walked all 248,832 combinations and found the environments past the cap); matrix_combinations_are_counted_across_the_project and matrix_names_longer_than_a_directory_name_are_not_built cover the counting, the cut file and the name bounds. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ends Vendoring into a pnpm or yarn berry project, and reverting it, parsed the user's package.json and printed the whole document back. That reflowed any manifest not already in JSON.stringify form (inline arrays and objects, `\u` and `\/` escapes, a one-line file, duplicate keys) and changed number values serde_json does not read exactly: `5.173e28` came back as `5.173000000000001e+28`, and an integer past u64 became a float. `vendor` followed by `vendor --revert` was not the identity. `npm_dir::splice_json` now applies the change as span edits: each member or array element the new document adds, drops or changes becomes one splice, and every other byte stays (formatting, escapes, number spellings, repeated keys, line endings, a BOM, the trailing newline). An added member copies the indentation and line ending of the member beside it, and removal is the exact inverse of insertion, so a revert lands on the pre-vendor bytes. The result is returned only once it parses as the intended document, so a scanner fault refuses instead of writing. pnpm (`pnpm.overrides`, and the build approvals carried into `pnpm.allowBuilds` / `pnpm.onlyBuiltDependencies`) and yarn berry (`resolutions`) write through it on vendor and on revert. Berry now records whether it created the `resolutions` table (in the ledger's existing package.json table flag), so a revert drops a table vendoring created and leaves one the project already had, even an empty one; entries recorded earlier keep the previous rule. Verified by round-trip tests over handwritten manifests (inline arrays, escapes, exponent and oversized numbers, CRLF, BOM, no trailing newline, tabs, duplicate keys, one line, uneven indent, pre-existing and empty override tables): the vendored file is the original plus one inserted run, and the revert is byte-identical. Both tests fail on the previous code. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A `${...}` text of a declaration is read under every combination of the
profiles that may move it (up to 255), each read a fresh interpolation
of up to 1,024 property lookups. That was done again for every
declaration, although the answer does not depend on the declaration: a
100 KB pom of 1,000 `<version>${p0}</version>` over a nine-deep property
chain and eight profiles cost 263 million lookups, 28 s in
`socket-patch list`, and every discovery pass and the hosted rewriter's
index pay it.
PomScope now keeps each interpolation it has worked out, keyed by the
text, the active profiles, whether user properties are read and how it
is interpolated, so a text shared by many declarations is read once per
combination. The profiles defining a property are found through an
index rather than by asking every profile. Results are unchanged: the
values the new test expects of every declaration (version, build
version and the eight alternatives with their profiles) are the ones
the old code gave, and the 1,000-declaration pom now takes under
600,000 units of work, where one declaration alone takes 262,911
lookups.
Declarations that each spell a different text share nothing, so one
pom's work is also bounded: a unit per property lookup, per profile
definition read and per 16 bytes of interpolated text kept or handed
out, 4,194,304 in all (a real pom stays under a hundredth of that).
Past it a `${...}` reads as unresolved and a declaration's alternatives
are the text as written under the new MovedBy::PastWorkBound, the same
fail-closed shape as "more than 8 profiles". CLI_CONTRACT.md says so.
Tests count work instead of time:
declarations_sharing_a_text_are_interpolated_once failed on the old code
with 10,516,440 lookups for 40 declarations against a bound of 1,000,000
(its value assertions passed there, which pins the results);
a_pom_past_its_work_bound_reads_as_unresolved and
definitions_list_the_profiles_in_pom_order cover the bound and the
index. formats::maven, crawlers::maven_pom_equivalence_tests,
vex::discover::maven, patch::redirect::maven_index and the
redirect_golden / crawler_maven_e2e suites pass unchanged.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The restore answers each pin once, from the registry lookup of whichever of its lock blocks came last. With the name check added to the lookup, a hosted source that held a block named `é` ahead of a real one would have had the real block's checksum and the crates.io source spliced into the `é` block too, where the old code panicked before writing anything. The names are now checked when the blocks are collected: a pin with a block crates.io could not have served is refused before any lookup, and none of its blocks, manifest pins or registry config is touched. Test: restore_refuses_the_whole_pin_beside_a_block_named_like_no_crate, in both block orders (offline, so the other block's lookup cannot answer). patch::redirect::upstream passes (177 tests). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…dry run To rebuild a gem's native extension, apply first asked each candidate Ruby to `Gem::Specification.load` the installed gemspec. That evaluates the file, a Ruby program a committed vendor/bundle lets the scanned repository supply. It ran before the record's files were checked against the installed copy and before the --dry-run test, so `socket-patch apply --dry-run` executed repository code. The decision to rebuild now runs nothing. The gemspec is read as text (it is Specification#to_ruby output): `extensions` as before, and the require path the lib copies live under with the new parse_require_path. The order in apply_gem_package_patch is: 1. Preflight from files alone: an extension dir on disk, a require path inside the gem, a Ruby to try (one inside the project is still never a candidate). 2. A verify-only pass over the record's files. A record that does not verify fails here, with nothing run and nothing written. 3. A dry run stops, with a native_extension_rebuilt warning saying apply would rebuild (it can no longer tell whether a Ruby here can). 4. The host Ruby is asked where it builds extensions for the gem home (`extensions_dir <home>`: RubyGems' own extensions_dir formula, default_ext_dir_for included). That loads nothing of the gem. The Ruby whose answer is the installed extension dir owns the install. 5. The sources are written and RubyGems' builder runs. Only this step loads the gemspec, and it refuses to build unless the loaded spec builds into that extension dir, has the require path that was read, and declares extensions; a build that exits cleanly without an extension dir is undone like a failed one. Refusals keep their codes and wording. `apply --check` and `vex` never started Ruby and still do not. Tests, failing before the change: - e2e_gem_native_ext_build, with a stand-in ruby on PATH that records each run: a dry run starts none and reports the pending rebuild; a record whose file matches neither hash starts none (apply and dry run); a real run makes exactly `extensions_dir` then `build`. - the_installed_gemspec_is_not_evaluated_before_the_record_verifies (real Ruby 3.4, #[ignore] capstone): a side effect planted in the installed gemspec does not fire on a dry run or for a mismatched copy, and does once the build runs. Before: "a dry run evaluated the gemspec". - patch::gem_ext: parse_require_path, find_ruby asking only for the extensions dir, the preflight's file-only refusals. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`.socket/vendor/.commit-journal.json` is the roll-forward log of the vendored group commit, and every command that takes the apply lock replays it. It is also a file in the working tree, so a checkout can carry one that no crash wrote: a pull request from a fork can commit it, with its payload in base64. The replay accepted any project path outside `.socket/`, printed nothing when it rolled files forward, and ran under `--dry-run` too. A hand-written journal therefore made `socket-patch apply` (also `apply --dry-run`) create `.git/hooks/post-checkout`, a place no commit can reach, without a line of output (PE-13), and `vendor --dry-run` finished a pending commit before previewing (CS-D11). Three things now hold. A replay writes only what a commit could have journaled. The captured set (`is_captured`), which is exactly what a journal may name, no longer includes a path with a git-directory component. No backend writes there, so a genuine journal from this or an older 5.x CLI never names one. The component is matched the way git guards its own tree (`is_ntfs_dotgit`, `is_hfs_dotgit`): ASCII case folded, the code points HFS+ ignores skipped, NTFS trailing dots/spaces and stream suffixes cut, and the `git~1` short name. The `.socket` component the captured set already excluded is matched the same way, so `.SOCKET/manifest.json` cannot reach the manifest or an artifact where the filesystem folds case. Absolute paths, `..` and linked levels were already refused; the lexical rules run before any level is probed, so a forged entry is a refusal (journal set aside, nothing applied, not even its plausible entries) rather than an I/O error. The names cannot be narrowed to an allow-list: the backends rewrite files the project names (`-r` requirement includes of any name at any depth, every workspace member's manifest, JVM build files). A replay is never silent. The lock acquire prints what it did on stderr in every output mode, naming each file it rolled forward, and the guard carries the same sentence (`LockGuard::commit_recovery`) so the command that took the lock adds it to its `--json` envelope as a `vendor_commit_recovered` warning. The existing set-aside report rides the same channel as `vendor_commit_set_aside`. Journaled paths are printed without control or bidi characters. A dry run replays nothing. `acquire_dry_run` takes the lock but leaves a pending journal alone and fails the acquire with `LockError::CommitPending`; the CLI's one lock wrapper now takes the run's `--dry-run`, so every lock site states it, and the refusal is the new top-level error `vendor_commit_pending` (exit 1), naming the journal and every file it covers. It stops rather than previews because the files may be half replaced: the same reason a replay that fails on I/O already fails the acquire, and `vendor` plans its eject off those lockfiles (#1782). Setting a journal aside is a rename, so a dry run does not do that either. Verified: the forged-journal unit test in group_commit and the three new CLI tests in vendor_group_commit_e2e were run against the previous code and failed there (the forged journal was rolled forward into `.git/hooks/`; `vendor --dry-run` exited 0 having replayed; no warning was printed). They pass now, with new unit tests in apply_lock and lock_cli and a scan test for the new API and report. A genuine journal still rolls forward under vendor, vendor --revert, apply, rollback, repair, remove and scan, and each reports it. One existing test triggered the set-aside through a dry run; it now asserts that the dry run refuses and uses a wet command. CLI_CONTRACT.md documents the path rule, the report, the two warning codes and the new error code. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The berry revert restored and wrote yarn.lock, and only then read and parsed package.json. A manifest that did not parse at revert time (a merge-conflict marker) failed the revert with the lock already rewritten to the registry entry while `resolutions` still pointed at the vendored tarball, a pair the next plain `yarn install` re-resolves, and the error did not say the lock had changed. The revert now reads and parses the manifest first, computes both new files in memory, and writes only once both are known good (lock first, as before). An unparseable or uneditable manifest fails with "nothing was reverted" and leaves both files as they were; a missing manifest is still a warning. The existing corrupt-manifest test asserted the partial restore; it now asserts both files are untouched and that the same revert converges once the manifest is repaired. It fails on the previous code. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`apply` and `apply --check` decide which purls `socket-patch vendor` owns from `.socket/vendor/state.json`. When the file could not be read they treated every manifest patch as an in-place patch and said nothing, where `rollback` and `remove` report `vendor_state_unreadable` (CA-D09). `list --json` left the vendored patches out of a `success` envelope with only a stderr line as the trace (CA-D10). Both now carry the `vendor_state_unreadable` warning: in `warnings[]` under `--json`, as a `Warning:` line on stderr otherwise. apply still patches and exits as before, so an unattended run on an agent-only project is not failed by a ledger it never needed; the run just no longer hides that vendor ownership was not consulted. `apply_warns_when_the_vendor_ledger_is_unreadable` and the new `--json` leg of `silent_gates_the_malformed_vendor_ledger_warning_via_binary` failed before the change. The apply, cli_parse_list and manifest_path_ledger_root binaries and the CLI unit tests pass. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Folding duplicate package records re-hoists the lock's dependency graph with a model of Bun's hoister. A graph that keeps nesting two packages of one name inside each other (root -> m -> n -> m' -> n' -> m), or one that nests two trees under every tree, made that hoist allocate without end: a 1.8 KB crafted bun.lockb took `vendor` to 2.7 GB in 1.6 s. The hoist now stops once it has placed more dependencies than the lock itself records (plus one unwritten optional peer per edge) or has spent 64 steps per recorded entry, edge and package; a hoist that reproduces the lock's own trees never does either. It looks names up in an index and walks the trees in a loop, so a step is constant work and a deep tree cannot overflow the stack. Running out is `None`, which already means "these records cannot be folded": the records are rewired one by one with the existing warning, as for any lock whose trees this model does not reproduce (the 0.1.x writers'), so the fold is declined rather than the lock refused. The same review of sizes read from the file found one more class: every string reference is checked against the string buffer, but nothing bounded their sum, so records that all name one long string were copied once per record (packages, the metadata hash, the hoist graph's names, the rebuilt legacy string buffer), and overlapping dependency slices listed every string field once per package. Each pass now spends from a budget of eight times the file size and refuses the lock beyond it. Verified: the two new hoist tests ran 2 CPU-seconds to 850 MB before the change (stopped by a CPU limit) and finish in milliseconds after it; `hoist_reproduces_every_captured_writers_trees` still reproduces the trees of every fixture lock byte for byte, using at most 1.2 steps per entry of the 64 allowed; the string-budget test fails with the guards taken out. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`rollback`, `remove` and the withdrawn-pin sweep restore a hosted pin by rewriting every file that wires it (pom.xml + .mvn/maven.config + the trusted checksums, package.json + yarn.lock, .npmrc + package-lock.json). The flush checked each file for a symlink right before writing it and never put an earlier file back, so a refusal or an I/O error on file N left files 1..N-1 rewritten. On a Maven pin with a symlinked pom.xml, `rollback` deleted .mvn/maven.config and the trusted checksums, left the pin live, printed "Restored ..." and counted `rolledBack: 1`, and `--dry-run` previewed a success (audit HE-D1, CS-D3). The flush is now two steps (patch/redirect/staged.rs): * `preflight` checks every staged file before the first is written: its real path is inside the repository root, it is a regular file or absent, it still holds the bytes the restore read, and its directory takes the write (skipped for a file an open group commit captures). A dry run runs it too, so the preview reports the refusal. * `flush` writes, and when a write fails it puts the files already written back from the bytes preflight read, newest first (a deleted file with its mode). Emptied directories are pruned only once every file landed, so a put-back still finds them. When a put-back fails as well, the error names each such file and the state it is left in. The undo is held in memory instead of the vendored group commit's journal: that journal lives in .socket/vendor/, which a hosted project does not have and a restore must not create, and a group captures neither the Gradle wiring under .socket/gradle/ nor files reached through `..`. A process killed between two writes is therefore still not covered. Reporting: a pin whose files were not written is the new `PinStatus::Unwritten`, never `Restored`, and `RestoreOutcome` lists the files a failed flush could not put back. `run_hosted_leg` prints no "Restored" line for such a pin and keeps it out of `reverted`: * rollback emits only the artifact-level `hosted_write_failed` event (`status: "error"` / `rollback_failed` when nothing else was rolled back, where the false `rolledBack` used to make it `partialFailure`), and `hosted.editedFiles` counts only files left changed; * `--dry-run` reports "writing the restored lockfiles would fail", exit 1; * the withdrawn sweep records one `redirect_withdrawn_restore_failed` warning naming the pins it kept and no `rolledBack` event; exit and status stay as documented. A restore left half applied stops the scan with `lockfile_write_failed` before the rewrite runs over it. `failpoint::fails` adds a `~fail` form of SOCKET_PATCH_FAILPOINT, so a test of the built binary can fail the n-th write (`hosted_restore_write`) or put-back (`hosted_restore_put_back`); release builds compile it out like `hit`. Verified: against main's flush (with the same test-only injection hook in its write loop) the staged.rs restore tests for a symlinked or unwritable file 2 of 3 and for a write failure on file 2 fail with file 1 already deleted and the pin `Restored`. The new rollback and in_process_scan_withdrawn tests fail on main too: the symlink and read-only cases because the pin is reported `rolledBack` / "Restored", the failpoint cases only because main has no failpoint to fail a write with. All pass here, together with the put-back, failed put-back and drift unit tests; cargo test -p socket-patch-core --lib patch::redirect, hosted::, vendor::, the redirect_golden / upstream_restore_golden / hosted_inventory suites, and the CLI rollback, remove, in_process_rollback_hosted, in_process_scan_withdrawn, in_process_redirect*, vendor_eject* and mode_migration_pypi suites pass. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`scan --mode hosted` puts the files it already wrote back when a later
write fails, and then reported "(nothing was changed)" whatever became of
that put-back: `put_back` (the Gradle files written straight to disk)
discarded its errors with `let _ =`, and the unjournaled group commit
discarded the error of its `restore` (audit T30). A project left with one
lock rewritten and the next not was reported as untouched.
* `group_commit::restore` now tries every file instead of stopping at the
first failure and returns one line per file it could not put back. A
journaled commit still keeps its journal for the next locked command;
an unjournaled one, which has no journal to finish from, carries the
lines in its error (`group_commit::left_replaced`).
* `put_back` returns the same lines for the direct writes, and only puts
back files that reached the disk (the file whose own write failed never
changed).
* `write_failure` words the error: "(nothing was changed)" only when
every file was put back, otherwise each file with the state it is left
in ("is left with its new content" / "created" / "deleted") and "every
other file is unchanged".
Verified: utils::group_commit::tests::
a_failed_unjournaled_commit_names_the_files_it_could_not_put_back fails
on main's `restore` (the error does not mention the replaced file) and
passes here; commands::scan::hosted::tests::put_back_names_each_file_it_
could_not_put_back and in_process_redirect's
a_failed_hosted_write_says_which_files_it_could_not_put_back (a second
lock in a read-only directory, with and without the new
`group_commit_restore` failpoint) pass, as do the group_commit unit
tests, vendor_group_commit_e2e, vendor_crash_safety_e2e and the
in_process_redirect suite.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The hosted trust edit splits pnpm-workspace.yaml at `\n`, inserts its lines and joins with `\n`. The file's own lines keep their `\r`, the inserted ones had none, so a CRLF file ended `...\r\ntrustLockfile: true\n` (audit HE-D3); the scaffold spliced into a CRLF file with no keys was three LF lines. `formats::pnpm::workspace::splice_lines` now inserts the lines in the file's own style (`utils::line_endings::terminator`): in a CRLF file each inserted line that another line follows carries the `\r`, and so does a last line that had no ending until the lines followed it. No other line changes, and an LF file is spliced exactly as before. The restore keeps up: `strip_keyless_splice` matches the scaffold in either line ending (and takes back the `\r` it gave an unterminated last line), so `rollback` still returns a CRLF keyless file byte for byte, and the file hosted mode created is recognised in either line ending, as the engine's `is_trust_scaffold` already does, so a `core.autocrlf` checkout of it is removed like its LF twin instead of being kept with `pnpm_trust_lockfile_left`. Verified: hosted::guidance::tests:: plan_workspace_trust_keeps_crlf_line_endings fails on main (`trustLockfile: true\n` after CRLF lines) and passes here; formats::pnpm::workspace tests and in_process_redirect_pnpm's hosted_trust_edit_keeps_a_crlf_workspace_yaml_crlf (scan, then rollback) pass, as do the hosted::, formats::pnpm and vendor:: unit tests and the hosted_memory_engine / hosted_memory_parity / in_process_vendor_pnpm_takeover suites. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A bun.lockb entry of `.socket/vendor/state.json` can carry the whole
original lock (`layout_original`), and `vendor --revert` parses it and
rebuilds its legacy string buffer to compare it with the live lock. The
live lock is checked for an unknown trailer before any edit, the ledger's
copy was not: one cut one to seven bytes into an extension tag parses as
a lock with an unknown trailer, and the extension walk then sliced eight
bytes past its end (exit 101).
`normalize_production_pool` now checks for the unknown trailer itself,
so no caller can reach the walk without it, and the walk stops where
`parse` does. A snapshot that cannot be read, normalized or hashed is
reported as what it is ("the vendor ledger's snapshot of the original
lockfile (layout_original) cannot be restored from: ..."); the revert
leaves that record alone as drift and keeps the lock, the artifact and
the ledger entry.
Verified: the new codec test panicked with "range end index 1468 out of
range for slice of length 1461" before the change; it and the revert
test (dry run and wet) pass after it.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Hosted mode wrote a reference grant's fields into the project's files as
the patch API sent them. A grant is whatever answered (Socket's API, a
--api-url server, a proxy in between) and nothing checked it, so a
quote, newline, `$`, `<` or `&` in a URL, digest or coordinate changed
the structure of the file it landed in while the run reported success:
- yarn.lock (v1): an integrity.sha1 or sha512 holding `"` and a newline
added a field to the block (the tarball check passed, since an SRI
string is read up to its first whitespace);
- Gemfile.lock: a gemChecksumSha256 holding a newline added a CHECKSUMS
entry for a gem the project never locked;
- pom.xml: markup in mavenSuffixedVersion or indexUrl landed verbatim (a
`system`-scoped path, a pom no longer well formed), and a group id
holding `</dependency>` panicked the run (exit 101);
- composer.lock, requirements.txt and the Gradle / Mill / scala-cli
snippets took the same values raw;
- a plain http:// artifact URL on any host was committed without a word,
so every later install sends the grant token in clear text.
build_candidates is where a grant becomes a rewrite candidate, for the
disk scan and the in-memory engine alike. It now checks every field of
the override it builds (patch::redirect::grant::check) before anything
is planned for it, so no takeover, artifact fetch or rewriter sees a
grant that fails:
- the patch uuid is canonical;
- the artifact URL and a registry override's index URL are https URLs of
plain URL characters (no whitespace, control character, quote,
backslash, `$`, `{}`, `<>`, `&`, `#`, `;`, `|`, `^`). Plain http is
taken only from a loopback address or the --patch-server-url origin
(origin::on_configured_origin, the rule discovery already applies);
- sha1 is 40 hex digits, sha256 64, sha512 one `sha512-<base64>` token,
the Go hashes `h1:` hashes, the berry checksum `<key>/<sha512 hex>`,
the cargo and gem checksums 64 hex digits. A Maven grant's sha256
digests are only held to the digest alphabet: the pom, Gradle and sbt
planners read `sha256-` / `sha256:` spellings and write no checksum
pin for anything else, which stays as documented;
- names, versions and every registry-override identifier are in their
ecosystem's coordinate grammar, through the existing guards
(is_safe_npm_name, is_plain_gem_token, is_plain_nuget_token,
safe_coordinates, the path_safety segments) plus a character set;
- an absent or empty optional field is not checked (the rewriters read
an empty string as "not served").
A grant that fails is skipped under the selection's purl with
redirect_grant_malformed (the detail names the field, never its value)
or redirect_grant_insecure_url (the detail names the host and the
override), and warned with the same code and detail. Nothing is written
for it, the run continues, and the exit code is unchanged. CLI_CONTRACT
documents the gate and both codes.
Tests. scan/hosted_grant_validation.rs drives each sink through the
built binary (`scan` for package-lock.json and yarn.lock, `get <uuid>`
for Gemfile.lock, pom.xml, composer.lock and requirements.txt) with the
payloads the audit reproduced; all six failed before this change (the
grant was written, or the run panicked) and now assert the skip, the
warning and a byte-identical project tree. The grammar is unit-tested in
grant.rs, the engine's skip and warning in engine.rs, and the in-memory
path in hosted_memory_engine.rs. Every golden fixture's overrides.json
passes the gate unchanged.
Fixtures. The CLI tests' `patch.test` origin is now https: its grants
were plain http on a host that is neither loopback nor, in most tests,
configured. The two golang e2e suites' grants named their `file://`
GOPROXY as artifact and index URL; they now name the patch server, as
production's do (go resolves the socket module by path through GOPROXY
either way). The engine unit tests' grants use canonical uuids and
64-digit sha256s.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Defense in depth behind the grant gate: the rewriters that pass a
grant's value through a regex replacement or author XML from it no
longer let the value mean anything but itself.
- composer.lock: the dist `url` and `shasum` went in through
`Regex::replace` with a template (`${1}<url>${2}`), so a `$` in the
value was read as a capture reference: `…zip?x=${1}y` wrote the
matched `"url": "` prefix into the middle of the URL and broke the
JSON. They are now spliced by a closure that reads only the two
captured delimiters.
- Gemfile.lock: an entry added under an empty CHECKSUMS header went in
through the same kind of template; `${1}` in a checksum expanded to
the header's captured line ending. Now a closure, with the same
output for every well-formed value (LF and CRLF pinned).
- Cargo.toml: the `registry = "socket-patch-<uuid>"` replacements use
`regex::NoExpand` (the uuid is already checked canonical there).
- pom.xml: the Maven index escapes `&`, `<` and `>` in the element text
it authors (repository id and url, the managed dependency's
coordinates, a rewritten version). A value can no longer close the
element it sits in, so `authored_element` always finds the close tag
it wrote: a group id holding `</dependency>` used to end the authored
element early and panic at `.expect("authored")`. For a value without
those three characters, which is every value the gate lets through,
the bytes written are the same.
Each has a unit test that failed before: the composer and gem values
are written verbatim, and the three pom payloads leave a pom the
rewriter still reads, with no `<scope>`, `<systemPath>` or comment of
the value's making.
The seeded composer sweep pinned the expansion: one of its URLs is
`…/with$1dollar.zip`, and the oracle it was blessed against dropped
`$1dollar`. composer_lock_rewrite.golden is re-blessed for that. Run
side by side over the 3000 seeds, the template and the closure differ on
410 cases, every one of them a case with that URL among its overrides;
no input digest changes.
The redirect, sbt and upstream-restore golden suites pass unchanged.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e the rename In-place patching wrote one file at a time: stage, fsync, rename, fsync the directory, and only then chown and chmod the new inode. Three defects came out of that shape. An interrupt between the rename and the chmod left the patched bytes under the stage's umask mode for good: a later apply sees the file as already patched and rollback snapshots the wrong mode. A 0755 script came back 0644 in 11 of 12 SIGINT runs on a 150-file package. A chmod the filesystem refuses was reported as a failure of a file whose bytes were already committed. A failure on file N (a blob that does not hold its hash's bytes, EACCES, ENOSPC, a refused rename) left files 1..N-1 patched, and the error named neither the file nor the blob. Rollback had the same loop. Each file cost two device flushes (F_FULLFSYNC on macOS): 17 s to apply a 2,000-file package. The engine now makes a package's changes through one PackageWrite: - Every file is staged before the first is renamed: blob read and hash-checked, parent directory relaxed or created, stage written with the file's final mode and owner (utils::fs::stage_blocking, which the mode-preserving writer for project files now shares; the engine's own restore_file_permissions is gone). The rename is the only step that changes what a path holds. A stage that already has the wanted bits is not chmod-ed, so a filesystem without permissions no longer fails. - Before the first rename every file about to be replaced gets a second name (a hard link; a private copy where the filesystem refuses one). A rename that fails puts the files already replaced back by renaming those names over them, so the very inodes return. Deletes (rollback of a patch-added file) are renames aside until the write is closed. - A failure names the file: `Cannot apply patch: <file> - <cause>`, `Cannot roll back: <file> - <cause>`; a corrupt blob is named with the remedy. On failure `files_patched` / `files_rolled_back` are empty. - Stages are fsynced as they are written; the device cache is flushed once before the first rename and once, with the directory syncs, when the write is closed. A renamed file is still never torn after a crash. What an interruption can still leave is some files renamed and the rest not, since N renames are not one; the renames now run back to back and the next apply or rollback finishes the package. It also sweeps the stages and second names an interrupted run left beside the patch's files once they are ten minutes old (longer than any write holds one, so a run of another project in a shared cache is never swept from under itself). Verified on macOS (APFS). With the pre-change binary and a temporary failpoint between its rename and chmod, the new interrupt_after_the_rename_keeps_the_permission_bits fails (mode 0644); a_corrupt_blob_for_a_later_file_writes_nothing fails (first file left patched); rollback with a corrupt before-blob for the second of three files restores the first and fails. All pass now, with a_rename_failing_midway_leaves_the_package_original and its rollback twin (failpoint `patch_file_commit@2~error`, a new error-injecting failpoint action). 24 SIGINT runs over a 2,000-file package left no wrong mode (the old binary: 11 of 12 on 150 files, all 12 half patched): 18 fully original, 5 finished, 1 half patched, which the next apply completed. Timing on that package, apply / rollback, three runs each on a quiet machine: 16.4-18.8 s / 17.5-18.2 s before, 1.2-1.6 s / 2.0-2.8 s after. With other builds saturating the disk both degrade (one round: 23.6 s / 25.9 s before, 9.5 s / 25.8 s after): the per-file fsync remains, only the two device flushes per file are gone. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every command that rewrites `.socket/manifest.json` (remove, rollback, get, scan) serialized the schema's own fields and nothing else, through the writer that stages with umask defaults. A top-level or per-record field this version does not know (one a newer CLI or the TS tool wrote) was dropped by the first rewrite, without a word, and a private 0600 manifest came back 0644 (owned by root after a sudo run). write_manifest now reads the manifest it is about to replace and carries its unknown top-level fields, and those of every record that is still the same patch (same uuid), into the new document, at the positions the file had them. A removed record takes its fields along and a record another patch replaced does not inherit them. A manifest without such fields is serialized exactly as before, so committed manifests keep their bytes. The write goes through the mode-preserving writer. The fields are merged at the write boundary rather than flattened into PatchManifest / PatchRecord: both are built as struct literals in some seventy files, and a new field would have to be added to each. Not changed: the manifest is still written without a trailing newline. Adding one would change the bytes of every committed manifest. Reproduced with the pre-change binary: `remove` of one of two records dropped `schemaVersion`, `futureTopLevel` and the other record's `futureRecordField`, and left the 0600 file at 0644. The new test_write_manifest_* tests cover an unchanged rewrite (byte-identical, unknown fields in place), a removed and a replaced record, the plain manifest's byte stability and the mode; test_known_keys_match_the_schema fails if a schema field is added without its key. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`apply`, `rollback` and `remove` built the API client before doing anything else, and `repair` built one afterwards just to address its telemetry event. Building the client reads the token, prints the client's advisories and, with a token and no org, asks the API for the org (`GET /v0/organizations`). So a run that fetched nothing still: - printed "No SOCKET_API_TOKEN set ... using the public patch API proxy" (or "Could not determine your organization ...") although no API was used, and reported `api_auth_fallback` in `--json`; - waited on the org lookup. A no-op `apply` with a token and no `--org` (the run CI makes after every install) took 12 s against an endpoint that drops packets and 65 s against one that accepts and never answers, with its output held back the whole time. The run's client is now a cell (`RunApiClient`, the one embedded `--vex` already used, moved to `args.rs`) that the first step that fetches fills through `GlobalArgs::api_client`: the staging download in `apply`, the before-blob download in `rollback` / `remove`, the Maven jar service, `repair`'s download pass. `get` and `scan --mode agent` seed it with their own client for the nested apply. A run that finds everything on disk makes no API request, prints no advisory and reports no fallback. Telemetry follows the client's route once one exists (`GlobalArgs::telemetry_auth_for`) and otherwise takes it from the credentials alone, as `list` always did. A token with no configured org therefore reports on the anonymous route when the run fetched nothing: the org is never looked up only to address an event. Not lazy: an `apply` with a Maven patch in scope still builds the client up front, because the jar-service config it hands to core holds one. Tests, each failing before this change: - apply/rollback/remove/repair with a token and no org and everything cached: the mock sees only `POST /patch/telemetry` (before: `GET /v0/organizations`, then the org telemetry route). - a tokenless apply / rollback that fetches nothing prints no proxy advisory; the one that downloads prints it once. - an apply that fetches nothing never resolves the org and reports no `api_auth_fallback`; the one that downloads still does (#648). - `stage_patch_sources` leaves the cell empty unless it downloads. CA-P01, CA-U07. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The first /code-review pass did not read the vendor backends, VEX discovery, the hosted rewriters, the group commit or the JVM code. A second pass over those returned four candidates; three are fixed here. - The hosted gem rewriter could append `source:` after an options hash. A trailing non-literal argument (`gem "rack", OPTS`) is taken for a version requirement when the lock shows one requirement per positional argument. That count was wrong in two cases: an array literal is several requirements in one argument (`gem "rack", [">= 2.0", "< 3"], OPTS`), and a `gemspec` directive lets bundler fold the gemspec's own requirement into the lock entry. In both, `OPTS` may be the options hash, and with an option after it bundler reads it as a version requirement: the Gemfile no longer loads. Such a declaration is now refused (`redirect_gem_unrecognized_declaration`) unless every other positional is a single quoted string and the manifest has no `gemspec`. - A revert decided whether a left-alone record could be discarded by searching the warning's text for "was re-resolved since vendoring". Other warnings quote ledger text (an unknown wiring kind and its key), so a committed ledger could make an unknown-kind record, which must keep the artifact, read as a re-resolved one. The fact is now a flag set only where that warning is built. - The hosted takeover took any drift warning for a revert that kept its artifact. A revert that finished because the dependency had moved on still reports which entries moved, so the takeover rolled it back and refused. It now asks whether the revert finished. Not changed: each `bun.lockb` ledger record carries the pre-vendor lock when the lock needed normalizing. Storing it once needs a ledger-level field older CLIs would not read; it is listed for the owner. Tests: three new refusal cases in gemfile_declarations_that_cannot_take_the_option_are_refused_with_a_remedy (array literal, %w, gemspec; they fail without the fix). Suites: core vendor::, patch::redirect, formats::gem (4017); CLI in_process_vendor, vendor, scan_vendor_e2e, in_process_redirect, in_process_vendor_pnpm_takeover, mode_migration_npm, command_module_layering, e2e_redirect_gem_stale_install; real Bundler e2e_redirect_gem_build --include-ignored (42). fmt and clippy clean. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`vex` read a Bundler lock as wiring by itself: a `GEM` section on the
patch registry made every spec under it `not_affected (redirected)`, a
`PATH` section under `.socket/vendor/gem/` made it `(vendored)`. The
Gemfile was read only to split a merged multi-remote section.
Bundler does not work that way. It takes a gem from the source its Gemfile
declaration names and treats a lock that says otherwise as out of date.
Measured with a local index on Bundler 1.17.2, 2.1.4, 2.4.22, 2.5.23 and
4.0.15, on fresh checkouts of each pair:
- Gemfile reverted to `gem "x", "1.0.0"`, lock still on the patch
registry (with or without the `!` in DEPENDENCIES): a frozen install
exits 16 ("the list of sources changed"), an unfrozen one rewrites the
lock to the upstream lock and installs the upstream gem;
- the same for a gem only another gem depends on, whose Socket section
the Gemfile never names;
- the same for a vendored `PATH` section whose `path:` the Gemfile lost
(2.4.22 crashes the frozen install instead of exiting 16);
- the pairs both writers leave install the patched gem, frozen and not.
So a Gemfile revert, or a merge that takes the other side of the Gemfile,
left a statement over an install of the unpatched gem.
A spec under a Socket-wired section is now a reference only when the
manifest Bundler loads beside that lock (`Gemfile` for `Gemfile.lock`,
`gems.rb` for `gems.locked`) gives the gem that source: every `gem`
declaration of it carries `source: "<the section's remote>"` or sits in a
`source "<remote>" do` block (hosted), or carries `path:` naming the
section's vendored directory (vendored), and there is one
(`ManifestSources`). The declarations are read with the reader the hosted
rewriter writes them with (`formats::gem::declarations`, block nesting
from `formats::gem::ruby`), so every form the writers leave passes: the
appended option in any call shape, the line added for a transitive gem,
the 5.0 / 5.1 block (nested in a user's block too), `vendor`'s `path:`
with the options it keeps after it (a `**opts` splat included) and its
managed block, LF or CRLF. A second source-selecting option beside ours
does not (`git:` / `path:` win over `source:` in Bundler), nor does a
source that is not a plain string, nor a lock with no manifest beside it.
A hosted spec the manifest does not confirm is still one gem version's
pin in the lock, so it is kept in `Discovery::shadowed`: never attested,
no ledger claim, still what `rollback` / `remove` / the vendored takeover
unwind. A vendored one is no reference; its ledger entry reads
`vendor_unwired` and the `patched_ref_unattributable` diagnostic keeps it
from the prune GC. The diagnostic names the gem, what the Gemfile says
instead, and both ways out (re-run the scan or vendor, or `bundle lock`).
The confirming read is quiet and recognizes nothing, so the ledger
fallback for a Gemfile-only pin answers as before; a merged section's
cross-check keeps its guarded read. The lock twin Bundler ignores is
explained without the confirmation (its manifest may be gone).
Fixtures that staged a wired lock with no manifest now stage the pair:
eight gem discovery unit tests, the cross-ecosystem `--no-verify`
capstone and the detached vendor matrix. No discovery golden changed.
Tests: a_hosted_section_the_manifest_does_not_name_is_not_a_ref and its
vendored twin (both fail with the confirmation off), the two positive
form matrices, and the CLI's
a_lock_section_the_manifest_does_not_name_attests_nothing and
hosted_source_option_pair_attests_in_every_era. The real-Bundler
capstones gain a manifest-only-revert leg each (the hosted one proves the
frozen refusal, the rewritten lock and the upstream bytes; the vendored
one the refusal and the dropped section) and pass on all five Bundler
lines above.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
For a dependency with no literal `<version>` in the root pom, hosted scan pins the patched version in the root's `<dependencyManagement>` (`redirect_maven_dep_management_added`), and `vex` attested that pin from the root pom alone. Maven applies a managed version only to a dependency that declares none, and a pom inherits its parents' `<dependencies>`, so a parent pom that declares the package with a version decides what the build resolves. Discovery read no parent pom. Measured with real Maven 3.9.16 (`dependency:list`, slf4j-api from Central, the Socket repository unreachable), the root holding only the managed pin: - the parent, the parent's parent, or an active profile of the parent declares it at 1.7.36: the build lists 1.7.36 and never asks for the patched version; - the parent declares it with no version, or re-declares it with none over a literal above, or the root declares it itself with no version over the parent's literal, or nothing declares it (transitive): Maven asks for the patched version. Discovery now follows `<parent>` through the checkout for a pin of that shape (`Ancestors`: `<relativePath>`, default `../pom.xml`, a file or its directory, taken only when the pom there is the parent the reference names; read through the view, so never outside the git checkout): - a parent pom, one above it, or a profile of either that declares the package with a version keeps the ref (rollback, remove and list still find it) and marks it unattested, `MavenParentDeclares`, omitted as `vex_maven_parent_declares` with the parent pom named and the remedy (declare the package in the root pom with no version); - the nearest parent declaring it with no version, or a chain read to a pom with no `<parent>`, changes nothing; - a parent that cannot be read (`<relativePath/>` or no pom at the relative path, so Maven resolves it from a repository; another project's pom there; one that does not parse; one outside the checkout) may declare it either way. The ref then carries no pin and requires one, the shape Gradle refs use, so it never takes the lockfile basis: `package_not_found` until a build has resolved the patched version, attested from that installed copy afterwards. A pin on the root pom's own declaration, or one governing a declaration the root makes with no version, is not weighed against anything. A coordinate a parent spells through a property may name the package, so it counts. This costs statements: a project whose parent is not in the checkout (Spring Initializr writes `<relativePath/>`) loses the pre-build statement for a managed-only pin and gets it back after a build. No committed fixture or capstone has a `<parent>`, so no golden changed. Tests: a_managed_pin_a_parent_pom_overrides_is_not_attested, a_managed_pin_under_an_unread_parent_needs_the_installed_copy (both fail with the check off) and a_managed_pin_no_parent_pom_overrides_is_attested (core), and the CLI's maven_hosted_managed_pin_is_weighed_against_the_parent_pom. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…m remedy that works Two loose ends of the fail-closed cargo and gem rules. The scan command's takeover classifier test staged the hosted side of a vendored-to-hosted cargo takeover as a Socket-sourced `Cargo.lock` and a registry definition, with no `Cargo.toml`. Hosted scan never leaves that: the manifest pin is what makes cargo resolve the crate from the lock's source, and a lock entry no declaration routes is now withheld. The fixture writes the pin, and the test passes as it was written (`cargo_takeover_classifies_hosted_when_the_lock_points_at_the_socket_registry` was the one CLI unit test the cargo change turned red). The diagnostic for a Bundler lock section the Gemfile does not name told the user to re-run the scan. That does not work while the lock's DEPENDENCIES entry is still source-pinned: the hosted rewriter skips such a gem (`redirect_gem_source_option`). The remedy that does work, on every Bundler line measured, is an unfrozen `bundle install`, which makes the lock follow the Gemfile, and then the scan. The diagnostic and docs/ecosystems.md say that now, and that `rollback` / `remove` still see the lock section (checked with the built binary: `list` shows the pin, `rollback --offline` reaches the restore and stops only for want of the registry). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
CHANGELOG.md is written only when a release is cut, by the release agent, from the merged PRs and the code (AGENTS.md). This branch is not a release branch, so its 606 added changelog lines come out here; the same details are in the PR description and the commit messages. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011f9aeSCJbrmVGsRGmsy7w4
Four entries under `### Changed` for the branch merged in the previous commit (Cargo, Composer, RubyGems, Maven), written into the 5.1.0 section at the owner's explicit request for this pull request, like the others. Each states what `vex` no longer attests and the code it reports. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A repository routine restored CHANGELOG.md on this branch (bf8925d), as AGENTS.md has it for any branch that is not a release branch. The 5.1.0 notes for these changes are kept, unchanged, on the release branch `release/v5.1.0-review-notes`, which the rule allows to edit the file. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
Ready for review (burn-down agent)
Generated by Claude Code |
A trailing `\` carried a hosted gem's declaration over any number of lines: `last` kept the backslash after its newline, so a blank or comment-only line that ends the statement in Ruby did not end it here. `gem 'rack', '~> 2.2' \` followed by a blank line and `gem 'puma'` read as one call, and the Gemfile edit put `source:` on puma while the lock pinned rack. The backslash now carries the call on one line only; a trailing comma still carries it over blank and comment lines. A bare `rollback` treated a project whose mode a bare `scan` cannot tell (agent-mode patches beside a vendor ledger) as one a scan looks below, so it rolled back the patched projects below `--cwd` while the scan stops with `mode_ambiguous` and touches nothing. Only a hosted or vendored project now sends rollback below `--cwd`. Both come with tests that fail without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Final review (merge steward): two fixes pushed in I reviewed this PR in five parts with parallel readers, focusing on the code the PR's own review only searched for panics: the Maven reactor's second half, the vlt/uv/yarn/legacy-pnpm/bun vendor backends, Fixed here (each with a test that fails without the fix):
Not blocking, for the owner's follow-up list:
Checked with no problems found: every spawn site removes the Socket credential variables from the child's environment. Journal entries can't reach Removed Generated by Claude Code |
|
bugbot run Generated by Claude Code |
|
Final review brief: #2052 What it does: merges the 24 fix branches from the full 5.1.0 review. The rule behind them is that patching must never break a user's build and a skipped patch is always reported. Main areas: all-or-nothing apply and rollback, stricter Risk: high, because of its size (298 files, +56k/-8k) and because it changes behaviour on purpose. The PR description lists 9 behaviour changes under "Behaviour changes to confirm in review", with exit codes and refusals. Those 9 are where to spend your two minutes. The code itself has been reviewed by 20 auditors, a pre-PR review and this final review. Look here:
Verified:
Changes I made: Open questions: none blocking. Merge #2053 (the changelog) after this PR. Auto-merge is armed (squash, 2026-11-11 by the merge steward), so approving sends this PR straight to the merge queue. Merge #2053 after it. Generated by Claude Code |
|
Ready for review (burn-down agent, re-applied after the merge steward's fixes)
Generated by Claude Code |
What this is
One pull request for the fixes that came out of a full review of socket-patch 5.1.0 (every production file read by 20 independent auditors, then a hands-on smoke test with real package managers against the public patch catalog). The review was judged against one rule: patching must never break the user's code, and a failed or skipped patch must be reported, never hidden.
It merges 24 fix branches, each started from a test that failed on
main, plus the commits that reconcile them with each other and fix what the pre-PR code review found.The 631 audit findings and the smoke-test results are in the owner's audit report.
What changes for users
Do no harm (agent, hosted and vendored modes)
scan --prune/--syncrun from a project no longer empties that project's manifest and deletes its blobs; nothing is swept after a failed manifest write.--proxy-url, a bare 404) fails instead of printing "no patches"..git; a vendor ledger can only undo socket-patch's own edits; spawned tools do not inherit Socket credentials.bun.lockb, Gradle builds) no longer panic, hang or exhaust memory.DO_NOT_TRACK, and is documented.RubyGems / Bundler (hosted mode)
source: "<url>"to the user's owngemline instead of replacing it with asource … doblock: version ranges, comments, quoting,gem(...)and multi-line declarations are kept, androllbackrestores the file byte for byte.CHECKSUMS) are converged to exactly what that Bundler'sbundle lockwrites, so frozen and deployment installs pass (they failed with exit 16).vendor/cachearchives) are replaced with the verified patched gem, so the patched code runs with no further command;rollbackputs the upstream gem back. A shared gem home is left untouched with one short instruction (--gem-refresh-sharedopts in).VEX
vexwithholds a statement whenever the lockfile evidence does not back it: vendor ledger entries no lockfile wires, hosted URLs clients read differently, locks the package manager is told not to read, second copies of a release, PEP 723 script locks, workspace members' stray locks (JS, Python, pnpm, yarn, Gradle); aCargo.lockentry no declaration routes, a gem whose Gemfile does not name the source its lock does, a Maven<dependencyManagement>pin a parent pom overrides or that sits under an unread parent, and a Composer install the crawler cannot read.--no-verifysays so when nothing was verified.Scans in a monorepo, and output
scaninside a git checkout finds every project below it by lockfile;listandrollbackat the same directory cover the same projects; the other commands name the projects to run in.Vendored mode
package.jsonby byte span instead of re-serializing it; reverts never delete an artifact a lockfile still references. uv rollback works with one dependency and vendored uv keeps the locked version. Vendored Maven POMs resolve what their upstream POMs do.bun.lockband vlt reverts are exact and bounded.How it was verified
cargo test -p socket-patch-core,-p socket-patch-cli,--no-fail-fast): 8,377 core and 4,873 CLI tests passed. The 5 failures were 2 source-reading guards and 1 real defect, all fixed in the two commits that follow (and re-run), and 2e2e_vendor_cargo_buildold-toolchain tests that fail on this machine only (the pinned rustup 1.41.1 toolchain is x86_64 and the host has no Rosetta).cargo fmt --all -- --checkandcargo clippy --locked -p socket-patch-core -p socket-patch-cli --all-features -- -D warnings, all clean.--ignored), 30 binaries on macOS arm64: npm, pnpm (7, 8, 10, 11 via corepack), yarn, uv, Poetry, Bundler 4.0.15, Go, Cargo, Maven, .NET and the live-API suites. Nothing fails on this branch that passes onmain. Four tests fail on both:e2e_scan::test_scan_json_read_only_emits_updates_array(stale since Decide: make the command model read-only scan plus fix/undo/sync, with mode taken from project state, and make "nothing to undo" exit 0 #1088: a bare scan keeps an agent project's mode),e2e_pypi(nopipon this machine), the yarn-berry install proof (local yarn is 1.22) and the gem monorepo crawl gap.87de43a3dand again onaaeeaefff, including the whole workspace under coverage and the real-toolchain suites this machine has no tools for (bun, vlt, Composer, Gradle, PDM, Hatch, Pipenv). Skipped for pull requests, so not run anywhere yet: the macOS and Windows end-to-end jobs, the Windows compile check, the Docker / extended / full end-to-end jobs (sbt among them) and the old Cargo toolchain matrix. The Windows paths of the new write engine, the gem refresh and the closed-pipe handling were reviewed, not run.path vendor/bundle, transitive gems, unusual declaration forms, frozen and deployment installs, scan → rollback byte-exactness).Behaviour changes to confirm in review
These are deliberate, and each is a place where a working setup can notice a difference. The changelog entries state every exit-code change, withheld
vexstatement, new file write and new refusal in full.repair --offlineexits 1 when blobs are missing or corrupt (was: a note and exit 0).vexis stricter. In particular:path:) is no longer attested until one plain install puts the lock back. 5.1.0 attested it on purpose; the two real-tool suites that pinned that are updated;<dependencyManagement>pin for a package the root pom does not declare is not attested from the pom alone when the parent pom cannot be read (<relativePath/>, which Spring Initializr writes). Standalonevexreadspackage_not_foundthere until a build has resolved the patched version. A remote parent that declares the package with a literal version makes Maven build the upstream jar (checked with Maven 3.9.16), which is why. Relaxing it is a one-line change if the owner prefers the statement;source … doblock or with its ownsource:/git:/path:, and refuse a lock with neitherBUNDLED WITHnorCHECKSUMS. The rewriter's edit kindredirect_gemfile_source_blockis replaced byredirect_gemfile_source_option/redirect_gemfile_dependency_line. Projects pinned with the old block form keep working and are not migrated.vendor/bundle, a project-localGEM_HOME,vendor/cache) and can compile a native-extension gem while doing so.--no-gem-install-refreshturns it off.httppatch grants are refused unless that origin was configured with--patch-server-url; a configured origin is compared on scheme as well as host.scanin a git checkout touches every project below--cwd, not only--cwd(approved by the owner on 2026-11-10); a barerollbackat that directory undoes the same set after one confirmation. Submodules and nested clones are never entered.scan/getfrom a pnpm or yarn 1 workspace member that has its own lock now exit 1 (redirect_workspace_lockfile_elsewhere) instead of pinning a lock the package manager never reads. Workspaces withsharedWorkspaceLockfile: falseare unaffected.Not in this PR
tests/fixtures/redirect/gem/bundler/basic/expected/{Gemfile,expected-edits.json}, which change here; it needs the matching change. depscan'ssuffixMavenPomhas the vendored-POM defect fixed here on the CLI side..socket/manifest.jsonafter a full rollback, exit codes when there is nothing to do,apply --checkat a monorepo root, binding a hosted lock pin to the patched artifact's digest forvex, machine-wide Python fallback in hosted mode, the telemetry default, and release-pipeline approvals.bundle outdatedno longer listing a hosted-patched gem; a false vendored-modevexwarning).Code review
/code-review highran over the whole diff before this PR was opened (291 files, about 53k added lines), in two passes because of its size. The first read the CLI command layer, project discovery, the API client, the gem refresh, the apply/rollback write path, the lock and the stdio/process/fs utilities. The second read the group commit, the hosted rewriters and their restore, the gem and yarn/pnpm format readers, the Gradle code, VEX discovery for npm, PyPI and gem, and most of the vendor backends. Neither read every file: the second half ofvendor/jvm/maven_reactor.rs, the vlt, uv, yarn and legacy-pnpm vendor backends,formats/maven, and the uv/cargo/nuget restores were only searched for panic-prone patterns.First pass: ten candidates. Seven are fixed in
9bad0dbdd(plus87de43a3d, an import-layering slip in that fix which CI caught):.ignorefiles git does not;rollbackreached further than a barescanfrom an agent-mode project, androllback --jsoncould roll back a subdirectory;rollbackwalked the whole checkout before running;.socket-patch-refreshsymlink;== <dir> ==headings on Windows.Three are not fixed:
patches-api.socket.dev: an unknown or malformed purl inPOST /patch/batchis answered200with askipped[]entry, and both400shapes the per-package route sends are ones the client accepts. Failing on any other shape is the point of the fix.listandrollbacktake the socket.yml bypass only fromSOCKET_NO_SOCKET_YML, whilescanalso has--no-socket-yml. A project scanned with that flag is not listed or rolled back from the root unless the variable is set. Left as documented behaviour: adding the flag to two more commands is an interface decision.listreads each project's stores twice, and a non-silentapplywith no manifest searches the directories below. Measured at about 0.15 s on this repository with the debug build;apply --silentdoes not search. Left as a performance follow-up.Second pass: four candidates. Three are fixed in
aaeeaefff:source:after an options hash when another argument was an array literal, or when agemspecdirective supplied the lock's requirement, leaving a Gemfile Bundler cannot load;One is not fixed: each
bun.lockbledger record carries the pre-vendor lock when the lock needed normalizing, so the ledger grows with the number of vendored packages. Storing it once needs a ledger-level field older CLIs would not read; it is on the owner's decision list.Generated by Claude Code