Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
11 changes: 8 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,12 @@ worse, lets a warm cache silently serve unpatched bytes):
(purl or patch uuid; v5 keeps no hosted ledger) restores each matched pin to its default
upstream registry entry — the same restore as `rollback` (see "Hosted unwind coverage"), for every
ecosystem. A hosted-only match works with no manifest at all (mirroring the manifest-less
vendored escape). A pin the restore refuses (`--offline`, a registry that does not answer,
vendored escape). **One owned pin per release**: an identifier that matches a manifest entry
also selects the vendored ledger entry that entry claims and every hosted pin wiring the same
package release, whatever patch uuid (generation) each recorded — so `remove <uuid A>` of a
record a later scan superseded with patch B (vendored or hosted) unwinds B's wiring too. That
hosted restore needs the registry lookups below, so such a remove can refuse offline where the
manifest entry alone would not. A pin the restore refuses (`--offline`, a registry that does not answer,
`bun.lockb`, …) or a failed write is the top-level `hosted_revert_failed` error BEFORE the
manifest mutation (exit 1, manifest not modified; message `could not restore <purl> to its
upstream registry entry: … (`git checkout -- <files>`)`). v4's `hosted_revert_unsupported` is no
Expand Down Expand Up @@ -899,7 +904,7 @@ worse, lets a warm cache silently serve unpatched bytes):

### Targets

`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact; PyPI names compare by their PEP 503 canonical form, so `pkg:pypi/typing_extensions@4.7.1` selects `pkg:pypi/typing-extensions@4.7.1`), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan):
`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact; PyPI names compare by their PEP 503 canonical form, so `pkg:pypi/typing_extensions@4.7.1` selects `pkg:pypi/typing-extensions@4.7.1`), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); an identifier that matches a manifest entry also selects the vendored ledger entry that entry claims and every hosted pin of the same package release under any patch uuid (one owned pin per release — `remove` parity); an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan):

* **A target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0).** Each rollback path pattern must select at least one patched package; the error names the pattern and the reachability rule.
* **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.**
Expand Down Expand Up @@ -934,7 +939,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
* **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format:
* **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy.
* **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together.
* **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-<uuid>"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-<uuid>]` block leaves the project cargo config. A declaration it cannot unpin refuses.
* **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-<uuid>"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-<uuid>]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses.
* **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module.
* **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). A restored `requirements.txt` line gets `--hash` options only when the file is in pip's hash-checking mode. The mode is read off the file's other requirement lines (an `-e` / `--editable` line means unhashed). When every requirement is a hosted pin, it is read off the hosted line itself (`--hash` vs a `#sha256=` url fragment) (#410). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`).
* **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "<patch registry>" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "<name>", "<version>"`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org.
Expand Down
38 changes: 18 additions & 20 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use clap::Args;
use socket_patch_core::api::client::get_api_client_with_overrides;
use socket_patch_core::ledgers::hosted_pins_matching;
use socket_patch_core::manifest::cleanup_blobs::{format_bytes, ArtifactReferences};
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
use socket_patch_core::manifest::schema::PatchManifest;
Expand All @@ -24,28 +25,23 @@ use crate::ui::plural;

/// Vendor-ledger entries matching a remove identifier
/// ([`socket_patch_core::ledgers::Ledgers::matching`]), sorted by key for
/// deterministic event order.
fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> {
/// deterministic event order. With the manifest, an entry the matched
/// manifest keys claim matches whatever patch generation it recorded
/// (#999); the ledger-only path passes `None`.
fn vendor_entries_matching(
state: &VendorState,
manifest: Option<&PatchManifest>,
identifier: &str,
) -> Vec<(String, VendorEntry)> {
socket_patch_core::ledgers::Ledgers {
manifest,
vendor: Some(state),
..Default::default()
}
.matching(identifier)
.vendor
}

/// The lockfiles' hosted pins matching a remove identifier (by purl or
/// patch uuid), sorted by purl.
fn hosted_pins_matching(pins: &[HostedPin], identifier: &str) -> Vec<HostedPin> {
let mut matches: Vec<HostedPin> = pins
.iter()
.filter(|pin| patch_matches(&pin.purl, &pin.uuid, identifier))
.cloned()
.collect();
matches.sort_by(|a, b| a.purl.cmp(&b.purl));
matches
}

/// Drop every manifest entry matching `identifier` except `exclusions`
/// (drift-kept vendored purls, whose record must survive with their
/// vendored state). Returns the removed purls, sorted.
Expand Down Expand Up @@ -444,6 +440,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
.filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier))
.collect();
matching.sort_by(|a, b| a.0.cmp(b.0));
let matched_keys: Vec<String> = matching.iter().map(|(purl, _)| (*purl).clone()).collect();

// The vendor ledger, loaded ONCE under the lock: it scopes the nested
// rollback (vendor-owned purls are not restored in place) and drives
Expand All @@ -462,7 +459,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
// --revert`'s all-at-once). An unreadable ledger falls through to
// `not_found`: nothing is mutated on that path.
if let Ok(state) = vendor_state_result {
let ledger_matches = vendor_entries_matching(&state, &args.identifier);
let ledger_matches = vendor_entries_matching(&state, None, &args.identifier);
if !ledger_matches.is_empty() {
return remove_ledger_only(
&args,
Expand All @@ -478,7 +475,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
// Hosted-only patches likewise have no manifest entry — their
// lockfile pins are their only persistence, and `remove` is their
// per-purl exit path (restoring the upstream entry IS the removal).
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier);
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &[]);
if !hosted_matches.is_empty() {
return remove_hosted_only(
&args,
Expand Down Expand Up @@ -541,9 +538,9 @@ pub async fn run(args: RemoveArgs) -> i32 {
} else {
let vendored = vendor_state_result
.as_ref()
.map(|st| vendor_entries_matching(st, &args.identifier).len())
.map(|st| vendor_entries_matching(st, Some(&manifest), &args.identifier).len())
.unwrap_or(0);
let hosted = hosted_pins_matching(&hosted_pins, &args.identifier).len();
let hosted = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys).len();
(vendored, hosted)
};
let prompt = remove_prompt(
Expand Down Expand Up @@ -723,7 +720,8 @@ pub async fn run(args: RemoveArgs) -> i32 {
return 1;
}
};
let vendored_matches = vendor_entries_matching(&vendor_state, &args.identifier);
let vendored_matches =
vendor_entries_matching(&vendor_state, Some(&manifest), &args.identifier);
let mut vendor_leg = RemoveVendorLeg::default();
if !vendored_matches.is_empty() {
if args.skip_rollback {
Expand Down Expand Up @@ -772,7 +770,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
// carried into the success envelope's `warnings[]`.
let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new();
if !args.skip_rollback {
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier);
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys);
if !hosted_matches.is_empty() {
let leg = match unwind_hosted(&args.common, &hosted_matches).await {
Ok(leg) => {
Expand Down
19 changes: 8 additions & 11 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ use socket_patch_core::patch::rollback::{
VerifyRollbackResult, VerifyRollbackStatus,
};
use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back};
use socket_patch_core::utils::composer_version::composer_purls_equivalent;
use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers};
use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState};
use socket_patch_core::vex::discover::canonical_base_purl;
use socket_patch_core::vex::discover::{canonical_base_purl, same_release};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
Expand Down Expand Up @@ -1248,16 +1247,14 @@ pub async fn run(args: RollbackArgs) -> i32 {
};
for id in &identifiers {
let found = ledgers.matching(id);
let mut matched = !found.is_empty();
// Hosted pins live in the lockfiles, not in a store: matched by the
// identifier, or as another generation of a matched manifest key.
let pins =
socket_patch_core::ledgers::hosted_pins_matching(&hosted_pins, id, &found.manifest);
let matched = !found.is_empty() || !pins.is_empty();
manifest_scope.extend(found.manifest);
vendor_scope.extend(found.vendor.into_iter().map(|(k, _)| k));
// Hosted pins live in the lockfiles, not in a store.
for (purl, uuid) in &redirect_records {
if patch_matches(purl, uuid, id) {
hosted_scope.insert(purl.clone());
matched = true;
}
}
hosted_scope.extend(pins.into_iter().map(|pin| pin.purl));
if !matched {
let hint = if id.starts_with("pkg:") || looks_like_uuid(id) {
String::new()
Expand Down Expand Up @@ -2817,7 +2814,7 @@ pub(crate) fn superseded_by_hosted(
let pkg = canonical_base_purl(purl);
let same: Vec<&HostedPin> = pins
.iter()
.filter(|pin| pin.purl == pkg || composer_purls_equivalent(&pin.purl, &pkg))
.filter(|pin| same_release(&pin.purl, &pkg))
.collect();
if same.iter().any(|pin| pin.uuid == record.uuid) {
return None;
Expand Down
Loading
Loading