Skip to content

Commit 56d1f6d

Browse files
mikolalysenkoclaude
andcommitted
Refuse scans from a uv workspace member dir
A scan run from a uv workspace member never saw the root uv.lock. A member with any Hatch configuration (the hatchling backend that `uv init --package` scaffolds before uv 0.8, a hatch.toml) was then rewritten as a lockless Hatch project in both modes, exit 0. The root uv.lock went stale, `uv sync --frozen` installed the unpatched release, and vendored vex attested it not_affected. A directory with a pyproject.toml but no Python lock of its own, listed by the nearest ancestor `[tool.uv.workspace] members` (minus `exclude`), is now refused before anything is written: hosted with `redirect_workspace_lockfile_elsewhere` (the governing-root pre-check), vendored with `pypi_uv_workspace_unsupported`, the code a run from the workspace root already gets. The message names the workspace root and its lock. Fixes #1138 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6fc25dd commit 56d1f6d

7 files changed

Lines changed: 325 additions & 3 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,7 +1325,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
13251325
| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed <code>` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail `<purl> was hosted; restored its upstream registry entry (<files>) before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). |
13261326
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. |
13271327
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
1328-
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
1328+
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml` but no Python lock of its own (`uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock*.toml`, a script lock), and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), so uv installs it from that root's `uv.lock`; the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
13291329
| `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. |
13301330
| `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
13311331
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,3 +2002,116 @@ async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() {
20022002
"the takeover names requirements.txt as an unpatched install source: {env:#}"
20032003
);
20042004
}
2005+
2006+
// ── #1138: a uv workspace member ─────────────────────────────────────────
2007+
2008+
/// A uv workspace (root `pyproject.toml` with `[tool.uv.workspace]` and its
2009+
/// `uv.lock`) whose member `packages/a` carries `member`'s Hatch
2010+
/// configuration; `six` 1.16.0 is installed in the run's venv. Returns the
2011+
/// member directory.
2012+
fn stage_uv_workspace_member(ws: &Path, member: &[(&str, &str)]) -> std::path::PathBuf {
2013+
std::fs::write(
2014+
ws.join("pyproject.toml"),
2015+
"[project]\nname = \"root\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"a\"]\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\n\n[tool.uv.sources]\na = { workspace = true }\n",
2016+
)
2017+
.unwrap();
2018+
std::fs::write(
2019+
ws.join("uv.lock"),
2020+
"version = 1\nrequires-python = \">=3.9\"\n\n[manifest]\nmembers = [\"a\", \"root\"]\n",
2021+
)
2022+
.unwrap();
2023+
let dir = ws.join("packages/a");
2024+
for (rel, text) in member {
2025+
let path = dir.join(rel);
2026+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2027+
std::fs::write(path, text).unwrap();
2028+
}
2029+
// The venv `run_raw` points at (beside the member) holds six 1.16.0.
2030+
let site = dir.join("../empty-venv").join(if cfg!(windows) {
2031+
"Lib/site-packages"
2032+
} else {
2033+
"lib/python3.11/site-packages"
2034+
});
2035+
let dist_info = site.join("six-1.16.0.dist-info");
2036+
std::fs::create_dir_all(&dist_info).unwrap();
2037+
std::fs::write(
2038+
dist_info.join("METADATA"),
2039+
"Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n",
2040+
)
2041+
.unwrap();
2042+
std::fs::write(site.join("six.py"), ORIG).unwrap();
2043+
dir
2044+
}
2045+
2046+
/// Every file under `root` outside `.socket/` and the test venv (relative
2047+
/// path → bytes).
2048+
fn tree(root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
2049+
let mut out = std::collections::BTreeMap::new();
2050+
let mut stack = vec![root.to_path_buf()];
2051+
while let Some(dir) = stack.pop() {
2052+
for entry in std::fs::read_dir(&dir).unwrap() {
2053+
let path = entry.unwrap().path();
2054+
let rel = path
2055+
.strip_prefix(root)
2056+
.unwrap()
2057+
.to_string_lossy()
2058+
.into_owned();
2059+
if path
2060+
.components()
2061+
.any(|c| c.as_os_str() == ".socket" || c.as_os_str() == "empty-venv")
2062+
{
2063+
continue;
2064+
}
2065+
if path.is_dir() {
2066+
stack.push(path);
2067+
} else {
2068+
out.insert(rel, std::fs::read(&path).unwrap());
2069+
}
2070+
}
2071+
}
2072+
out
2073+
}
2074+
2075+
/// #1138: a scan from a uv workspace member whose own files are Hatch-shaped
2076+
/// (the hatchling backend `uv init --package` scaffolds before uv 0.8, or a
2077+
/// `hatch.toml`) used to rewrite the member as a lockless Hatch project in
2078+
/// both modes, exit 0 `success`, while the root `uv.lock` went stale and
2079+
/// `uv sync --frozen` installed the unpatched release. Both modes must fail
2080+
/// closed, name the workspace root and write nothing.
2081+
#[tokio::test]
2082+
async fn uv_workspace_hatch_member_is_refused_in_both_modes() {
2083+
const HATCHLING: &str = "[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n";
2084+
const PLAIN: &str = "[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n";
2085+
for member in [
2086+
&[("pyproject.toml", HATCHLING)][..],
2087+
&[
2088+
("pyproject.toml", PLAIN),
2089+
("hatch.toml", "[envs.default]\n"),
2090+
][..],
2091+
] {
2092+
let (_tmp, ws) = project();
2093+
let dir = stage_uv_workspace_member(&ws, member);
2094+
let before = tree(&ws);
2095+
2096+
let server = MockServer::start().await;
2097+
mount_hosted_api(&server, true).await;
2098+
let (code, env) = hosted_scan(&dir, &server);
2099+
assert_eq!(code, 1, "hosted: {env:#}");
2100+
assert_eq!(
2101+
env["error"]["code"], "redirect_workspace_lockfile_elsewhere",
2102+
"hosted: {env:#}"
2103+
);
2104+
let message = env["error"]["message"].as_str().unwrap_or_default();
2105+
assert!(message.contains("uv workspace"), "{message}");
2106+
assert_eq!(tree(&ws), before, "hosted wrote nothing");
2107+
2108+
stage_manifest(&dir);
2109+
let (code, env) = run_cli(&dir, &["vendor"], &[]);
2110+
assert_ne!(code, 0, "vendored: {env:#}");
2111+
assert!(
2112+
env.to_string().contains("pypi_uv_workspace_unsupported"),
2113+
"vendored: {env:#}"
2114+
);
2115+
assert_eq!(tree(&ws), before, "vendored wrote nothing");
2116+
}
2117+
}

‎crates/socket-patch-core/src/hosted/governing_root.rs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! either pins nothing and reports success (pnpm, #590; npm, yarn and Bun
55
//! `package.json` workspaces, #884; vlt `vlt.json` workspaces, #942) or
66
//! rewrites the member as a lockless project and breaks the workspace
7-
//! (cargo, #417).
7+
//! (cargo, #417; a uv workspace member with Hatch configuration, #1138).
88
//!
99
//! [`refusal`] spots these layouts before any takeover or write, so the run
1010
//! fails closed and names the directory to run from. It also refuses a
@@ -44,7 +44,8 @@ pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere";
4444

4545
/// Refusal code for an npm, yarn, Bun or vlt workspace member: an ancestor
4646
/// `package.json` (or, for vlt, `vlt.json`) lists the project directory in
47-
/// its `workspaces`, and the workspace's lock lives at that root.
47+
/// its `workspaces`, and the workspace's lock lives at that root. Also a uv
48+
/// workspace member (`[tool.uv.workspace] members`, #1138).
4849
pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_elsewhere";
4950

5051
/// Refusal code for a pnpm workspace member with its own lock whose
@@ -86,6 +87,14 @@ pub async fn refusal(
8687
return Some(refusal);
8788
}
8889
}
90+
if candidates.iter().any(|c| c.dep.ecosystem == "pypi") {
91+
if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(root).await {
92+
return Some(Refusal {
93+
code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(),
94+
message: crate::utils::uv_workspace::member_detail(root, &workspace),
95+
});
96+
}
97+
}
8998
if candidates.iter().any(|c| c.dep.ecosystem == "npm") {
9099
let workspace = if has_own_npm_family_lock(root) {
91100
member_stray_lock_refusal(root).await

‎crates/socket-patch-core/src/utils/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod socket_dir;
3232
pub mod target;
3333
pub(crate) mod toml_edit_ext;
3434
pub mod uri;
35+
pub(crate) mod uv_workspace;
3536
pub(crate) mod workspace_globs;
3637

3738
pub mod hatch;

0 commit comments

Comments
 (0)