[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).
Summary
The hosted requirements.txt rewriter compares the pinned version to the patch version as a raw string. pip compares them under PEP 440, where trailing zeros, leading zeros and case don't matter. As a result, a pin that pip resolves to exactly the patched release is treated as "no entry":
| requirements.txt |
what pip installs |
hosted rewrite on main |
six==1.16.0 |
six 1.16.0 |
rewritten to six @ <hosted wheel> --hash=… ✅ |
six==1.16 |
six 1.16.0 |
left alone, redirect_requirements_entry_not_found |
six==1.16.0.0 |
six 1.16.0 |
left alone, redirect_requirements_entry_not_found |
Six==01.16.0 |
six 1.16.0 |
left alone, redirect_requirements_entry_not_found |
scan --mode hosted (and get <uuid> --mode hosted) still finds the patch for the installed pkg:pypi/six@1.16.0. It then reports "redirected": 0 with that warning and exits 0 with status: success. The next pip install -r requirements.txt installs the unpatched upstream 1.16.0.
Short pins like ==X.Y for an X.Y.0 release are common in hand-written requirements files (for example urllib3==2.0, numpy==1.26).
The released v4.0.0 rewrites six==1.16 correctly, so this is a regression.
Impact
- The project is silently left unpatched. Exit 0 with
status: success, and the only signal is a warning that says there is no requirements.txt entry for six, even though there is one.
vex correctly attests nothing, so nothing downstream flags it either.
- The vendored path (
vendor / scan --mode vendored) has the same string comparison. It fails closed rather than silently: pypi_requirement_not_pinned: requirements.txt: six is not pinned to ==1.16.0, even though it is pinned to 1.16.0 under PEP 440. That's the "refusal fires when it shouldn't" case.
Repro (Linux, real pip)
The repro uses a local mock of the patch API (batch, by-package and package routes) that grants a hosted wheel for pkg:pypi/six@1.16.0. The probe workflow linked below contains a self-contained copy.
python3 -m venv .venv && .venv/bin/pip install six==1.16.0
printf 'six==1.16\n' > requirements.txt
socket-patch scan --mode hosted --yes --json \
--api-url http://127.0.0.1:$PORT --org test-org --patch-server-url http://127.0.0.1:$PORT
# → "status": "success", exit 0
# "redirect": {"redirected": 0, "warnings": [{"code": "redirect_requirements_entry_not_found",
# "detail": "no requirements.txt entry for six@1.16.0"}]}
cat requirements.txt # → six==1.16 (unchanged)
python3 -m venv fresh && fresh/bin/pip install -r requirements.txt
fresh/bin/python -c 'import six; print(getattr(six, "SOCKET_PATCHED", 0))' # → 0 (upstream 1.16.0)
# control: the same project with `six==1.16.0` is rewritten and the fresh install is PATCHED.
This reproduced on every run (local Linux pip 20.3.4 / 23.3.2 / 24.0, plus all CI cells below).
Expected vs actual
- Expected: CLI_CONTRACT.md (hosted mode) says scan should "rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages". The requirement
six==1.16 is the patched dependency: pip's resolver picks exactly six 1.16.0 for it, the release the patch targets. docs/ecosystems.md says only "Version/source ambiguity is refused", and ==1.16 is not ambiguous under PEP 440, because zero-padding makes it equal to 1.16.0 and nothing else. The pin should be rewritten. Failing that, the run should at least fail or say the pin was skipped, rather than claim there is no entry and exit 0.
- Actual: the rewrite is silently skipped, the run exits 0, and the project installs unpatched bytes.
OS × pip matrix (probe run 36878654662)
| OS |
Python |
pip |
==1.16.0 (control) |
==1.16 |
==1.16.0.0 |
Six==01.16.0 |
| ubuntu-latest |
3.8 |
20.3.4, 23.0.1 |
PATCHED |
❌ UNPATCHED |
❌ |
❌ |
| ubuntu-latest |
3.13 |
24.0, bundled |
PATCHED |
❌ |
❌ |
❌ |
| macos-latest |
3.8 |
20.3.4, 23.0.1, bundled |
PATCHED |
❌ |
❌ |
❌ |
| macos-latest |
3.13 |
24.0, 26.2.1 |
PATCHED |
❌ |
❌ |
❌ |
| windows-latest |
3.8 |
20.3.4, 23.0.1, 21.1.1 (bundled) |
PATCHED |
❌ |
❌ |
❌ |
| Linux (local) |
3.10 / 3.12 |
20.3.4, 23.3.2, 24.0 |
PATCHED |
❌ |
❌ |
❌ |
First bad commit
git bisect with the repro above: ade011e (#238) is good, and 745e8dc "Harden uv lockfile patch preservation and verify every uv release family" (#239) is the first bad commit. The published v4.0.0 (96df6ae) matched requirements lines by name only and rewrites six==1.16.
Suspect code
crates/socket-patch-core/src/patch/redirect/requirements.rs:248: RequirementVersion::Exact(version) if version != dep.version => continue is a raw string comparison. A PEP 440 comparison (pad release segments with zeros, strip leading zeros, normalise case and spelling) would match pip.
crates/socket-patch-core/src/vendor/pypi_requirements.rs:81: the vendored spec_no_ws == format!("=={version}") has the same raw comparison, which produces the false pypi_requirement_not_pinned refusal.
- The lock-only path (
utils/requirements.rs::exact_pin) carries the spelled version through to the purl (pkg:pypi/six@1.16 in the batch request). It may need the same normalisation when no venv is present.
[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).
Summary
The hosted requirements.txt rewriter compares the pinned version to the patch version as a raw string. pip compares them under PEP 440, where trailing zeros, leading zeros and case don't matter. As a result, a pin that pip resolves to exactly the patched release is treated as "no entry":
six==1.16.0six @ <hosted wheel> --hash=…✅six==1.16redirect_requirements_entry_not_foundsix==1.16.0.0redirect_requirements_entry_not_foundSix==01.16.0redirect_requirements_entry_not_foundscan --mode hosted(andget <uuid> --mode hosted) still finds the patch for the installedpkg:pypi/six@1.16.0. It then reports"redirected": 0with that warning and exits 0 withstatus: success. The nextpip install -r requirements.txtinstalls the unpatched upstream 1.16.0.Short pins like
==X.Yfor anX.Y.0release are common in hand-written requirements files (for exampleurllib3==2.0,numpy==1.26).The released v4.0.0 rewrites
six==1.16correctly, so this is a regression.Impact
status: success, and the only signal is a warning that says there is no requirements.txt entry for six, even though there is one.vexcorrectly attests nothing, so nothing downstream flags it either.vendor/scan --mode vendored) has the same string comparison. It fails closed rather than silently:pypi_requirement_not_pinned: requirements.txt: six is not pinned to ==1.16.0, even though it is pinned to 1.16.0 under PEP 440. That's the "refusal fires when it shouldn't" case.Repro (Linux, real pip)
The repro uses a local mock of the patch API (
batch,by-packageandpackageroutes) that grants a hosted wheel forpkg:pypi/six@1.16.0. The probe workflow linked below contains a self-contained copy.This reproduced on every run (local Linux pip 20.3.4 / 23.3.2 / 24.0, plus all CI cells below).
Expected vs actual
six==1.16is the patched dependency: pip's resolver picks exactly six 1.16.0 for it, the release the patch targets. docs/ecosystems.md says only "Version/source ambiguity is refused", and==1.16is not ambiguous under PEP 440, because zero-padding makes it equal to 1.16.0 and nothing else. The pin should be rewritten. Failing that, the run should at least fail or say the pin was skipped, rather than claim there is no entry and exit 0.OS × pip matrix (probe run 36878654662)
==1.16.0(control)==1.16==1.16.0.0Six==01.16.0First bad commit
git bisectwith the repro above: ade011e (#238) is good, and 745e8dc "Harden uv lockfile patch preservation and verify every uv release family" (#239) is the first bad commit. The published v4.0.0 (96df6ae) matched requirements lines by name only and rewritessix==1.16.Suspect code
crates/socket-patch-core/src/patch/redirect/requirements.rs:248:RequirementVersion::Exact(version) if version != dep.version => continueis a raw string comparison. A PEP 440 comparison (pad release segments with zeros, strip leading zeros, normalise case and spelling) would match pip.crates/socket-patch-core/src/vendor/pypi_requirements.rs:81: the vendoredspec_no_ws == format!("=={version}")has the same raw comparison, which produces the falsepypi_requirement_not_pinnedrefusal.utils/requirements.rs::exact_pin) carries the spelled version through to the purl (pkg:pypi/six@1.16in the batch request). It may need the same normalisation when no venv is present.