[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
Some uv projects write their sources only as sub-tables ([tool.uv.sources.idna] with url = … beneath it) and have no [tool.uv.sources] header. On those projects, hosted scan and vendored scan add an explicit [tool.uv.sources] header that holds the new six = { … } entry. rollback, remove and vendor --revert then remove the entry but keep the header and a blank line. So after a full round trip, pyproject.toml isn't byte-identical to the original: two lines ([tool.uv.sources] and an empty line) are left over.
Impact
The impact is low. The table is empty, uv parses it, uv lock --locked still passes, uv.lock is restored byte for byte, and the install is unchanged. But the tree is left dirty after an unwind, and that breaks "rollback and check for a clean git status" CI flows. It also breaks the restore contract. The residue is stable: a second scan → rollback cycle doesn't add more.
Repro (Linux, real uv 0.8.17 and 0.5.31, main 61cfb9b)
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7", "certifi==2024.2.2"]
[tool.uv.sources.idna]
url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl"
EOF
uv lock && uv sync && cp pyproject.toml pyproject.orig
socket-patch scan --mode hosted --yes $API # adds "[tool.uv.sources]\nsix = { url = … }\n\n" above the sub-table
uv sync --locked # six is patched; vex says not_affected (redirected)
socket-patch rollback --yes $API # exit 0, status success
diff pyproject.orig pyproject.toml
# > [tool.uv.sources]
# >
The same residue appears with socket-patch remove <uuid> after a hosted scan, and with scan --mode vendored followed by vendor --revert. The patch API was a local mock serving a free six 1.16.0 patch (SRI integrity, deterministic wheel), and SOCKET_PYPI_JSON_API pointed at a pass-through to pypi.org.
Spellings that come back byte-identical, checked alongside: [tool.uv] + sources.idna = {…} (dotted key), a root-level tool.uv.sources.idna = {…}, sources = { idna = {…} } (inline table), and no sources at all.
Expected vs actual
- Expected: CLI_CONTRACT.md, "Hosted unwind coverage" → "What a restore does": "only the hosted entries change and every other byte stays the file's own". The
[tool.uv.sources] header is hosted-mode bytes, so it should go when its last hosted entry goes, the same way it already does when hosted mode created the whole table.
- Actual: the header and a blank line stay. Exit code 0, with no warning.
OS × version
| Cell |
uv 0.5.31 |
uv 0.8.17 |
| hosted scan → rollback (Linux) |
❌ residue |
❌ residue |
| hosted scan → remove (Linux) |
– |
❌ residue |
| vendored scan → vendor --revert (Linux) |
– |
❌ residue |
The rewrite is a pure text and TOML edit on the CLI side, independent of OS or uv release; uv only has to accept the result. Not bisected: v5 is the first release with the upstream restore.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:498-502: created_sources_table is false whenever tool.uv.sources exists, including when it's only implied by a [tool.uv.sources.<name>] sub-table. So the remove_table_if_empty(…, "[tool.uv.sources]") call at :887-890 never runs, even though toml_edit printed a new explicit header.
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1111-1128 (restore_metadata): it removes the key, but the sources table isn't empty (it still holds the idna sub-table), and the table stays explicit, so the header is printed. One possible fix is to mark the table implicit again when only sub-tables remain and it wasn't explicit before the scan.
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
Some uv projects write their sources only as sub-tables (
[tool.uv.sources.idna]withurl = …beneath it) and have no[tool.uv.sources]header. On those projects, hostedscanand vendoredscanadd an explicit[tool.uv.sources]header that holds the newsix = { … }entry.rollback,removeandvendor --revertthen remove the entry but keep the header and a blank line. So after a full round trip,pyproject.tomlisn't byte-identical to the original: two lines ([tool.uv.sources]and an empty line) are left over.Impact
The impact is low. The table is empty, uv parses it,
uv lock --lockedstill passes,uv.lockis restored byte for byte, and the install is unchanged. But the tree is left dirty after an unwind, and that breaks "rollback and check for a cleangit status" CI flows. It also breaks the restore contract. The residue is stable: a second scan → rollback cycle doesn't add more.Repro (Linux, real uv 0.8.17 and 0.5.31, main
61cfb9b)The same residue appears with
socket-patch remove <uuid>after a hosted scan, and withscan --mode vendoredfollowed byvendor --revert. The patch API was a local mock serving a free six 1.16.0 patch (SRI integrity, deterministic wheel), andSOCKET_PYPI_JSON_APIpointed at a pass-through to pypi.org.Spellings that come back byte-identical, checked alongside:
[tool.uv]+sources.idna = {…}(dotted key), a root-leveltool.uv.sources.idna = {…},sources = { idna = {…} }(inline table), and no sources at all.Expected vs actual
[tool.uv.sources]header is hosted-mode bytes, so it should go when its last hosted entry goes, the same way it already does when hosted mode created the whole table.OS × version
The rewrite is a pure text and TOML edit on the CLI side, independent of OS or uv release; uv only has to accept the result. Not bisected: v5 is the first release with the upstream restore.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:498-502:created_sources_tableis false whenevertool.uv.sourcesexists, including when it's only implied by a[tool.uv.sources.<name>]sub-table. So theremove_table_if_empty(…, "[tool.uv.sources]")call at:887-890never runs, even though toml_edit printed a new explicit header.crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1111-1128(restore_metadata): it removes the key, but the sources table isn't empty (it still holds theidnasub-table), and the table stays explicit, so the header is printed. One possible fix is to mark the table implicit again when only sub-tables remain and it wasn't explicit before the scan.