[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).
Summary
pip parses the option part of a requirements line with shlex.split and expands ${VAR} references before parsing. So it follows all of these includes:
-r "dev reqs.txt"
-r 'dev reqs.txt'
-r "dev.txt"
-r dev\ reqs.txt
--requirement "dev.txt"
--requirement="dev.txt"
-r ${REQDIR}/dev.txt
socket-patch's include grammar (include_target) splits on whitespace and takes the token as is. The quotes, backslash and ${…} stay in the target. For a quoted path with spaces, only the first fragment ("dev) is kept. The include resolves to a file that doesn't exist, which the walk skips silently ("a broken include is pip's error to report").
As a result, a lock-only scan never discovers a pin that lives in such an include, in both hosted and vendored mode. A lock-only scan is a fresh checkout with no venv, which is the usual CI case. The scan exits 0 with lockfileOnlyPackages: 0 and nothing reaches the patch API. This is the same symptom class as #412 (fixed) and #523 (fixed): pip resolves the file one way and discovery reads it another.
Impact
A project that keeps pins in an include whose name has a space, or that quotes its include paths, or that picks an include directory with an env var (-r ${REQS}/prod.txt is a common CI pattern) gets "No patches available" from scan. pip install -r requirements.txt then installs the unpatched releases, with no warning. The vendored planner, requirements_include_names (used by the in-use / prune probe, repair and lock-only vex) and the lock inventory share the same grammar, so the vendored walk is blind to these includes too.
Repro (main 9c43dfc)
The harness is scan_requirements_lock_only.rs (a wiremock batch endpoint, with VIRTUAL_ENV / CONDA_PREFIX removed). Each case is a root requirements.txt holding one include line, plus the include holding qa-fixture==1.0.0:
("plain", "-r dev.txt\n", "dev.txt"),
("dq", "-r \"dev reqs.txt\"\n", "dev reqs.txt"),
("sq", "-r 'dev reqs.txt'\n", "dev reqs.txt"),
("dq_nospace", "-r \"dev.txt\"\n", "dev.txt"),
("bs", "-r dev\\ reqs.txt\n", "dev reqs.txt"),
("env", "-r ${REQDIR}/dev.txt\n", "sub/dev.txt"), // REQDIR=sub in the env
("longq", "--requirement \"dev.txt\"\n", "dev.txt"),
("eqq", "--requirement=\"dev.txt\"\n", "dev.txt"),
Each case runs socket-patch scan --json --yes --api-url <mock> --api-token x --org test-org [--vendor], then checks the purls the scan POSTed to /patches/batch.
pip side (real pip, pip download --no-deps -d dl -r requirements.txt with six==1.16.0 in the include):
pip 20.3.4 / py3.8, 24.0 / py3.11, 26.2.1 / py3.11:
dq sq dq_nospace bs env longq eqq → six-1.16.0-py2.py3-none-any.whl downloaded (include followed)
Expected vs actual
Expected: Lock-only discovery reads the requirements tree the way pip does (the stated goal of #412 / scan_requirements_lock_only.rs: "Discovery must read the pins the way pip does"). qa-fixture@1.0.0 should reach the batch endpoint and lockfileOnlyPackages should be 1. At the very least, an include that can't be resolved should produce a warning instead of being skipped silently.
Actual (reproduced twice):
| Case |
hosted |
--vendor |
-r dev.txt (control) |
lockOnly=1, sent |
lockOnly=1, sent |
-r\tdev.txt (tab) |
lockOnly=1, sent |
lockOnly=1, sent |
-r "dev reqs.txt" |
lockOnly=0, not sent, exit 0 |
same |
-r 'dev reqs.txt' |
0, exit 0 |
0 |
-r "dev.txt" (quoted, no space) |
0, exit 0 |
0 |
-r dev\ reqs.txt |
0, exit 0 |
0 |
-r ${REQDIR}/dev.txt |
0, exit 0 |
0 |
--requirement "dev.txt" |
0, exit 0 |
0 |
--requirement="dev.txt" |
0, exit 0 |
0 |
OS × version
| OS |
pip |
Reproduces |
| Linux |
20.3.4 / py3.8, 24.0 / py3.11, 26.2.1 / py3.11 |
yes: pip follows all 7 forms and socket-patch follows none |
| macOS / Windows |
— |
untested. The grammar is OS-independent; on Windows, cmd-style %VAR% is not expanded by pip, only ${VAR} |
Not a regression: v4.0.0 doesn't follow lock-only includes at all (#412).
Suspect code
crates/socket-patch-core/src/vendor/pypi_requirements.rs:979 include_target: code.split_whitespace() with no shlex unquoting, no backslash unescaping and no ${VAR} expansion. The --requirement= arm returns the quoted value verbatim.
crates/socket-patch-core/src/vendor/pypi_requirements.rs:959 requirements_includes and the shared walk at :926, which skip an unreadable include silently. lock_inventory/pypi.rs:728 requirements_tree does the same.
pip reference: pip/_internal/req/req_file.py, where break_args_options passes the options to shlex.split and expand_env_variables handles ${NAME} with [A-Z0-9_]+.
No probe run: the grammar is OS-independent and was reproduced on Linux with real pip.
[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).
Summary
pip parses the option part of a requirements line with
shlex.splitand expands${VAR}references before parsing. So it follows all of these includes:socket-patch's include grammar (
include_target) splits on whitespace and takes the token as is. The quotes, backslash and${…}stay in the target. For a quoted path with spaces, only the first fragment ("dev) is kept. The include resolves to a file that doesn't exist, which the walk skips silently ("a broken include is pip's error to report").As a result, a lock-only
scannever discovers a pin that lives in such an include, in both hosted and vendored mode. A lock-only scan is a fresh checkout with no venv, which is the usual CI case. The scan exits 0 withlockfileOnlyPackages: 0and nothing reaches the patch API. This is the same symptom class as #412 (fixed) and #523 (fixed): pip resolves the file one way and discovery reads it another.Impact
A project that keeps pins in an include whose name has a space, or that quotes its include paths, or that picks an include directory with an env var (
-r ${REQS}/prod.txtis a common CI pattern) gets "No patches available" fromscan.pip install -r requirements.txtthen installs the unpatched releases, with no warning. The vendored planner,requirements_include_names(used by the in-use / prune probe,repairand lock-onlyvex) and the lock inventory share the same grammar, so the vendored walk is blind to these includes too.Repro (main
9c43dfc)The harness is
scan_requirements_lock_only.rs(a wiremock batch endpoint, withVIRTUAL_ENV/CONDA_PREFIXremoved). Each case is a rootrequirements.txtholding one include line, plus the include holdingqa-fixture==1.0.0:Each case runs
socket-patch scan --json --yes --api-url <mock> --api-token x --org test-org [--vendor], then checks the purls the scan POSTed to/patches/batch.pip side (real pip,
pip download --no-deps -d dl -r requirements.txtwithsix==1.16.0in the include):Expected vs actual
Expected: Lock-only discovery reads the requirements tree the way pip does (the stated goal of #412 /
scan_requirements_lock_only.rs: "Discovery must read the pins the way pip does").qa-fixture@1.0.0should reach the batch endpoint andlockfileOnlyPackagesshould be 1. At the very least, an include that can't be resolved should produce a warning instead of being skipped silently.Actual (reproduced twice):
--vendor-r dev.txt(control)-r\tdev.txt(tab)-r "dev reqs.txt"-r 'dev reqs.txt'-r "dev.txt"(quoted, no space)-r dev\ reqs.txt-r ${REQDIR}/dev.txt--requirement "dev.txt"--requirement="dev.txt"OS × version
cmd-style%VAR%is not expanded by pip, only${VAR}Not a regression: v4.0.0 doesn't follow lock-only includes at all (#412).
Suspect code
crates/socket-patch-core/src/vendor/pypi_requirements.rs:979include_target:code.split_whitespace()with no shlex unquoting, no backslash unescaping and no${VAR}expansion. The--requirement=arm returns the quoted value verbatim.crates/socket-patch-core/src/vendor/pypi_requirements.rs:959requirements_includesand the shared walk at:926, which skip an unreadable include silently.lock_inventory/pypi.rs:728requirements_treedoes the same.pip reference:
pip/_internal/req/req_file.py, wherebreak_args_optionspasses the options toshlex.splitandexpand_env_variableshandles${NAME}with[A-Z0-9_]+.No probe run: the grammar is OS-independent and was reproduced on Linux with real pip.