Skip to content

uv hosted → vendored takeover (scan / get --mode vendored) restores the package to PyPI before the uv vendored refusals run, so an inline [tool.uv] sources = {…} table (or a #928 marker split) leaves it unpatched in both modes, while --dry-run previews would_vendor #944

Description

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

Summary

In a uv project that hosted mode has already patched, scan --mode vendored and get --mode vendored first restore the hosted pin to its PyPI entry (vendor_takeover_reverted_redirect), and only then run the uv vendored backend. When that backend refuses a shape that hosted mode accepts, the run exits 1 with the restore already committed. The package ends up neither hosted nor vendored, so the next uv sync installs the unpatched release. --dry-run doesn't predict any of this: it reports would_vendor with exit 0.

Two triggers I confirmed:

  1. uv.lock project with an inline sources table, [tool.uv] sources = { attrs = { index = "pypi" } }. Hosted mode grows the inline table and patches six. The vendored backend refuses it with apply_failed / pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table. That refusal is intentional, but here it fires after the restore.
  2. uv pip compile --universal requirements.txt with a marker split (Vendored requirements.txt from uv pip compile --universal refuses a marker-split package (six==1.16.0 ; python < 3.12 + six==1.17.0 ; python >= 3.12) as "not pinned to ==1.16.0", while --dry-run previews would_vendor and hosted / vendored pylock handle the same split #928): six==1.16.0 ; python_full_version < '3.12' plus six==1.17.0 ; …>= '3.12'. Hosted mode rewrites the 1.16.0 line. The takeover puts back six==1.16.0 ; …, then refuses with pypi_requirement_not_pinned, and the hosted line is gone.

Any other uv vendored-only refusal reached after the restore probably behaves the same way. The cause is that no PyPI vendored preflight runs before restore_upstream.

Impact

A user who switches a patched project from hosted to vendored silently loses the security patch. The run does exit 1, but its events say six "was hosted; restored its upstream registry entry", and the committed files are byte-identical to the pre-patch originals. Re-running hosted mode is the only way back. Standalone socket-patch vendor on the same project fails without restoring, so six stays hosted there; only the scan / get takeover path loses it.

Repro (uv 0.12.23, Linux)

Patch data came from a local mock patch server, the same one used on the ledger's earlier probe branches. It serves a patched six 1.16.0 wheel that adds SOCKET_PATCHED = 1.

git init -q app && cd app
cat > pyproject.toml <<'P'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "attrs"]

[tool.uv]
sources = { attrs = { index = "pypi" } }

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"
P
uv lock --python 3.11
socket-patch scan --mode hosted --yes --json          # exit 0
uv sync --locked && .venv/bin/python -c 'import six; print(six.SOCKET_PATCHED)'   # 1
socket-patch scan --mode vendored --dry-run --json    # exit 0, six: would_vendor
socket-patch scan --mode vendored --yes --json        # exit 1
grep -c 'patch/pypi/six\|socket/vendor' pyproject.toml uv.lock   # 0 / 0, byte-identical to the pre-hosted files
rm -rf .venv && uv sync --locked && .venv/bin/python -c 'import six; print(getattr(six,"SOCKET_PATCHED",0))'  # 0

six events in the wet run:

skipped vendor_takeover_reverted_redirect  pkg:pypi/six@1.16.0 was hosted; restored its upstream registry entry (pyproject.toml, uv.lock) before vendoring (mode takeover)
failed  apply_failed                       pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table
skipped vendor_prebuilt_downloaded         …

socket-patch get pkg:pypi/six@1.16.0 --mode vendored --yes --json gives the same events and leaves the same state (exit 1, 0 hosted refs).

Expected vs actual

  • Expected (CLI_CONTRACT.md, "Takeover reconciliation"): a purl the takeover can't carry through fails with "nothing vendored for it, the hosted wiring left in place". The contract's Bun and npm v1 preflights state the goal directly: the refusal is checked "BEFORE the upstream restore … so the package stays hosted-patched instead of being un-hosted and then refused". The dry run should also predict the wet run's failed <code> (exit-code parity), as it does for Bun.
  • Actual: the restore is committed, the vendor refusal follows, and six is unpatched in both modes. The dry run says would_vendor and exits 0.

OS × version

Shape uv 0.5.31 uv 0.8.17 uv 0.12.23
uv.lock + inline [tool.uv] sources = {…}, scan --mode vendored fail fail fail (twice)
same, get --mode vendored — — fail
same, standalone vendor — — pass (stays hosted; exit 1)
uv pip compile --universal requirements.txt split (#928), scan --mode vendored — — fail

All runs were on Linux. I didn't probe macOS or Windows: the failure is in the order of the takeover planning (no file-system specifics). On 0.5.31 and 0.8.17 a warm .venv keeps the hosted bytes after uv sync --locked, but a fresh venv installs the unpatched release.

Bisect

Not bisected. The hosted → vendored takeover is v5 (main) behaviour; I tested main 9c43dfc.

Suspect code

The same class has been reported for other managers: #853 (pnpm), #775 (gem), #688 (npm), #612 (pipenv's sibling requirements.txt). This is the uv / PyPI instance.

No activity

Activity on this issue will appear here.

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