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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex

**Bare-UUID fallback.** `socket-patch <UUID>` is rewritten to `socket-patch get <UUID>`, also when root-position flags come first (`socket-patch --json <UUID>`); a UUID after a subcommand name is that subcommand's operand. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive), the target grammar's.

**Target grammar (v5.0).** `get`, `remove`, `rollback` and the bare-UUID fallback classify their package/patch token with one parser (core `utils::target`): a UUID; `CVE-…` / `GHSA-…` (case-insensitive); a `pkg:` purl (with a version: that release, a base purl covering every release variant and a `?qualified` one exactly one; without a version: every version); otherwise an **exact** package name — full name or last segment, case-insensitive, PEP 503 for PyPI — the same matcher as `scan --package` and `socket.yml`. A name never matches by prefix or substring, and a Go major-version suffix (`v2`) is never a name. **Ambiguous names**: `get`, `remove` and `rollback` act on one package per name, so a name whose last-segment rule reaches several packages (`core` → `@angular/core` and `@babel/core`; the same name in two ecosystems) is refused with exit 1 before anything is searched or changed — `"core" is ambiguous: it names pkg:npm/@angular/core, pkg:npm/@babel/core; use the full name or a purl` (`remove --json`: `errorCode: "ambiguous_target"`; `get` / `rollback --json`: `{status: "error", error}`). Several versions of one package are not ambiguous. `scan --package` and `socket.yml` keep selecting every package the name reaches. `get <name>` searches every installed version of the matched name (within `--ecosystems`) and prints `Matched: …` on stderr; with no exact match it is `no_match` (exit 0, no API call) and, in human mode, suggests up to five near names (`Did you mean: …?`) without acting on them. `remove` / `rollback` accept the same names and versionless purls against manifest records, vendor-ledger entries and hosted pins; CVE/GHSA ids match no record there. A name containing `/` (composer `vendor/pkg`, a go module path) is path-shaped, so `rollback` first tries it as a name: when it selects a recorded or hosted patch it is a target, otherwise a path glob.
**Target grammar (v5.0).** `get`, `remove`, `rollback` and the bare-UUID fallback classify their package/patch token with one parser (core `utils::target`): a UUID; `CVE-…` / `GHSA-…` (case-insensitive); a `pkg:` purl (with a version: that release, a base purl covering every release variant and a `?qualified` one exactly one; without a version: every version of that package, compared by purl identity — case-sensitive for npm, Go, Maven, cargo and gem, folded for PyPI (PEP 503), NuGet and Composer, so `pkg:npm/jsonstream` never selects `JSONStream`); otherwise an **exact** package name — full name or last segment, case-insensitive, PEP 503 for PyPI — the same matcher as `scan --package` and `socket.yml`. A name never matches by prefix or substring, and a Go major-version suffix (`v2`) is never a name. **Ambiguous names**: `get`, `remove` and `rollback` act on one package per name, so a name whose last-segment rule reaches several packages (`core` → `@angular/core` and `@babel/core`; the same name in two ecosystems), or whose case-insensitive rule reaches case-distinct packages (`jsonstream` → npm's `JSONStream` and `jsonstream`, Go's `Sirupsen` and `sirupsen`), is refused with exit 1 before anything is searched or changed — `"core" is ambiguous: it names pkg:npm/@angular/core, pkg:npm/@babel/core; use the full name or a purl` (`remove --json`: `errorCode: "ambiguous_target"`; `get` / `rollback --json`: `{status: "error", error}`). Several versions of one package are not ambiguous, and a name typed with an uppercase letter settles on its exact-case package among case-distinct ones (`JSONStream`). `scan --package` and `socket.yml` keep selecting every package the name reaches. `get <name>` searches every installed version of the matched name (within `--ecosystems`) and prints `Matched: …` on stderr; with no exact match it is `no_match` (exit 0, no API call) and, in human mode, suggests up to five near names (`Did you mean: …?`) without acting on them. `remove` / `rollback` accept the same names and versionless purls against manifest records, vendor-ledger entries and hosted pins; CVE/GHSA ids match no record there. A name containing `/` (composer `vendor/pkg`, a go module path) is path-shaped, so `rollback` first tries it as a name: when it selects a recorded or hosted patch it is a target, otherwise a path glob.

**Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update).

Expand Down
55 changes: 54 additions & 1 deletion crates/socket-patch-cli/tests/in_process_target_ambiguity.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! The shared target grammar on the verbs that act on one package per
//! name: `get`, `remove` and `rollback` refuse a bare name whose
//! last-segment rule reaches several packages (`core` → `@angular/core`
//! and `@babel/core`), a Go major-version suffix (`v2`) is never a name,
//! and `@babel/core`) or whose case-insensitive rule reaches case-distinct
//! ones (`jsonstream` → `JSONStream` and `jsonstream`), a Go major-version suffix (`v2`) is never a name,
//! and `rollback` treats a slash-containing package name (composer
//! `vendor/pkg`) as a target before it treats it as a path glob.
//!
Expand Down Expand Up @@ -141,6 +142,58 @@ async fn remove_never_treats_a_go_major_suffix_as_a_name() {
assert!(after.contains("github.com/x/z/v2"), "{after}");
}

/// npm's legacy `JSONStream` and `jsonstream` are two packages (#1292).
const JSONSTREAM_UPPER: (&str, &str) = (
"pkg:npm/JSONStream@1.3.5",
"f6f6f6f6-0000-4000-8000-000000000006",
);
const JSONSTREAM_LOWER: (&str, &str) = (
"pkg:npm/jsonstream@0.0.1",
"a7a7a7a7-0000-4000-8000-000000000007",
);

#[tokio::test]
#[serial]
async fn remove_never_reaches_a_case_distinct_package() {
let tmp = tempfile::tempdir().unwrap();
let before = write_manifest(tmp.path(), &[JSONSTREAM_UPPER, JSONSTREAM_LOWER]);
// A lowercase name reaching both is ambiguous: nothing removed.
assert_eq!(remove_run(remove_args(tmp.path(), "jsonstream")).await, 1);
assert_eq!(read_manifest(tmp.path()), before, "nothing may be removed");

// A versionless purl selects its own spelling only.
assert_eq!(
remove_run(remove_args(tmp.path(), "pkg:npm/jsonstream")).await,
0
);
let after = read_manifest(tmp.path());
assert!(after.contains("pkg:npm/JSONStream@1.3.5"), "{after}");
assert!(!after.contains("pkg:npm/jsonstream@0.0.1"), "{after}");

// An exact-case name settles on that package.
let tmp = tempfile::tempdir().unwrap();
write_manifest(tmp.path(), &[JSONSTREAM_UPPER, JSONSTREAM_LOWER]);
assert_eq!(remove_run(remove_args(tmp.path(), "JSONStream")).await, 0);
let after = read_manifest(tmp.path());
assert!(!after.contains("pkg:npm/JSONStream@1.3.5"), "{after}");
assert!(after.contains("pkg:npm/jsonstream@0.0.1"), "{after}");
}

#[tokio::test]
#[serial]
async fn rollback_refuses_a_lowercase_name_reaching_case_distinct_packages() {
let tmp = tempfile::tempdir().unwrap();
write_manifest(tmp.path(), &[JSONSTREAM_UPPER, JSONSTREAM_LOWER]);
assert_eq!(
rollback_run(rollback_args(tmp.path(), &["jsonstream"])).await,
1
);
assert_eq!(
rollback_run(rollback_args(tmp.path(), &["pkg:npm/jsonstream"])).await,
0
);
}

#[tokio::test]
#[serial]
async fn rollback_refuses_a_name_that_reaches_two_packages() {
Expand Down
Loading
Loading