Skip to content

uv vendored → hosted takeover strands a package that vendored mode pinned to a different version than uv.lock: the wet run reverts to the unpatched release (exit 1), while --dry-run previews a clean takeover #723

Description

[agent] Found by the scheduled uv bug-hunt routine (ledger #310).

Summary

On a uv project, vendored mode pins the patched package to the patch's version even when uv.lock resolved another one (pypi_uv.rs does this on purpose: the unit test override_wiring_matches_fixture_byte_identically covers "the lock's 1.17.0 → 1.16.0 version pin-down"). For example, python-dateutil pulls in six 1.17.0, the manifest patch is for six@1.16.0, and vendor writes override-dependencies = ["six==1.16.0"] plus a path source, and rewrites the lock entry to version = "1.16.0". A direct six>=1.15 gets the same treatment, with no warning.

The vendored → hosted takeover (scan --mode hosted over that project, from #503) doesn't account for this:

  • Wet run: it reverts the vendored wiring first. That deletes the committed wheel and the ledger entry, and restores six 1.17.0 in uv.lock. Only then does it ask the uv rewriter to pin six@1.16.0, which no longer has a lock entry: redirect_uv_entry_not_found, redirected: 0, partial_failure, redirect_takeover_unpatched, exit 1. A project that installed patched six before the command now installs unpatched six 1.17.0 from PyPI.
  • --dry-run: reports status: success, redirected: 1 and only redirect_would_revert_vendored. Every previewed takeover is counted as confirmed (hosted.rs:1018, confirmed.extend(dry_run_takeover)), so the strand is never predicted.

This is the uv counterpart of #699 (requirements.txt). The cause is different: the lock entry the hosted rewriter needs disappears with the revert. Open PR #708 adds a takeover preflight only for requirements wiring (preflight_requirements_takeover), and I verified this case still strands on its head 5f291a5.

Impact

  • A user who previews with --dry-run, sees a clean takeover, and runs it loses the patch. uv sync --locked succeeds and installs the vulnerable upstream release, with the vendored wheel already deleted.
  • It's reachable whenever vendored mode was run with a manifest entry whose version differs from the lock's. For example, a committed .socket/manifest.json from agent mode or get records six@1.16.0, and uv lock --upgrade later moved the transitive six to 1.17.0. Hosted discovery then finds six@1.16.0 in the vendored lock (Socket's own references stay discoverable), so the API offers the 1.16.0 patch and the takeover runs.
  • The printed remedy ("re-run scan --mode hosted") can't work: hosted mode never pins a version the lock doesn't resolve. Only scan --mode vendored recovers.

Repro

I used the mode_migration_pypi.rs harness: stage_manifest (six@1.16.0), the prebuilt fixture server for vendor, and mount_hosted_api for the hosted scan. Real uv was used for lock and install.

mkdir proj && cd proj
cat > pyproject.toml <<'EOF'
[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["python-dateutil==2.9.0.post0"]
EOF
uv lock                                  # six 1.17.0 (transitive)
# stage .socket/manifest.json for pkg:pypi/six@1.16.0, then:
socket-patch vendor --json               # success, no warning; uv.lock six -> version "1.16.0", path source;
                                         # pyproject gains override-dependencies = ["six==1.16.0"] + [tool.uv.sources]
uv sync --locked && .venv/bin/python -c 'import six; print(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]   exit 0
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_takeover_reverted_vendored, redirect_uv_entry_not_found, redirect_takeover_unpatched ; exit 1
git status --short                        # .socket/vendor/** deleted, pyproject.toml + uv.lock back to six 1.17.0 from PyPI
rm -rf .venv && uv sync --locked && .venv/bin/python -c 'import six; print(getattr(six,"SOCKET_PATCHED","UNPATCHED"))'   # UNPATCHED

The direct variant dependencies = ["six>=1.15"] (lock: 1.17.0) behaves identically.

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". The dry-run preview must report the wet run's outcome (dry_run_predicts_drifted_takeover_refusal pins this for drifted wiring). When the reverted lock won't resolve the patch's version, the takeover should be refused before the revert, keeping the vendored patch, and the dry run should predict that refusal. A uv-side twin of the npm takeover_refusal gates at hosted.rs:1725, which today returns None for every non-npm purl, would cover it.
  • Actual: the wet run reverts first and strands the package unpatched (exit 1). The dry run previews success with redirected: 1.

Matrix (Linux, main 045d7ec)

uv transitive pin-down (dateutil → six 1.17.0) direct six>=1.15 dry run uv sync --locked after the wet run
0.5.31 strands (exit 1) – success, redirected: 1 unpatched 1.17.0
0.8.17 strands (reproduced 2×) strands success, redirected: 1 unpatched 1.17.0
0.12.23 (newest) strands – success, redirected: 1 unpatched 1.17.0
0.8.17 on PR #708 5f291a5 strands – success, redirected: 1 unpatched
control: lock already resolves 1.16.0 pass (redirected: 1, patched) patched

The decision is made on lock text, so it doesn't depend on the OS, and I ran no probe. First bad: #503 (0ac5b91a), which enabled the PyPI takeover. Before it the takeover was refused (#328), and the vendored patch survived.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1018: confirmed.extend(dry_run_takeover).
  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1725: takeover_refusal gates npm only. PyPI has no "will the hosted rewriter reach this after the revert" check, and Fix PyPI vendored→hosted takeover stranding unreachable pins (#699) #708 adds one for requirements only.
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:1150: the lock entry's version is rewritten to the patch version (the pin-down that the revert undoes).
  • crates/socket-patch-core/src/patch/redirect/mod.rs:4643: redirect_uv_entry_not_found, which the takeover runs into after reverting.

Related: #699 / PR #708 (requirements.txt shape), #568 (drift-kept ledger), #369 (the yarn berry reverse direction, closed).

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions