Skip to content

Lock-only requirements.txt discovery skips -r includes that are quoted, backslash-escaped or use ${VAR} (-r "dev reqs.txt", --requirement="dev.txt", -r ${DIR}/dev.txt), so the scan exits 0 with "No patches" while pip installs the include's unpatched pins #994

Description

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

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