Skip to content

Commit 0ee6dd4

Browse files
committed
Match PyPI names by PEP 503 form in remove
`remove pkg:pypi/typing_extensions@4.7.1` and the same `rollback` exited 1 with "No patch found" and left the patch in place, although `get` had applied it with that exact identifier. Patches are stored under the canonical name (`typing-extensions`), but users and scripts type the name the way pyproject.toml or requirements.txt spells it (`typing_extensions`, `Jinja2`, `ruamel.yaml`). purl equality now also treats two pypi purls as equal when their names share a PEP 503 canonical form, next to the existing composer rule, so the manifest, hosted-pin and vendor-ledger matchers that `remove` and `rollback` use all accept every spelling. Versions and qualifiers still have to match exactly. CLI_CONTRACT.md documents the rule for both commands. Fixes #1024 Assisted-by: Claude Code:claude-opus-5-5
1 parent 0692894 commit 0ee6dd4

3 files changed

Lines changed: 31 additions & 14 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments
103103
| `scan` | `--min-severity <critical\|high\|medium\|moderate\|low\|none>` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. |
104104
| `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not |
105105
| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--mode <hosted\|vendored\|agent>` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) |
106-
| `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var |
106+
| `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. The identifier matches like a `rollback` target (a PyPI name compares by its PEP 503 canonical form). `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var |
107107
| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--preserve-state` (v5.0) | `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) |
108108
| `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below |
109109
| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) |
@@ -885,7 +885,7 @@ worse, lets a warm cache silently serve unpatched bytes):
885885

886886
### Targets
887887

888-
`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), 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):
888+
`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):
889889

890890
* **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.
891891
* **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.**

‎crates/socket-patch-cli/tests/remove/pypi_name_spellings.rs‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ fn patched_fixture() -> Fixture {
7272
serde_json::to_vec_pretty(&manifest).expect("serialize manifest"),
7373
)
7474
.expect("write manifest");
75-
std::fs::write(socket.join("blobs").join(common::git_sha256(BEFORE)), BEFORE)
76-
.expect("stage before-blob");
75+
std::fs::write(
76+
socket.join("blobs").join(common::git_sha256(BEFORE)),
77+
BEFORE,
78+
)
79+
.expect("stage before-blob");
7780
Fixture {
7881
_tmp: tmp,
7982
root,
@@ -107,10 +110,8 @@ const SPELLINGS: &[&str] = &[
107110
fn remove_selects_pypi_patch_by_any_pep503_spelling() {
108111
for spelling in SPELLINGS {
109112
let fx = patched_fixture();
110-
let (code, stdout, stderr) = run(
111-
&fx,
112-
&["remove", spelling, "--json", "--yes", "--offline"],
113-
);
113+
let (code, stdout, stderr) =
114+
run(&fx, &["remove", spelling, "--json", "--yes", "--offline"]);
114115
assert_eq!(
115116
code, 0,
116117
"remove {spelling} must select {KEY}; stdout=\n{stdout}\nstderr=\n{stderr}"
@@ -131,10 +132,8 @@ fn remove_selects_pypi_patch_by_any_pep503_spelling() {
131132
fn rollback_selects_pypi_patch_by_any_pep503_spelling() {
132133
for spelling in SPELLINGS {
133134
let fx = patched_fixture();
134-
let (code, stdout, stderr) = run(
135-
&fx,
136-
&["rollback", spelling, "--json", "--yes", "--offline"],
137-
);
135+
let (code, stdout, stderr) =
136+
run(&fx, &["rollback", spelling, "--json", "--yes", "--offline"]);
138137
assert_eq!(
139138
code, 0,
140139
"rollback {spelling} must select {KEY}; stdout=\n{stdout}\nstderr=\n{stderr}"

‎crates/socket-patch-core/src/utils/purl.rs‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ pub fn normalize_purl(purl: &str) -> Cow<'_, str> {
108108
/// Purl equality up to percent-encoding of the base components
109109
/// (`pkg:npm/%40scope/x@1` ≡ `pkg:npm/@scope/x@1`) and, for composer, up to
110110
/// the version spelling of one release (`@3.0.2` ≡ `@v3.0.2` ≡ `@3.0.2.0`,
111-
/// name case-insensitive; see [`crate::utils::composer_version`]).
111+
/// name case-insensitive; see [`crate::utils::composer_version`]) and, for
112+
/// pypi, up to the PEP 503 spelling of the name (`typing_extensions` ≡
113+
/// `Typing-Extensions` ≡ `typing-extensions`).
112114
/// Qualifiers and subpath must still match exactly.
113115
pub fn purl_eq(a: &str, b: &str) -> bool {
114116
let (a, b) = (normalize_purl(a), normalize_purl(b));
@@ -119,7 +121,23 @@ pub fn purl_eq(a: &str, b: &str) -> bool {
119121
let (base_a, suffix_a) = a.split_at(split(&a));
120122
let (base_b, suffix_b) = b.split_at(split(&b));
121123
suffix_a == suffix_b
122-
&& crate::utils::composer_version::composer_bases_equivalent(base_a, base_b)
124+
&& (crate::utils::composer_version::composer_bases_equivalent(base_a, base_b)
125+
|| pypi_bases_equivalent(base_a, base_b))
126+
}
127+
128+
/// Whether two decoded `pkg:pypi/<name>@<version>` bases name the same
129+
/// release once both names are in PEP 503 canonical form. `false` unless
130+
/// both are pypi bases.
131+
fn pypi_bases_equivalent(a: &str, b: &str) -> bool {
132+
fn canonical(base: &str) -> Option<String> {
133+
let rest = base.strip_prefix("pkg:pypi/")?;
134+
let (name, version) = match rest.rfind('@').filter(|&i| i > 0) {
135+
Some(at) => rest.split_at(at),
136+
None => (rest, ""),
137+
};
138+
Some(format!("{}{version}", canonicalize_pypi_name(name)))
139+
}
140+
matches!((canonical(a), canonical(b)), (Some(x), Some(y)) if x == y)
123141
}
124142

125143
/// Extract the value of a single PURL qualifier (`?key=value&…`), if present.

0 commit comments

Comments
 (0)