Repository navigation
remove, rollback and get treat case-distinct packages as one: remove pkg:npm/jsonstream also removes JSONStream's patch #1292
Description
[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: discussion #560 register.
Kind: bug. Source: new finding, register row C80 (follows C62/#1034 and C63/#1045).
Problem
#1034 gave get, remove and rollback one target grammar, utils::target::Target. Purl and name targets match through policy::package_spec_matches, the lenient filter matcher behind scan --package and socket.yml, which lowercases names in every ecosystem. PurlKey, the purl identity #1045 introduced, folds case only for PyPI, NuGet and Composer. So Target uses two equivalences, depending on whether the purl carries a version:
- Versionless purls and names compare lowercased. These are
Target::matches_package/name_matchesand the versionless arm ofmatches_patch, which both go throughpackage_spec_matches. Its doc says "npm forbids uppercase", but legacy npm names such asJSONStreamare uppercase and distinct fromjsonstream. - Versioned purls in
matches_patchcompare throughpurl_matches_identifier→PurlKey, which is case-preserving for npm, Go, Maven, Cargo and gem. - The ambiguity guard is blind to case.
package_identitylowercases the whole purl, soTarget::settlecountspkg:npm/JSONStreamandpkg:npm/jsonstream, orgithub.com/Sirupsen/logrusandgithub.com/sirupsen/logrus, as one package and never refuses them.
CLI_CONTRACT.md promises: "get, remove and rollback act on one package per name, so a name whose last-segment rule reaches several packages … is refused". It also says a purl without a version selects "every version" of that package, not of a different package.
Proof by execution (main @ 31383f5, run twice, identical results). The test used a debug build and a manifest holding pkg:npm/JSONStream@1.3.5 and pkg:npm/jsonstream@0.0.1, run with remove <target> --skip-rollback --json:
| target | removed | left |
|---|---|---|
pkg:npm/jsonstream |
both | none |
jsonstream (name; the contract says ambiguous) |
both, exit 0, no ambiguous_target |
none |
pkg:npm/jsonstream@0.0.1 |
jsonstream only |
JSONStream@1.3.5 |
pkg:npm/JSONStream@1.3.5 |
JSONStream only |
jsonstream@0.0.1 |
A unit probe of Target:
matches_packageis true andmatches_patchfalse forpkg:npm/Lodash@4.17.21,pkg:gem/Rails@7.0.0,pkg:cargo/Serde@1.0.0,pkg:golang/github.com/Foo/bar@v1.0.0andpkg:maven/Org.X/y@1.0against their lowercase records. Sogetselects an installed package whose patch the same spelling can'tremoveorrollback.- The versionless spelling of each of those matches both.
Target::parse("logrus").ambiguity([Sirupsen@v1.0.0, sirupsen@v1.8.1])andTarget::parse("jsonstream").ambiguity([JSONStream, jsonstream])both returnNone.
rollback <target> selects through the same matches_patch (find_patches_to_rollback), so it reverts both packages' files.
Symptoms and impact
- Symptoms: no open issue reports this.
- Impact: a destructive command acts on a second, distinct package.
removedrops its patch record and, without--skip-rollback, restores its files.rollbackreverts it.getsearches and applies to it. - Who it reaches: Go projects that carry both
Sirupsenandsirupsenspellings of logrus (a well-known case in older module graphs), npm legacy mixed-case names, and Maven groupIds. - The inconsistency: the same package selects differently depending on whether a version is typed.
Proposed change
- In
utils/target.rs, compare packages byPurlKeyidentity (canonical_base_purlwith the version dropped), not by a blanketto_lowercase:package_identityreturns thePurlKeybase. This deletes its private lowercase + PEP 503 fold.- A versionless purl target matches a record or installed purl when the
PurlKeybases are equal (inmatches_packageandmatches_patch), the same relation the versioned arm already uses. - A name may still be typed in any case.
settlecounts distinctPurlKeyidentities, sojsonstreambesideJSONStreamis refused asambiguous_target. An exact-case full name wins, the way the full name already wins over last-segment matches today.
package_spec_matcheskeeps its lenient behaviour forscan --packageand socket.yml, which the contract documents as filters.Targetstops calling it for purl targets. Correct its "npm forbids uppercase" comment.
Size and scope
- Files:
utils/target.rs(~40 production lines changed, plus tests) and possiblypolicy/mod.rs(comment only). - Out of scope:
- the
scan --package/socket.yml filter semantics; get's API search spelling;- any
PurlKeyfolding change.
- the
Acceptance criteria
-
Target::parse("pkg:npm/jsonstream").matches_patch("pkg:npm/JSONStream@1.3.5", _)is false. The same holds for GoSirupsen/sirupsenand a Maven groupId pair. -
Target::parse("jsonstream").settle([JSONStream, jsonstream])isErr.Target::parse("JSONStream").settle(...)settles onJSONStreamalone. - Versioned and versionless purl targets agree on case for every ecosystem: a table test over npm, golang, maven, cargo, gem, pypi, nuget and composer.
- PyPI (
typing_extensions), NuGet and Composer case/spelling folds still match (existingget <name>doesn't PEP 503-normalise PyPI names, soget typing_extensionsorget ruamel.yamlreports "No packages matching" (exit 0) for an installed, patchable package #926/removeandrollbackdon't PEP 503-normalise PyPI purl identifiers, soremove pkg:pypi/typing_extensions@4.7.1exits 1 "No patch found" whilegetaccepts the same identifier #1024 tests stay green). - CLI regression test:
remove pkg:npm/jsonstream --skip-rollback --jsonon the two-record manifest above removes onlyjsonstream, andremove jsonstream --jsonexits 1 withambiguous_target. -
cargo test -p socket-patch-core utils::targetplus theget,removeandrollbackCLI suites stay green.
Dependencies
- Blocked by: none.
- Related: Tracking: build and classify purls through one validated utils::purl API #748 (purl tracking) and Compare purls through one PurlKey type #1045 (
PurlKey).
Activity
audit-core: run 20261009T155505Z (C80 filed #1292; C62, C77 fixed)
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming this issue for the architecture refactor routine (highest leverage:
Targetmatches packages through two case equivalences; moving it onto the onePurlKeyidentity fixes a destructive cross-packageremove/rollback, and its file is free of open PRs). Branch: arch-refactor/1292-target-purlkey-identity. Claim-ID: 2026-10-09T15:56:17Z-24970fGenerated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Draft PR: #1294.
Generated by Claude Code
Match package targets by PurlKey identity