Skip to content

Commit 08c71ff

Browse files
Fix lock-only PEP 440 pin spellings missing patches (#604) (#1334)
* Start v5 blocker fix (pypi-pep440-lockonly) Empty commit to open the draft PR. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Find lock-only patches for PEP 440 pin spellings On a fresh checkout (no venv), requirements.txt discovery sent a pin as written: `six==1.16` became `pkg:pypi/six@1.16`, `==1.16.0.0` became `@1.16.0.0`, `==01.16.0` became `@01.16.0`. pip installs the registry release 1.16.0 for all three, but the patch API keys it `@1.16.0`, so the scan said "No patches available" and the unpatched release was installed. The same file with a venv holding six was patched. Scan now also asks the API for the other PEP 440 spellings of a lockfile-only PyPI pure-release pin (leading zeros dropped, release padded or trimmed to at least three segments). A patch returned under another spelling is counted as that lockfile-only package, so `notInstalled` and the vendored baseline pre-check see it. The rewriters already match `==` pins under PEP 440 (#478). Fixes #604 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5096c41 commit 08c71ff

6 files changed

Lines changed: 257 additions & 6 deletions

File tree

‎crates/socket-patch-cli/src/commands/scan/mod.rs‎

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,53 @@ const BATCH_BODY_BYTE_CAP: usize = 256 * 1024;
104104
/// authenticated API and [`DEFAULT_PROXY_BATCH_SIZE`] on the public proxy.
105105
/// Floored at 1: `--batch-size 0` is otherwise unvalidated and would make
106106
/// the chunking below panic, so it degrades to one-package batches.
107+
/// `purls` plus, for each lockfile-only PyPI purl among them, its other
108+
/// PEP 440 spellings of the same release (#604), deduplicated in order.
109+
fn with_pypi_equivalents(purls: &[String], lockfile_only: &HashSet<PurlKey>) -> Vec<String> {
110+
let mut out = purls.to_vec();
111+
let mut seen: HashSet<PurlKey> = purls.iter().map(|p| PurlKey::new(p)).collect();
112+
for purl in purls {
113+
if !lockfile_only_contains(lockfile_only, purl) {
114+
continue;
115+
}
116+
for spelling in socket_patch_core::utils::purl_key::pypi_equivalent_purls(purl) {
117+
if seen.insert(PurlKey::new(&spelling)) {
118+
out.push(spelling);
119+
}
120+
}
121+
}
122+
out
123+
}
124+
125+
/// Mark each API purl that names a lockfile-only PyPI pin under another
126+
/// PEP 440 spelling (`@1.16.0` for a lock's `@1.16`, #604) as lockfile-only
127+
/// too, so the `notInstalled` flag, the `[NOT INSTALLED]` marker and the
128+
/// vendored baseline pre-check treat it as the package it is. A spelling an
129+
/// installed copy already carries is left alone.
130+
fn adopt_pypi_equivalents(
131+
packages: &[BatchPackagePatches],
132+
scanned: &[String],
133+
lockfile_only: &mut HashSet<PurlKey>,
134+
) {
135+
let scanned_keys: HashSet<PurlKey> = scanned.iter().map(|p| PurlKey::new(p)).collect();
136+
let lock_only_pypi: Vec<&String> = scanned
137+
.iter()
138+
.filter(|p| p.starts_with("pkg:pypi/") && lockfile_only_contains(lockfile_only, p))
139+
.collect();
140+
for pkg in packages {
141+
let key = PurlKey::new(&pkg.purl);
142+
if scanned_keys.contains(&key) {
143+
continue;
144+
}
145+
if lock_only_pypi
146+
.iter()
147+
.any(|lock| socket_patch_core::utils::purl_key::pypi_same_release(lock, &pkg.purl))
148+
{
149+
lockfile_only.insert(key);
150+
}
151+
}
152+
}
153+
107154
fn effective_batch_size(requested: Option<usize>, use_public_proxy: bool) -> usize {
108155
requested
109156
.unwrap_or(if use_public_proxy {
@@ -1858,7 +1905,10 @@ async fn run_scan(
18581905
// that have NO installed copy (fresh clone, partial install). They join
18591906
// discovery and are flagged "not yet installed". Scoped to the crawled
18601907
// ecosystems.
1861-
let lockfile_only = lockfile_supplement(&ctx, &all_crawled, crawl_scope).await;
1908+
let mut lockfile_only = lockfile_supplement(&ctx, &all_crawled, crawl_scope).await;
1909+
// Counted once: #604 adds the API's spellings of lockfile-only PyPI pins
1910+
// to `lockfile_only.purls` after the batch query.
1911+
let lockfile_only_count = lockfile_only.purls.len();
18621912
// Unsupported layouts and malformed binary Bun locks, kept on empty
18631913
// scans too: an unreadable graph is not evidence of no dependencies.
18641914
let mut layout_refusals = unsupported_layout_warnings(&lockfile_only.unsupported);
@@ -2305,8 +2355,8 @@ async fn run_scan(
23052355
plural(package_count, "package", "packages")
23062356
));
23072357
if human {
2308-
if !lockfile_only.purls.is_empty() {
2309-
eprintln!("{}", render::lockfile_only_note(lockfile_only.purls.len()));
2358+
if lockfile_only_count > 0 {
2359+
eprintln!("{}", render::lockfile_only_note(lockfile_only_count));
23102360
}
23112361
print_layout_refusals(&layout_refusals, args.common.silent);
23122362
policy.print_warnings(args.common.silent);
@@ -2315,7 +2365,12 @@ async fn run_scan(
23152365
// Query API in batches
23162366
let mut all_packages_with_patches: Vec<BatchPackagePatches> = Vec::new();
23172367
let mut can_access_paid_patches = false;
2318-
let chunks: Vec<&[String]> = batch_chunks(&all_purls, batch_size, BATCH_BODY_BYTE_CAP);
2368+
// #604: a lockfile-only PyPI pin is spelled as the user wrote it
2369+
// (`six==1.16` → `@1.16`) while the API keys the release as the
2370+
// registry published it (`@1.16.0`); pip treats both as one release
2371+
// (PEP 440). Ask for its equivalent spellings too.
2372+
let query_purls = with_pypi_equivalents(&all_purls, &lockfile_only.purls);
2373+
let chunks: Vec<&[String]> = batch_chunks(&query_purls, batch_size, BATCH_BODY_BYTE_CAP);
23192374
let total_batches = chunks.len();
23202375
let mut batch_error_count = 0usize;
23212376
let mut last_batch_error: Option<String> = None;
@@ -2426,6 +2481,13 @@ async fn run_scan(
24262481
// drives the table, the `--json` `packages` array and the apply order,
24272482
// which operators diff across runs.
24282483
all_packages_with_patches.sort_by(|a, b| a.purl.cmp(&b.purl));
2484+
// #604: a patch the API returned under an equivalent spelling of a
2485+
// lockfile-only PyPI pin is that lockfile-only package.
2486+
adopt_pypi_equivalents(
2487+
&all_packages_with_patches,
2488+
&all_purls,
2489+
&mut lockfile_only.purls,
2490+
);
24292491

24302492
// If every batch errored, surface a full scan failure rather than
24312493
// silently reporting zero patches.
@@ -2440,7 +2502,7 @@ async fn run_scan(
24402502
"status": "error",
24412503
"error": { "code": API_BATCH_FAILED, "message": err },
24422504
"scannedPackages": package_count,
2443-
"lockfileOnlyPackages": lockfile_only.purls.len(),
2505+
"lockfileOnlyPackages": lockfile_only_count,
24442506
"packagesWithPatches": 0,
24452507
"totalPatches": 0,
24462508
"freePatches": 0,
@@ -2520,7 +2582,7 @@ async fn run_scan(
25202582
let mut result = serde_json::json!({
25212583
"status": "success",
25222584
"scannedPackages": package_count,
2523-
"lockfileOnlyPackages": lockfile_only.purls.len(),
2585+
"lockfileOnlyPackages": lockfile_only_count,
25242586
"packagesWithPatches": all_packages_with_patches.len(),
25252587
"totalPatches": total_patches,
25262588
"freePatches": free_patches,

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,59 @@ async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() {
20712071
);
20722072
}
20732073

2074+
// ── #604: PEP 440-equivalent lock-only pins ──────────────────────────────
2075+
2076+
/// #604: on a fresh checkout (no venv), `six==1.16` (or `==1.16.0.0`,
2077+
/// `==01.16.0`) is the release the patch API keys as `six@1.16.0`. Hosted
2078+
/// mode must find the patch and rewrite the pin, as it does when a venv
2079+
/// holds six 1.16.0, and report the package as not installed.
2080+
#[tokio::test]
2081+
async fn lock_only_pep440_equivalent_pin_is_patched() {
2082+
use wiremock::matchers::body_string_contains;
2083+
for pin in ["1.16", "1.16.0.0", "01.16.0"] {
2084+
let server = MockServer::start().await;
2085+
// The API matches purls exactly: only `@1.16.0` has the patch.
2086+
Mock::given(method("POST"))
2087+
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
2088+
.and(body_string_contains(PURL))
2089+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
2090+
"packages": [{ "purl": PURL, "patches": [{
2091+
"uuid": UUID, "purl": PURL, "tier": "free", "cveIds": [], "ghsaIds": [],
2092+
"severity": "high", "title": "pep440 fixture"
2093+
}]}],
2094+
"canAccessPaidPatches": false,
2095+
})))
2096+
.with_priority(1)
2097+
.mount(&server)
2098+
.await;
2099+
Mock::given(method("POST"))
2100+
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
2101+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
2102+
"packages": [], "canAccessPaidPatches": false,
2103+
})))
2104+
.with_priority(2)
2105+
.mount(&server)
2106+
.await;
2107+
let hosted_url = mount_hosted_api(&server, true).await;
2108+
let (_tmp, root) = project();
2109+
std::fs::write(
2110+
root.join("requirements.txt"),
2111+
format!("idna==3.7\nsix=={pin}\n"),
2112+
)
2113+
.unwrap();
2114+
let (code, env) = hosted_scan(&root, &server);
2115+
assert_eq!(code, 0, "{pin}: {env:#}");
2116+
assert_eq!(env["redirect"]["redirected"], 1, "{pin}: {env:#}");
2117+
assert_eq!(env["packages"][0]["purl"], PURL, "{pin}: {env:#}");
2118+
assert_eq!(env["packages"][0]["notInstalled"], true, "{pin}: {env:#}");
2119+
let requirements = std::fs::read_to_string(root.join("requirements.txt")).unwrap();
2120+
assert!(
2121+
requirements.contains(&format!("six @ {hosted_url}")),
2122+
"{pin}: the pin is rewritten:\n{requirements}"
2123+
);
2124+
}
2125+
}
2126+
20742127
// ── #1138: a uv workspace member ─────────────────────────────────────────
20752128

20762129
/// A uv workspace (root `pyproject.toml` with `[tool.uv.workspace]` and its

‎crates/socket-patch-cli/tests/scan_requirements_lock_only.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,40 @@ async fn lock_only_scan_discovers_pin_with_dangling_eof_continuation() {
376376
)
377377
.await;
378378
}
379+
380+
/// #604: pip resolves `six==1.16`, `six==1.16.0.0` and `six==01.16.0` to
381+
/// the registry release `1.16.0` (PEP 440), but lock-only discovery queried
382+
/// only the spelled version, so the patch keyed `@1.16.0` was never found.
383+
/// Each spelling must also ask for the release's other spellings.
384+
#[tokio::test]
385+
async fn lock_only_scan_queries_pep440_equivalent_spellings() {
386+
for (pin, spelled) in [
387+
("1.16", "1.16"),
388+
("1.16.0.0", "1.16.0.0"),
389+
("01.16.0", "01.16.0"),
390+
] {
391+
for mode in [&[][..], &["--mode", "vendored"][..]] {
392+
let mock = MockServer::start().await;
393+
mount_empty_batch(&mock).await;
394+
let tmp = tempfile::tempdir().unwrap();
395+
std::fs::write(
396+
tmp.path().join("requirements.txt"),
397+
format!("sp-fixture-six=={pin}\n"),
398+
)
399+
.unwrap();
400+
let (code, v) = run_scan(tmp.path(), &mock.uri(), mode, &[]);
401+
assert_eq!(code, 0, "{pin} {mode:?}: {v}");
402+
assert_eq!(v["lockfileOnlyPackages"].as_u64(), Some(1), "{v}");
403+
let purls = batch_purls(&mock).await;
404+
for want in [
405+
format!("pkg:pypi/sp-fixture-six@{spelled}"),
406+
"pkg:pypi/sp-fixture-six@1.16.0".to_string(),
407+
] {
408+
assert!(
409+
purls.contains(&want),
410+
"{pin} {mode:?}: {want} must reach the patch API; sent {purls:?}"
411+
);
412+
}
413+
}
414+
}
415+
}

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ pub(crate) fn is_exact_pin(specifier: &str) -> bool {
130130
.is_some_and(|pinned| parse(pinned).is_some())
131131
}
132132

133+
/// The other spellings a registry may have published the pure release
134+
/// `version` (digits and dots only) under, as PEP 440 equality reads them:
135+
/// leading zeros dropped and the release padded with `.0` from its
136+
/// trailing-zero-trimmed form up to at least three segments (`1.16` →
137+
/// `1.16.0`; `1.16.0` → `1.16`; `01.16.0.0` → `1.16`, `1.16.0`). `version`
138+
/// itself is not included; anything that is not a pure release has none.
139+
pub(crate) fn equivalent_release_spellings(version: &str) -> Vec<String> {
140+
let valid = !version.is_empty()
141+
&& version.split('.').all(|segment| {
142+
!segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit())
143+
});
144+
if !valid {
145+
return Vec::new();
146+
}
147+
let segments: Vec<String> = version.split('.').map(number).collect();
148+
let mut trimmed = segments.clone();
149+
while trimmed.len() > 1 && trimmed.last().is_some_and(|segment| segment == "0") {
150+
trimmed.pop();
151+
}
152+
let mut out = Vec::new();
153+
for len in trimmed.len()..=segments.len().max(3) {
154+
let mut release = trimmed.clone();
155+
release.resize(len, "0".to_string());
156+
let spelling = release.join(".");
157+
if spelling != version && !out.contains(&spelling) {
158+
out.push(spelling);
159+
}
160+
}
161+
out
162+
}
163+
133164
#[cfg(test)]
134165
mod tests {
135166
use super::*;
@@ -182,6 +213,32 @@ mod tests {
182213
}
183214
}
184215

216+
#[test]
217+
fn equivalent_release_spellings_pad_and_trim() {
218+
assert_eq!(equivalent_release_spellings("1.16"), ["1.16.0"]);
219+
assert_eq!(equivalent_release_spellings("1.16.0"), ["1.16"]);
220+
assert_eq!(equivalent_release_spellings("1.16.0.0"), ["1.16", "1.16.0"]);
221+
assert_eq!(equivalent_release_spellings("01.16.0"), ["1.16", "1.16.0"]);
222+
assert_eq!(equivalent_release_spellings("2"), ["2.0", "2.0.0"]);
223+
assert!(equivalent_release_spellings("2.8.2").is_empty());
224+
for other in [
225+
"1.0rc1",
226+
"2.9.0.post0",
227+
"1.0+local",
228+
"",
229+
"1..2",
230+
"v1.0",
231+
"1.*",
232+
] {
233+
assert!(equivalent_release_spellings(other).is_empty(), "{other}");
234+
}
235+
for version in ["1.16", "1.16.0.0", "01.16.0"] {
236+
for spelling in equivalent_release_spellings(version) {
237+
assert!(versions_equal(version, &spelling), "{version} {spelling}");
238+
}
239+
}
240+
}
241+
185242
#[test]
186243
fn exact_pin_spellings() {
187244
assert!(is_exact_pin_of("==1.16", "1.16.0"));

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,43 @@ fn composer_identity(canonical: &str) -> Option<String> {
173173
))
174174
}
175175

176+
/// `pkg:pypi/<name>@<v>` purls for every other spelling of `purl`'s pure
177+
/// release (`@1.16` → `@1.16.0`, see
178+
/// [`crate::utils::pep440::equivalent_release_spellings`]): the lockfile
179+
/// spells a pin as the user wrote it (`six==1.16`), while the patch API
180+
/// keys the release as the registry published it (#604). Empty for any
181+
/// other purl.
182+
pub fn pypi_equivalent_purls(purl: &str) -> Vec<String> {
183+
let canonical = canonical_base_purl(purl);
184+
let Some((name, version)) = canonical
185+
.strip_prefix("pkg:pypi/")
186+
.and_then(|rest| rest.rsplit_once('@'))
187+
else {
188+
return Vec::new();
189+
};
190+
crate::utils::pep440::equivalent_release_spellings(version)
191+
.into_iter()
192+
.map(|spelling| format!("pkg:pypi/{name}@{spelling}"))
193+
.collect()
194+
}
195+
196+
/// Whether two PyPI purls name the same release under PEP 440 equality
197+
/// (`@1.16` and `@1.16.0`), the way pip resolves an `==` pin. `false` for
198+
/// anything that is not two PyPI purls of one (PEP 503) name.
199+
pub fn pypi_same_release(a: &str, b: &str) -> bool {
200+
let (a, b) = (canonical_base_purl(a), canonical_base_purl(b));
201+
let split = |purl: &str| -> Option<(String, String)> {
202+
let (name, version) = purl.strip_prefix("pkg:pypi/")?.rsplit_once('@')?;
203+
Some((name.to_string(), version.to_string()))
204+
};
205+
match (split(&a), split(&b)) {
206+
(Some((name_a, version_a)), Some((name_b, version_b))) => {
207+
name_a == name_b && crate::utils::pep440::versions_equal(&version_a, &version_b)
208+
}
209+
_ => false,
210+
}
211+
}
212+
176213
#[cfg(test)]
177214
#[path = "purl_key_nuget_vendor_tests.rs"]
178215
mod nuget_vendor_tests;

‎docs/testing/uv-compatibility.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ frozen, locked, and ordinary installation outcomes separately where supported.
165165
those cases requires marker-specific source mappings. Standalone PEP 751
166166
rewriting selects the exact package version; duplicate entries for the same
167167
name and version are refused when source selection is ambiguous.
168+
- Lock-only discovery (a fresh checkout, no venv) queries the patch API with
169+
every PEP 440 spelling of an exact pure-release pin as well as the one
170+
written (`six==1.16` asks for `@1.16` and `@1.16.0`; `==1.16.0` also asks for
171+
`@1.16`), so it finds the patch the registry keys under its own spelling, as a
172+
venv-backed run does, and reports the package as not installed (#604).
168173
- Hosted requirements select exact `==`/`===` pins or socket-patch's own
169174
hosted archive URLs. A user-authored direct reference to the patched release
170175
(`name @ <url>` on any other origin, `files.pythonhosted.org` and `file://`

0 commit comments

Comments
 (0)