Skip to content

Vendored → hosted takeover strands a requirements.txt pin that lives in a -r include or a vendored "(transitive)" line: the wet run reverts it to the unpatched release, while --dry-run previews a clean takeover #699

Description

[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).

Summary

Vendored mode follows -r includes, and it appends a # socket-patch vendor: six==X (transitive) line to the root requirements.txt for a package pinned only in a file outside the include tree. Hosted mode rewrites only the root requirements.txt and refuses anything else with redirect_requirements_entry_not_found (documented). The vendored → hosted takeover (scan --mode hosted over a vendored project, from #503) doesn't take that gap into account:

  • Wet run: it first reverts the vendored wiring. That deletes the committed wheel, drops the ledger entry, and puts back six==1.16.0 in base.txt or removes the transitive line. Only then does it ask the requirements rewriter to pin the hosted URL, which can't reach that entry. Result: redirected: 0, partial_failure, exit 1, redirect_takeover_unpatched. A project that was patched before the command now installs the unpatched release.
  • --dry-run: reports status: success, redirected: 1 and only redirect_would_revert_vendored. Every takeover preview counts as confirmed (confirmed.extend(dry_run_takeover)), so the stranding is never predicted.
  • The printed remedy ("fix the reported cause and re-run scan --mode hosted") can't work, because hosted mode never rewrites -r includes or adds lines. Only scan --mode vendored recovers.

Impact

  • A user who previews with --dry-run, sees a clean takeover, and then runs it loses their patch. The vendored artifact is deleted, and pip install -r requirements.txt installs the vulnerable upstream release (verified with real pip below).
  • This affects every vendored requirements.txt project whose patched pin sits in a -r include (a very common requirements.txt → -r base.txt layout), and every project where vendored mode added a (transitive) line.
  • The failure is deterministic and knowable before anything is written. Fix berry mode takeover reverting before gates (#468, #369) #470 fixed the same "takeover reverts before the gates" shape for yarn berry.

Repro

Uses the mode_migration_pypi.rs harness: stage_manifest + vendor against the prebuilt fixture server, then mount_hosted_api + hosted_scan_args(uri).

mkdir proj && cd proj
printf -- '-r base.txt\nidna==3.7\n' > requirements.txt
printf 'six==1.16.0\n' > base.txt
# stage .socket/manifest.json for pkg:pypi/six@1.16.0 (as in mode_migration_pypi.rs::stage_manifest), then:
socket-patch vendor --json                 # base.txt -> ./.socket/vendor/pypi/<uuid>/six-1.16.0-py3-none-any.whl  # socket-patch vendor: six==1.16.0
pip install -r requirements.txt            # six.SOCKET_PATCHED == 1
socket-patch scan --mode hosted --yes --dry-run --json --api-url $API --org test-org --api-token x --patch-server-url $API
#   status: success, redirect.redirected: 1, warnings: [redirect_would_revert_vendored]
socket-patch scan --mode hosted --yes --json --api-url $API --org test-org --api-token x --patch-server-url $API ; echo $?
#   status: partial_failure, redirected: 0, warnings: redirect_requirements_entry_not_found,
#   redirect_takeover_reverted_vendored, redirect_takeover_unpatched ; exit 1
cat base.txt                               # six==1.16.0  (vendor wheel deleted, .socket/vendor gone)
pip install -r requirements.txt            # six installed WITHOUT the patch

Transitive variant: requirements.txt = idna==3.7, requirements-dev.txt = six==1.16.0. vendor appends ./.socket/vendor/pypi/<uuid>/six-1.16.0-py3-none-any.whl # socket-patch vendor: six==1.16.0 (transitive) to requirements.txt. The dry run then says redirected: 1, and the wet run removes the line, exits 1 with redirect_takeover_unpatched, and leaves six unpatched.

Expected vs actual

  • Expected (CLI_CONTRACT §scan --mode hosted / the vendored_takeover doc in hosted.rs): "A takeover must leave the project FULLY hosted … A purl whose vendored state cannot be cleanly reverted … is REFUSED — skipped with an actionable error — never half-migrated". Also, --dry-run previews must report the wet run's outcome (hosted.rs ~1012: "the preview's redirected count must report that outcome"; dry_run_predicts_drifted_takeover_refusal pins this for drifted wiring). When the hosted rewriter can't pin the entry because it isn't in the root requirements.txt, the takeover should be refused before the revert, keeping the vendored patch, and the dry run should predict that refusal.
  • Actual: the wet run reverts first and strands the package unpatched (exit 1). The dry run previews success with redirected: 1.

Matrix

Main 045d7ec, Linux. The decision is made on requirements text, so it doesn't depend on the OS or the pip version, and there was no probe run. Real pip confirms the install outcome.

Layout dry run wet run pip install -r afterwards (pip 26.2.1 / py3.13, pip 20.3.4 / py3.8)
pin in -r base.txt success, redirected: 1 exit 1, stranded (reproduced 3×) unpatched / unpatched
pin only in requirements-dev.txt (vendored (transitive) line) success, redirected: 1 exit 1, stranded —
pin in root requirements.txt (control) success, redirected: 1 success, hosted patched (existing requirements_vendored_to_hosted test)

First bad: 0ac5b91a (#503, which enabled the PyPI vendored → hosted takeover). Before it, the takeover was refused outright (#328), so the vendored patch survived.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1018: confirmed.extend(dry_run_takeover) counts every previewed takeover as redirected.
  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1567 (vendored_takeover, dry-run arm ~1780–1810, plus the wet arm): gates only on revertability, not on whether the hosted rewriter can reach the entry. For requirements.txt, that means the vendored line must be in the root file and must not be a vendor-added (transitive) line.
  • crates/socket-patch-core/src/patch/redirect/requirements.rs:355: the root-only redirect_requirements_entry_not_found refusal that the takeover runs into after reverting.

Related but distinct: #659 (npm v1, the opposite direction), #410 (sole-pin refusal), #412 (lock-only include discovery).

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