Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bcf290b
Harden uv lockfile patch preservation after #238
mikolalysenko Sep 15, 2026
af9c79b
Follow each uv `[[distribution]]` lock shape when redirecting
mikolalysenko Sep 15, 2026
310b904
Vendor into uv 0.2.35/0.2.36 locks that predate [package.metadata]
mikolalysenko Sep 15, 2026
3726770
Record the 41-release uv compatibility matrix for source 310b904
mikolalysenko Sep 15, 2026
1e5a62a
utils/fs: export read_regular_to_string and is_symlink helpers
mikolalysenko Sep 15, 2026
da2f188
uv vendor: classify [tool.uv] dev-dependencies as direct; repoint eve…
mikolalysenko Sep 15, 2026
8ffe535
uv vendor: advise that override-dependencies wiring needs uv >= 0.5.6
mikolalysenko Sep 15, 2026
01326eb
uv: repoint [manifest] constraints / build-constraints for the redire…
mikolalysenko Sep 15, 2026
a7579ce
uv vendor: keep a CRLF pyproject.toml / uv.lock pair CRLF through wir…
mikolalysenko Sep 15, 2026
433af3f
uv: say what a [[distribution]] lock actually cannot do instead of "r…
mikolalysenko Sep 15, 2026
34cea6a
uv vendor: gate the metadata no-op on the sub-tables too, not the bar…
mikolalysenko Sep 15, 2026
fb5b584
uv vendor: refuse a symlinked pyproject.toml / uv.lock before any write
mikolalysenko Sep 15, 2026
a543899
vendor/pypi: fail-closed listing, include probing and orphan reclaim …
mikolalysenko Sep 15, 2026
94693ed
repair/vendor: FIFO-safe reference scan that also sees `-r` include pins
mikolalysenko Sep 15, 2026
91e6794
python_lock: fix uv version boundaries in comments; pin ≤0.2.5 sdist …
mikolalysenko Sep 15, 2026
82e3385
lock_inventory: document that uv.lock exclusivity is keyed on parse s…
mikolalysenko Sep 15, 2026
8e9af22
python_script: scope the SourcesLayout revert claim to direct deps; p…
mikolalysenko Sep 15, 2026
1f3c87b
pypi_lock: read symlinked locks like their targets, refuse to write t…
mikolalysenko Sep 15, 2026
b5d49ad
hosted: refuse symlinked rewrite targets before the ledger; FIFO-safe…
mikolalysenko Sep 15, 2026
45258f6
backtest-uv: variant lane, stdout export, and a doc-table renderer
mikolalysenko Sep 15, 2026
f03e623
docs: correct the uv boundaries and describe the #239 hardening
mikolalysenko Sep 15, 2026
17d0dcb
uv: count [tool.uv] dev-dependencies as direct for hosted sources; br…
mikolalysenko Sep 15, 2026
f5edb14
uv vendor: advise that the [manifest] constraints repoint needs uv >=…
mikolalysenko Sep 15, 2026
18d4046
Record the 42-release uv compatibility matrix for source 17d0dcb
mikolalysenko Sep 15, 2026
f81cb2e
utils/fs: accept Windows' PermissionDenied for a directory in the syn…
mikolalysenko Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,75 @@ into the new version's section — see docs/releasing.md.

### Added

- **Python patches survive uv lockfiles in both hosted and vendored modes.**
`scan --mode hosted|vendored` now rewrites native `uv.lock` together with
the paired `pyproject.toml` source and metadata, PEP 723 script locks
(`*.py.lock` plus the script's inline metadata), PEP 751 `pylock*.toml`,
and uv-compiled hashed `requirements.txt`, so `uv sync --frozen|--locked`,
`uv run --script`, and `uv pip sync --require-hashes` install the patched
wheel instead of the registry artifact. Verified against real uv binaries
from every 0.x release family (0.0 through 0.12) — first and latest release
of each plus every observed behaviour boundary — see
`docs/testing/uv-compatibility.md`: hosted mode covers requirements from
uv 0.0.5 and native `uv.lock` from 0.1.45 — the first release whose
`uv lock` writes one — through all three `[[distribution]]` lock shapes
and `[[package]]`; vendored native covers every `[[package]]` release
(uv ≥ 0.2.35), vendored requirements cover uv ≥ 0.1.24 (hash-enforced by
`uv pip sync --require-hashes` from 0.1.32 and by default from 0.5.x).
Follow-up hardening:
`vendor --revert` refuses to delete a vendored Python wheel a lock still
references when the ledger entry has no wiring to replay (the shape
`repair` rebuilds), a script or PEP 751 lock supplements rather than hides
`poetry.lock`/`requirements.txt` pins, symlinked locks are discovered, and
refused before any write (never rewritten in place — uv writes through the
link, an atomic rename would replace it; hosted
`redirect_symlinked_file_unsupported`, vendored
`pypi_uv_symlink_unsupported` / `pypi_lock_symlink_unsupported`), CRLF
locks keep their line endings in both the hosted rewriter and the vendored
uv backend (`pyproject.toml`, the rewritten `[[package]]` unit and the
appended `[manifest]` / `[package.metadata]` fragments, plus their revert),
and the hosted `[tool.uv.sources]` edit renders as a header after
`[project]` the way uv writes it. (#238, #239)
- **uv `--locked` survives the project shapes the plain fixture never
reached.** A package listed only in `[tool.uv] dev-dependencies` is now
classified as a direct dependency (it was wired as a transitive override
and its `requires-dev` entry left stale), and every duplicate
`requires-dist` / `requires-dev` entry for the package — extras, markers —
is repointed rather than only the first, so `uv sync --locked` and
`uv lock --check` accept the patched lock instead of exiting 2 and a plain
`uv sync` no longer rewrites it. `[tool.uv] constraint-dependencies` /
`build-constraint-dependencies` naming the package have their `[manifest]`
`constraints` / `build-constraints` entries repointed too (uv ≥ 0.5.6
serializes them with the package's source; 0.2.37–0.5.3 reject the
repointed entry under `--locked`, so the repoint emits the advisory
`pypi_uv_constraints_require_uv_0_5_6`). The transitive
(override-dependencies) branch emits the advisory
`pypi_uv_override_requires_uv_0_5_6`: uv applies `[tool.uv.sources]` to
overrides only from 0.5.6, so on 0.2.35–0.5.3 `--frozen` installs the
patch but a plain `uv sync` reinstalls the registry wheel. The
`[[distribution]]`-grammar vendoring refusal now names the real reasons
(relative path sources unparseable through 0.2.6, rejected by `--locked`
and absolutized by `uv lock` / `uv sync` on 0.2.17–0.2.34) instead of
"records absolute paths". `scripts/backtest-uv.py` gains a project-variant
lane covering these shapes on every `[[package]]` binary and a
`--render-doc-table` mode that prints the doc's results tables from
`results.json`; its export lane reads `uv export` from stdout because
`--output-file` only exists from 0.4.7. (#239)
- **Unwired Python vendor entries revert safely.** `vendor --revert` /
`rollback` on a ledger entry without wiring to replay (the shape `repair`
reconstructs) skips the lock-reference guard under `--preserve-state`
(nothing is deleted, so nothing needs protecting), refuses fail-closed when
the project root cannot be listed or a lock's symlink target cannot be
read (instead of treating "could not enumerate locks" as "no lock
references it" and deleting the wheel), probes `-r` / `--requirement`
includes of `requirements.txt` alongside the root file — the orphan sweep
and `repair` see include-hosted pins too — and reclaims a genuinely
orphaned artifact directory whose lock is gone or whose ledger flavor is
unknown instead of failing forever. Hosted `scan` / `get`, `repair`, and
ledger-less `rollback` read candidate lockfiles through the FIFO-safe
reader, so a named pipe at a lockfile name no longer wedges the command in
`open(2)`. (#239)

- **Path targeting on `scan` and `rollback`.** `scan [PATHS]...` scopes
discovery to packages with an installed copy under a matching glob
(ancestor rule: `scan packages/foo` covers the subtree; `*` never crosses
Expand Down
141 changes: 133 additions & 8 deletions crates/socket-patch-cli/src/commands/repair_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
//! from the lockfile path itself (the contract's uuid-in-path rule), the
//! record from the manifest (or the patch API, yielding a detached entry),
//! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the
//! artifact again — stamped with the npm lockfile FLAVOR the reference was
//! found in, so a later `vendor --revert` routes to the backend whose
//! unwired-revert guard probes the right lockfile. WIRING reconstruction is
//! artifact again — stamped with the lockfile FLAVOR the reference was
//! found in (npm family and pypi), so a later `vendor --revert` routes to the
//! backend whose unwired-revert guard probes the right lockfile. WIRING reconstruction is
//! per-ecosystem: gem recognizes
//! its own Gemfile/lock wiring and rebuilds full revert-capable records
//! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other
Expand Down Expand Up @@ -55,6 +55,7 @@ use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
use socket_patch_core::crawlers::CrawlerOptions;
use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
use socket_patch_core::patch::copy_tree::remove_tree;
use socket_patch_core::utils::fs::read_regular_to_string;
use socket_patch_core::utils::purl::{
normalize_purl, percent_decode_purl_component, strip_purl_qualifiers,
};
Expand Down Expand Up @@ -98,6 +99,8 @@ struct Candidate {

/// Files the vendor backends rewire — the search space for
/// `.socket/vendor/<eco>/<uuid>/<leaf>` references when the ledger is gone.
/// The Python locks the root LISTS (`pylock*.toml`, `*.py.lock` + script)
/// and the requirements `-r` include tree are appended at scan time.
const WIRING_FILES: &[&str] = &[
"package-lock.json",
"npm-shrinkwrap.json",
Expand Down Expand Up @@ -143,10 +146,23 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String,
files.push(path);
}
}
// The requirements planner writes a vendored pin where the original pin
// was — possibly inside a `-r` include — so the root requirements.txt
// alone would miss it (and the orphan sweep, which reuses this scan,
// would delete the include-referenced wheel). An unreadable include
// tree degrades to the root file, matching the per-file tolerance
// below.
if let Ok(includes) =
socket_patch_core::vendor::requirements_include_names(project_root).await
{
files.extend(includes);
}
files.sort();
files.dedup();
for file in files {
let Ok(text) = tokio::fs::read_to_string(project_root.join(file)).await else {
// FIFO-safe: a pipe under a wiring-file name must be skipped, not
// waited on forever in open(2).
let Ok(text) = read_regular_to_string(&project_root.join(file)).await else {
continue;
};
let mut rest = text.as_str();
Expand Down Expand Up @@ -221,8 +237,14 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V
async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option<String> {
if eco == "pypi" {
let needle = format!(".socket/vendor/pypi/{uuid}/");
for file in socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()? {
if tokio::fs::read_to_string(project_root.join(&file))
let mut files =
socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()?;
// uv.lock outranks the standalone locks (the vendor backend's own
// precedence): a pylock EXPORTED from the wired project lock must not
// relabel the entry `python-lock`. Alphabetical order would.
files.sort_by_key(|file| file != "uv.lock");
for file in files {
if read_regular_to_string(&project_root.join(&file))
.await
.ok()
.is_some_and(|text| text.contains(&needle))
Expand All @@ -244,7 +266,7 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) ->
}
let needle = format!(".socket/vendor/npm/{uuid}/");
let read = |name: &'static str| async move {
tokio::fs::read_to_string(project_root.join(name))
read_regular_to_string(&project_root.join(name))
.await
.ok()
};
Expand Down Expand Up @@ -1446,6 +1468,96 @@ fn npm_coords(base_purl: &str) -> Option<(String, String)> {
mod tests {
use super::*;

/// A FIFO under a wiring-file name (here the paired `<script>.py` of a
/// `*.py.lock`, which the lister cannot filter because it derives the
/// script name without stat'ing it) used to wedge `repair` forever: a
/// plain `read_to_string` blocks in open(2) waiting for a writer. Every
/// reference read must go through the FIFO-safe reader and skip it.
#[cfg(unix)]
#[tokio::test]
async fn repair_returns_with_fifo_script() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uuid = "11111111-1111-4111-8111-111111111111";
let path = format!(".socket/vendor/pypi/{uuid}/requests-2.28.1-py3-none-any.whl");
tokio::fs::write(
root.join("tool.py.lock"),
format!("archive = {{ path = '{path}' }}"),
)
.await
.unwrap();
let fifos = ["tool.py", "bun.lock"];
for name in fifos {
let c = std::ffi::CString::new(root.join(name).to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0, "mkfifo {name}");
}
// Release valve: if a read DID wedge in open(2), connecting a
// writer lets the blocking thread finish so the runtime can shut
// down and the test fails on the timeout instead of hanging.
let release = || {
use std::os::unix::fs::OpenOptionsExt as _;
for name in fifos {
let _ = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(root.join(name));
}
};

let scanned =
tokio::time::timeout(Duration::from_secs(5), scan_vendor_references(root)).await;
release();
let refs = scanned.expect("scan_vendor_references must not wedge on a FIFO script");
assert_eq!(
refs,
vec![("pypi".to_string(), uuid.to_string(), path.clone())],
"the lock reference is still recovered around the FIFO"
);

let flavor = tokio::time::timeout(
Duration::from_secs(5),
detect_reference_flavor(root, "npm", uuid),
)
.await;
release();
assert_eq!(
flavor.expect("detect_reference_flavor must not wedge on a FIFO lock"),
None
);
}

/// The requirements planner writes vendored pins into `-r` includes,
/// so a reference may live ONLY in an include. The scan used to read
/// the root requirements.txt alone, leaving such a wheel unrecoverable
/// by `repair` and deletable by the orphan sweep.
#[tokio::test]
async fn scan_recovers_include_hosted_requirements_reference() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uuid = "11111111-1111-4111-8111-111111111111";
let path = format!(".socket/vendor/pypi/{uuid}/six-1.16.0-py2.py3-none-any.whl");
tokio::fs::write(root.join("requirements.txt"), "-r requirements/base.txt\n")
.await
.unwrap();
tokio::fs::create_dir(root.join("requirements")).await.unwrap();
tokio::fs::write(
root.join("requirements/base.txt"),
format!(
"./{path} --hash=sha256:{} # socket-patch vendor: six==1.16.0\n",
"0".repeat(64)
),
)
.await
.unwrap();
let refs = scan_vendor_references(root).await;
assert_eq!(
refs,
vec![("pypi".to_string(), uuid.to_string(), path)],
"{refs:?}"
);
}

#[tokio::test]
async fn scan_recovers_script_and_pep751_vendor_references() {
let tmp = tempfile::tempdir().unwrap();
Expand All @@ -1462,14 +1574,27 @@ mod tests {
let references = scan_vendor_references(tmp.path()).await;
assert_eq!(
references,
vec![("pypi".to_string(), uuid.to_string(), path)]
vec![("pypi".to_string(), uuid.to_string(), path.clone())]
);
assert_eq!(
detect_reference_flavor(tmp.path(), "pypi", uuid)
.await
.as_deref(),
Some("python-lock")
);
// The project lock outranks a pylock exported from it.
tokio::fs::write(
tmp.path().join("uv.lock"),
format!("version = 1\n\n[[package]]\nname = \"requests\"\nversion = \"2.28.1\"\nsource = {{ path = \"{path}\" }}\n"),
)
.await
.unwrap();
assert_eq!(
detect_reference_flavor(tmp.path(), "pypi", uuid)
.await
.as_deref(),
Some("uv")
);
}

/// pnpm writes vendored paths in THREE spellings — override values,
Expand Down
Loading
Loading