Skip to content

Hosted requirements.txt rewrite skips PEP 440-equivalent pins like six==1.16 for an installed 1.16.0, so scan exits 0 and pip installs the unpatched release (regression from v4.0.0) #475

Description

[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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentpm:pippip / requirements.txtpriority:p1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions