diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs index 789e7c522..8ffa40860 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs @@ -740,3 +740,88 @@ fn hatch_existing_env_hosted() { fn hatch_existing_env_vendored() { existing_env_flow(Mode::Vendored); } + +/// #505: a vendored scan run from a member of a Hatch 1.16+ workspace whose +/// root environment installs the member's `[project]` dependencies (read +/// raw, so `{root:uri}` would reach pip unexpanded) is refused with +/// `pypi_hatch_unsupported` and writes nothing; the workspace root's real +/// `hatch env create` still succeeds afterwards. +#[test] +#[ignore = "real Hatch + PyPI; run with --ignored (CI: SOCKET_PATCH_HATCH_E2E_REQUIRED=1)"] +fn hatch_workspace_member_vendored_is_refused() { + let Some(hatch) = hatch() else { return }; + let version = hatch.version.clone(); + if vtuple(&version) < (1, 16) { + eprintln!("SKIP: Hatch {version} has no workspaces"); + return; + } + let what = format!("hatch {version} workspace-member/vendored"); + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("ws"); + let member = ws.join("libs/lib1"); + std::fs::create_dir_all(ws.join("src/app")).unwrap(); + std::fs::create_dir_all(member.join("src/lib1")).unwrap(); + std::fs::write(ws.join("src/app/__init__.py"), "").unwrap(); + std::fs::write(member.join("src/lib1/__init__.py"), "").unwrap(); + let root_pyproject = format!( + "{BUILD}[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = []\n\n\ + [tool.hatch.build.targets.wheel]\npackages = [\"src/app\"]\n\n\ + [tool.hatch.envs.default.workspace]\nmembers = [\"libs/*\"]\n" + ); + let member_pyproject = format!( + "{BUILD}[project]\nname = \"lib1\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n\ + [tool.hatch.build.targets.wheel]\npackages = [\"src/lib1\"]\n" + ); + std::fs::write(ws.join("pyproject.toml"), &root_pyproject).unwrap(); + std::fs::write(member.join("pyproject.toml"), &member_pyproject).unwrap(); + + let pristine_venv = member.join(".venv"); + if let Err(e) = make_venv(&hatch.uv, &hatch.python(), &pristine_venv, &["six==1.16.0"]) { + skip_or_fail(REQUIRED, &format!("{what}: six==1.16.0 from PyPI: {e}")); + return; + } + let (_, pristine, _) = + six_oracle(&venv_bin(&pristine_venv, "python"), &member).expect("pristine six"); + let patched = [pristine.as_slice(), PATCH_SUFFIX].concat(); + let api = RealApi::start(Mode::Vendored.uuid(), &pristine, &patched); + let scan_envs = vec![("PATH".to_string(), hatch.bin_path())]; + let (code, env, stderr) = + socket_scan(&member, &api, &scan_mode_args(Mode::Vendored), &scan_envs); + assert_ne!(code, Some(0), "{what}: must refuse: {env}\n{stderr}"); + let events: Vec<&serde_json::Value> = env["events"].as_array().into_iter().flatten().collect(); + assert!( + events + .iter() + .any(|e| e["errorCode"] == "pypi_hatch_unsupported" + && e.to_string().contains("Hatch workspace")), + "{what}: {env}\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(member.join("pyproject.toml")).unwrap(), + member_pyproject, + "{what}: member pyproject must be untouched" + ); + assert_eq!( + std::fs::read_to_string(ws.join("pyproject.toml")).unwrap(), + root_pyproject, + "{what}: root pyproject must be untouched" + ); + let _ = std::fs::remove_dir_all(&pristine_venv); + + // The workspace root env still syncs from the untouched member. + let case = tmp.path().join("case"); + let envs = hatch.case_env(&case); + let out = hatch.run(&ws, &envs, &["env", "create"]); + assert!( + out.status.success(), + "{what}: root `hatch env create`: {}", + String::from_utf8_lossy(&out.stderr) + ); + record( + "hatch", + &version, + "workspace-member/vendored", + "refused", + "pass", + ); +} diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 914e7c535..46108180b 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -138,6 +138,26 @@ pub(crate) fn dependency_specs<'d>( specs } +/// The `envs` item Hatch reads and the [`HATCH_FILES`] entry holding it: +/// hatch.toml's when that document has an `envs` key (Hatch merges an +/// external config by TOP-LEVEL key, so it replaces pyproject's whole +/// table), else pyproject's `[tool.hatch.envs]`. +pub(crate) fn environments<'d>( + pyproject: Option<&'d DocumentMut>, + hatch_toml: Option<&'d DocumentMut>, +) -> (&'static str, Option<&'d Item>) { + match hatch_toml.filter(|d| d.contains_key("envs")) { + Some(doc) => (HATCH_FILES[1], doc.get("envs")), + None => ( + HATCH_FILES[0], + pyproject + .and_then(|d| d.get("tool")) + .and_then(|t| t.get("hatch")) + .and_then(|h| h.get("envs")), + ), + } +} + /// The Hatch environment tables: `envs` of the hatch.toml document, else of /// pyproject's `[tool.hatch]` (Hatch merges an external config by TOP-LEVEL /// key, so a hatch.toml `envs` key — whatever its value — replaces @@ -146,18 +166,8 @@ fn environment_specs<'d>( pyproject: Option<&'d DocumentMut>, hatch_toml: Option<&'d DocumentMut>, ) -> Vec> { - let (file, hatch) = match hatch_toml.filter(|d| d.contains_key("envs")) { - Some(doc) => (HATCH_FILES[1], Some(doc.as_item())), - None => ( - HATCH_FILES[0], - pyproject - .and_then(|d| d.get("tool")) - .and_then(|t| t.get("hatch")), - ), - }; - hatch - .and_then(|h| h.get("envs")) - .and_then(Item::as_table_like) + let (file, envs) = environments(pyproject, hatch_toml); + envs.and_then(Item::as_table_like) .into_iter() .flat_map(|envs| envs.iter()) .flat_map(|(_, env)| { diff --git a/crates/socket-patch-core/src/utils/hatch_workspace.rs b/crates/socket-patch-core/src/utils/hatch_workspace.rs new file mode 100644 index 000000000..9068775c0 --- /dev/null +++ b/crates/socket-patch-core/src/utils/hatch_workspace.rs @@ -0,0 +1,604 @@ +//! Hatch workspaces (Hatch 1.16+, #505): an environment's +//! `workspace.members` installs each member editable together with the +//! member's `[project]` dependencies, and its raw optional dependencies +//! whenever the member's `features` or any requirement the environment +//! installs names one of them (`lib[extra]`, expanded transitively across +//! the root and every member by `resolve_extras`). For a member whose +//! dependencies are static, Hatch reads those strings from the member's +//! `pyproject.toml` raw, without context formatting, so a `{root:uri}` +//! reference there reaches pip unexpanded and every sync of the workspace +//! environment fails. This module finds the workspace environments that +//! list a project, read the way Hatch 1.18's `Workspace.members` resolves +//! them. Which extras an environment ends up installing depends on the +//! whole workspace (environment templates, dependency groups, the root's +//! and every member's extras), so every optional dependency of a listed +//! member counts as read raw: a refusal leaves hosted and agent mode, a +//! missed one breaks the workspace. + +use std::path::{Path, PathBuf}; + +use toml_edit::{DocumentMut, Item, TableLike, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::read_regular_to_string_sync; +use crate::utils::hatch::{environments, HATCH_FILES}; +use crate::utils::repo_root; +use crate::vendor::common::pep508_name; + +/// How deep below the checkout root a workspace root is looked for. +const MAX_DEPTH: usize = 8; + +/// A workspace environment that installs a member's dependencies raw. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawConsumer { + /// The directory of the project declaring the workspace. + pub root: PathBuf, + /// The environment whose `workspace.members` lists the member. + pub env: String, +} + +/// The first workspace environment that lists the member and so may +/// install `name` raw from its static `[project]` dependencies or optional +/// dependencies, read from `member_pyproject`. Workspace roots are looked +/// for in the member's ancestors and, when the member sits in a git +/// checkout, in every project of that checkout (a root can list a sibling +/// through a `../` spec); the walk never leaves the checkout. `None` when +/// the member does not declare `name` there or no workspace lists it, +/// including when its dependencies are dynamic: Hatch then builds the +/// metadata through the backend, which expands `{root:uri}`. +pub fn raw_consumer(member: &Path, member_pyproject: &str, name: &str) -> Option { + let document: DocumentMut = member_pyproject.parse().ok()?; + let project = document.get("project")?; + if strings(project.get("dynamic")) + .iter() + .any(|field| matches!(*field, "dependencies" | "optional-dependencies")) + { + return None; + } + let name = canonicalize_pypi_name(name); + let declared = strings(project.get("dependencies")) + .into_iter() + .chain( + project + .get("optional-dependencies") + .and_then(Item::as_table_like) + .into_iter() + .flat_map(|groups| groups.iter()) + .flat_map(|(_, group)| strings(Some(group))), + ) + .any(|spec| canonicalize_pypi_name(pep508_name(spec)) == name); + if !declared { + return None; + } + // Hatch reaches a member through symlinks without resolving them, so + // roots are looked for above both spellings and found members are + // compared by their real path. + let lexical = absolute(member)?; + let member = std::fs::canonicalize(&lexical).unwrap_or_else(|_| lexical.clone()); + roots(&member, &lexical).into_iter().find_map(|root| { + listing_environments(&root, &member) + .into_iter() + .next() + .map(|env| RawConsumer { root, env }) + }) +} + +/// `path` made absolute and normalized lexically, the way Python's +/// `os.path.abspath` reads it. +fn absolute(path: &Path) -> Option { + crate::utils::relpath::normalize_lexically(&std::path::absolute(path).ok()?) +} + +/// The directories that may declare a workspace listing the member at +/// `real` (its canonical path) or `lexical` (as spelled): their ancestors +/// as far as a repository lookup goes ([`repo_root::search_dirs`]: through +/// the checkout root, never into the home directory or past a +/// `GIT_CEILING_DIRECTORIES` entry), then the other projects of the +/// checkout. +fn roots(real: &Path, lexical: &Path) -> Vec { + let home = repo_root::home(); + let mut roots: Vec = repo_root::search_dirs(real).into_iter().skip(1).collect(); + let at_checkout = lexical.join(".git").exists(); + for dir in lexical.ancestors().skip(1).take_while(|_| !at_checkout) { + if home.as_deref() == Some(dir) { + break; + } + if !roots.iter().any(|root| root == dir) { + roots.push(dir.to_path_buf()); + } + if dir.join(".git").exists() { + break; + } + } + let Some(checkout) = repo_root::find_git_repo(real).map(|repo| repo.root) else { + return roots; + }; + let walk = ignore::WalkBuilder::new(&checkout) + .max_depth(Some(MAX_DEPTH)) + .require_git(false) + .filter_entry(|entry| { + !matches!( + entry.file_name().to_str(), + Some("node_modules" | "__pypackages__" | "site-packages") + ) + }) + .build(); + for entry in walk.flatten() { + let path = entry.path(); + let Some(dir) = path.parent() else { continue }; + if !HATCH_FILES + .iter() + .any(|file| path.file_name() == Some(file.as_ref())) + || dir == real + || roots.iter().any(|root| root == dir) + { + continue; + } + if read_regular_to_string_sync(path).is_ok_and(|text| text.contains("workspace")) { + roots.push(dir.to_path_buf()); + } + } + roots +} + +fn strings(item: Option<&Item>) -> Vec<&str> { + item.and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect() +} + +fn parse(dir: &Path, file: &str) -> Option { + read_regular_to_string_sync(&dir.join(file)) + .ok()? + .parse() + .ok() +} + +/// The environments of the project at `root` whose workspace lists +/// `member` (a canonical path). Members are a string array, an array of +/// inline tables or an array of tables (`[[...workspace.members]]`), each +/// table naming its `path`. An environment that inherits a workspace from +/// its template lists the same members as the template, which is found. +fn listing_environments(root: &Path, member: &Path) -> Vec { + let external = parse(root, HATCH_FILES[1]); + let project = parse(root, HATCH_FILES[0]); + let (_, envs) = environments(project.as_ref(), external.as_ref()); + let Some(envs) = envs.and_then(Item::as_table_like) else { + return Vec::new(); + }; + let path_of = |table: &dyn TableLike| table.get("path")?.as_str().map(str::to_owned); + let mut found = Vec::new(); + for (env, config) in envs.iter() { + let Some(workspace) = config.get("workspace") else { + continue; + }; + let exclude = strings(workspace.get("exclude")); + let specs: Vec = match workspace.get("members") { + Some(Item::Value(Value::Array(members))) => members + .iter() + .filter_map(|entry| match entry { + Value::String(path) => Some(path.value().clone()), + Value::InlineTable(table) => path_of(table), + _ => None, + }) + .collect(), + Some(Item::ArrayOfTables(members)) => { + members.iter().filter_map(|table| path_of(table)).collect() + } + _ => continue, + }; + let lists = specs.iter().any(|spec| { + members_of(root, spec, &exclude) + .iter() + .any(|path| std::fs::canonicalize(path).ok().as_deref() == Some(member)) + }); + if lists { + found.push(env.to_string()); + } + } + found +} + +/// A member spec with the context fields Hatch formats in it (`{root}`, +/// `{home}`) filled in; `None` when another field remains. +fn expand(root: &Path, spec: &str) -> Option { + let mut spec = spec.replace("{root}", &root.to_string_lossy()); + if spec.contains("{home}") { + let home = crate::utils::fs::home_dir()?; + spec = spec.replace("{home}", &home.to_string_lossy()); + } + (!spec.contains('{')).then_some(spec) +} + +/// The members (directories, as reached) the member spec `spec` of the +/// workspace at `root` lists, as Hatch's `find_members` resolves it: the +/// spec is joined to the root and normalized, and the part below the root +/// and the spec's shared prefix is matched one directory level at a time +/// (a component with `*`, `?` or `[` as an fnmatch pattern Python searches +/// for, so it may match a suffix of the directory name; symlinked +/// directories are followed), less any an `exclude` pattern matches by +/// its path relative to that prefix or its absolute path. +fn members_of(root: &Path, spec: &str, exclude: &[&str]) -> Vec { + let Some(spec) = expand(root, spec).filter(|spec| !spec.is_empty()) else { + return Vec::new(); + }; + let Some(target) = absolute(&root.join(spec)) else { + return Vec::new(); + }; + let prefix: PathBuf = root + .components() + .zip(target.components()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a) + .collect(); + let Ok(pattern) = target.strip_prefix(&prefix) else { + return Vec::new(); + }; + let mut found = vec![prefix.clone()]; + for component in pattern.iter().map(|c| c.to_string_lossy().into_owned()) { + let globbed = component.contains(['*', '?', '[']); + let search = format!("*{component}"); + let mut next = Vec::new(); + for dir in &found { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let matched = if globbed { + fnmatch(&search, &name) + } else { + name == component + }; + if matched && entry.path().is_dir() { + next.push(entry.path()); + } + } + } + found = next; + } + // `fnmatch.fnmatch` applies `os.path.normcase` to both sides. + let normcase = |text: &str| { + if cfg!(windows) { + text.to_lowercase().replace('/', "\\") + } else { + text.to_owned() + } + }; + found.retain(|path| { + let relative = path + .strip_prefix(&prefix) + .map(|rel| rel.to_string_lossy().into_owned()) + .unwrap_or_default(); + let absolute = path.to_string_lossy(); + !exclude.iter().any(|pattern| { + let pattern = normcase(pattern); + [relative.as_str(), &absolute] + .iter() + .any(|text| fnmatch(&pattern, &normcase(text))) + }) + }); + found +} + +enum Token { + Star, + Any, + Class(Vec<(char, char)>, bool), + Literal(char), +} + +/// Python's `fnmatch.fnmatchcase` over the whole of `text`: `*` matches +/// any run (separators included), `?` one character, `[..]` a class +/// (`[!..]` negated, `a-z` ranges); an unclosed `[` is literal. Matched +/// greedily, backtracking only to the last `*`, so time stays linear in +/// the number of stars. +fn fnmatch(pattern: &str, text: &str) -> bool { + let tokens = tokenize(&pattern.chars().collect::>()); + let text: Vec = text.chars().collect(); + let (mut p, mut t) = (0, 0); + let mut resume: Option<(usize, usize)> = None; + while t < text.len() { + let step = match tokens.get(p) { + Some(Token::Star) => { + resume = Some((p, t)); + p += 1; + continue; + } + Some(Token::Any) => true, + Some(Token::Class(ranges, negated)) => { + ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&text[t])) != *negated + } + Some(Token::Literal(c)) => *c == text[t], + None => false, + }; + if step { + p += 1; + t += 1; + } else if let Some((star, from)) = resume { + p = star + 1; + t = from + 1; + resume = Some((star, from + 1)); + } else { + return false; + } + } + tokens[p..].iter().all(|token| matches!(token, Token::Star)) +} + +fn tokenize(pattern: &[char]) -> Vec { + let mut tokens = Vec::new(); + let mut i = 0; + while i < pattern.len() { + match pattern[i] { + '*' => tokens.push(Token::Star), + '?' => tokens.push(Token::Any), + '[' => { + let negated = pattern.get(i + 1) == Some(&'!'); + let start = i + 1 + usize::from(negated); + // A `]` right after the opening (or `[!`) is a member. + match (start + 1..pattern.len()).find(|&j| pattern[j] == ']') { + Some(close) => { + let items = &pattern[start..close]; + let mut ranges = Vec::new(); + let mut k = 0; + while k < items.len() { + if k + 2 < items.len() && items[k + 1] == '-' { + ranges.push((items[k], items[k + 2])); + k += 3; + } else { + ranges.push((items[k], items[k])); + k += 1; + } + } + tokens.push(Token::Class(ranges, negated)); + i = close; + } + None => tokens.push(Token::Literal('[')), + } + } + c => tokens.push(Token::Literal(c)), + } + i += 1; + } + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT: &str = "[project]\nname = \"app\"\nversion = \"0.1.0\"\n"; + const MEMBER: &str = + "[project]\nname = \"lib1\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\ + [project.optional-dependencies]\nsec = [\"urllib3==1.26.18\"]\n\ + all = [\"lib1[sec]\", \"idna==3.4\"]\nSpaced_Out = [\"certifi==1\"]\n"; + + /// A git checkout at `/ws` with `root` as the pyproject and + /// `hatch` as the hatch.toml of the workspace at `/ws/`, and + /// the member directory `member`. + fn layout_at( + at: &str, + root: &str, + hatch: Option<&str>, + member: &str, + ) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let ws = temp.path().join("ws"); + std::fs::create_dir_all(ws.join(".git")).unwrap(); + let declaring = ws.join(at); + std::fs::create_dir_all(&declaring).unwrap(); + std::fs::write(declaring.join("pyproject.toml"), root).unwrap(); + if let Some(hatch) = hatch { + std::fs::write(declaring.join("hatch.toml"), hatch).unwrap(); + } + let dir = ws.join(member); + std::fs::create_dir_all(&dir).unwrap(); + (temp, dir) + } + + fn layout(root: &str, hatch: Option<&str>, member: &str) -> (tempfile::TempDir, PathBuf) { + layout_at("", root, hatch, member) + } + + fn consumer(dir: &Path, member: &str, name: &str) -> Option { + raw_consumer(dir, member, name).map(|found| found.env) + } + + fn workspace(members: &str) -> String { + format!("{ROOT}[tool.hatch.envs.default.workspace]\nmembers = {members}\n") + } + + /// #505: a member listed by a glob, a plain path, a `{path = ..}` + /// table, an array of tables or a `{root}` spec is consumed for its + /// `[project]` dependencies; a project the workspace does not list, or + /// none at all, is not. + #[test] + fn members_listed_by_glob_path_or_table() { + for members in [ + "[\"libs/*\"]", + "[\"libs/lib1\"]", + "[\"./libs/../libs/lib1/\"]", + "[{ path = \"libs/lib1\" }]", + "[\"libs/l?b[0-9]\"]", + // Python searches a component pattern, so it may match a + // suffix of the directory name. + "[\"libs/b[1]\"]", + "[\"{root}/libs/lib1\"]", + ] { + let (_temp, dir) = layout(&workspace(&members), None, "libs/lib1"); + assert_eq!( + consumer(&dir, MEMBER, "six").as_deref(), + Some("default"), + "{members}" + ); + assert_eq!(consumer(&dir, MEMBER, "Six").as_deref(), Some("default")); + } + let tables = + format!("{ROOT}[[tool.hatch.envs.default.workspace.members]]\npath = \"libs/lib1\"\n"); + let (_temp, dir) = layout(&tables, None, "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six").as_deref(), Some("default")); + for members in [ + "[\"other/*\"]", + "[\"libs\"]", + "[\"libs/*/x\"]", + "[]", + "[\"{env:X}/libs/lib1\"]", + ] { + let (_temp, dir) = layout(&workspace(members), None, "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six"), None, "{members}"); + } + let (_temp, dir) = layout(ROOT, None, "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six"), None); + } + + /// #505 (review): a workspace root beside the member, listing it + /// through a `../` spec, is found anywhere in the checkout. + #[test] + fn sibling_roots_listing_the_member_through_parent_specs() { + let (_temp, dir) = layout_at( + "apps/web", + &workspace("[\"../../libs/*\"]"), + None, + "libs/lib1", + ); + assert_eq!(consumer(&dir, MEMBER, "six").as_deref(), Some("default")); + let (_temp, dir) = layout_at("apps/web", &workspace("[\"../x/*\"]"), None, "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six"), None); + } + + /// #505 and review: once a workspace lists the member, every optional + /// dependency counts as read raw, whatever reaches it (the entry's + /// `features`, a `lib1[sec]` in an environment, its template, a + /// dependency group, the root or another member, a mixed-case key); + /// a package the member does not declare is never refused. + #[test] + fn every_optional_dependency_of_a_listed_member_counts() { + for root in [ + workspace("[\"libs/*\"]"), + workspace("[{ path = \"libs/lib1\", features = [\"sec\"] }]"), + format!( + "{ROOT}[tool.hatch.envs.default]\ndependencies = [\"lib1[Spaced_Out]\"]\n\ + [tool.hatch.envs.test.workspace]\nmembers = [\"libs/*\"]\n" + ), + ] { + let (_temp, dir) = layout(&root, None, "libs/lib1"); + for name in ["six", "urllib3", "idna", "certifi"] { + assert!(consumer(&dir, MEMBER, name).is_some(), "{root}: {name}"); + } + assert_eq!(consumer(&dir, MEMBER, "requests"), None, "{root}"); + } + } + + /// #505: a workspace declared in hatch.toml counts, and a hatch.toml + /// `envs` table hides pyproject's environments. + #[test] + fn hatch_toml_workspaces_replace_pyproject_envs() { + let hatch = "[envs.test.workspace]\nmembers = [\"libs/*\"]\n"; + let (_temp, dir) = layout(ROOT, Some(hatch), "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six").as_deref(), Some("test")); + let root = workspace("[\"libs/*\"]"); + let (_temp, dir) = layout(&root, Some("[envs.lint]\n"), "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six"), None); + let (_temp, dir) = layout(&root, Some("[build]\n"), "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six").as_deref(), Some("default")); + } + + /// `exclude` drops a listed member; dynamic member dependencies are + /// built by the backend, which expands `{root:uri}`. + #[test] + fn excluded_and_dynamic_members_are_not_raw() { + let root = format!("{}exclude = [\"libs/lib1\"]\n", workspace("[\"libs/*\"]")); + let (_temp, dir) = layout(&root, None, "libs/lib1"); + assert_eq!(consumer(&dir, MEMBER, "six"), None); + let (_temp, dir) = layout(&workspace("[\"libs/*\"]"), None, "libs/lib1"); + let dynamic = MEMBER.replace( + "version = ", + "dynamic = [\"optional-dependencies\"]\nversion = ", + ); + assert_eq!(consumer(&dir, &dynamic, "six"), None); + } + + /// The walk stops at the checkout root, including when the member is + /// that root: a workspace above `.git` is not read. + #[test] + fn ancestors_stop_at_the_checkout_root() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("pyproject.toml"), + workspace("[\"repo/lib1\", \"top\"]"), + ) + .unwrap(); + let dir = temp.path().join("repo/lib1"); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(consumer(&dir, MEMBER, "six").as_deref(), Some("default")); + std::fs::create_dir_all(temp.path().join("repo/.git")).unwrap(); + assert_eq!(consumer(&dir, MEMBER, "six"), None); + let top = temp.path().join("top"); + std::fs::create_dir_all(&top).unwrap(); + assert_eq!(consumer(&top, MEMBER, "six").as_deref(), Some("default")); + std::fs::create_dir_all(top.join(".git")).unwrap(); + assert_eq!(consumer(&top, MEMBER, "six"), None); + } + + /// Bugbot: a member Hatch reaches through a symlinked directory is + /// still listed, whichever spelling the scan was given. + #[cfg(unix)] + #[test] + fn members_reached_through_symlinks_are_listed() { + let (temp, real) = layout(&workspace("[\"libs/*\"]"), None, "vendor/lib1"); + let ws = temp.path().join("ws"); + std::fs::create_dir_all(ws.join("libs")).unwrap(); + std::os::unix::fs::symlink(&real, ws.join("libs/lib1")).unwrap(); + assert_eq!(consumer(&real, MEMBER, "six").as_deref(), Some("default")); + assert_eq!( + consumer(&ws.join("libs/lib1"), MEMBER, "six").as_deref(), + Some("default") + ); + let (_temp, real) = layout(&workspace("[\"other/*\"]"), None, "vendor/lib1"); + assert_eq!(consumer(&real, MEMBER, "six"), None); + } + + /// A FIFO squatting an ancestor's or a checkout project's + /// pyproject.toml / hatch.toml is skipped, never opened blocking. + #[cfg(unix)] + #[test] + fn fifos_in_place_of_workspace_files_never_block() { + let (temp, dir) = layout(ROOT, None, "libs/lib1"); + let ws = temp.path().join("ws"); + std::fs::remove_file(ws.join("pyproject.toml")).unwrap(); + std::fs::create_dir_all(ws.join("apps/web")).unwrap(); + for fifo in [ + ws.join("pyproject.toml"), + ws.join("hatch.toml"), + ws.join("apps/web/pyproject.toml"), + ] { + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(status.success()); + } + assert_eq!(consumer(&dir, MEMBER, "six"), None); + } + + #[test] + fn fnmatch_reads_python_patterns() { + assert!(fnmatch("pkg-*", "pkg-a")); + assert!(fnmatch("*", "a/b")); + assert!(fnmatch("*a*b", "xxaxxb")); + assert!(!fnmatch("*a*b", "xxaxxbc")); + assert!(fnmatch("[!x]y", "ay")); + assert!(!fnmatch("[!x]y", "xy")); + assert!(fnmatch("[a-c]", "b")); + assert!(fnmatch("[]]", "]")); + assert!(fnmatch("a[", "a[")); + assert!(!fnmatch("a?", "a")); + assert!(fnmatch("a**", "a")); + // Many stars against a long miss stays fast. + let text = "x".repeat(200); + assert!(!fnmatch("*/*/*/*/*/*/*/*/legacy", &text)); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 127f2c121..06ea44531 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -40,3 +40,4 @@ pub(crate) mod uv_workspace; pub(crate) mod workspace_globs; pub mod hatch; +pub mod hatch_workspace; diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index ec7f35b47..c800a168b 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -42,6 +42,7 @@ pub(super) async fn load( let files = read_files(root).await?; require_pip_installer()?; require_expandable_root(root)?; + require_no_raw_workspace(root, &files, name).await?; let prefix = format!("{{root:uri}}/.socket/vendor/pypi/{uuid}/"); let state = super::state::load_state_shared(root) .await @@ -117,6 +118,7 @@ pub(super) async fn preflight(root: &Path, name: &str) -> Result<(), Failure> { let files = read_files(root).await?; require_pip_installer()?; require_expandable_root(root)?; + require_no_raw_workspace(root, &files, name).await?; hatch::require_pip_installer(&files).map_err(|error| ("pypi_hatch_unsupported", error))?; if hatch::has_environment_dependency(&files, name) { require_environment_context_support(root).await?; @@ -138,6 +140,42 @@ fn require_expandable_root(root: &Path) -> Result<(), Failure> { } } +/// Refuse `name` in this project's `[project]` dependencies or optional +/// dependencies when a Hatch workspace lists the project (#505): Hatch +/// reads a member's static dependencies raw, so the workspace root's +/// environments would get an unexpanded `{root:uri}` and fail every sync. +async fn require_no_raw_workspace( + root: &Path, + files: &BTreeMap, + name: &str, +) -> Result<(), Failure> { + let Some(pyproject) = files.get(hatch::HATCH_FILES[0]).cloned() else { + return Ok(()); + }; + let vendored = pyproject.contains("{root:uri}/.socket/vendor/"); + let (member, owned) = (root.to_path_buf(), name.to_owned()); + let consumer = crate::utils::fs::run_blocking(move || { + crate::utils::hatch_workspace::raw_consumer(&member, &pyproject, &owned) + }) + .await; + match consumer { + Some(consumer) => Err(( + "pypi_hatch_unsupported", + format!( + "this project is a member of the Hatch workspace at {} (environment {:?}), which reads this project's dependencies and optional dependencies, {name} among them, without expanding {{root:uri}}, so a vendored reference would break that workspace's environments; use hosted or agent mode{}", + consumer.root.display(), + consumer.env, + if vendored { + " (undo references an earlier run vendored here with `socket-patch vendor --revert`)" + } else { + "" + }, + ), + )), + None => Ok(()), + } +} + fn require_pip_installer() -> Result<(), Failure> { if std::env::var("HATCH_ENV_TYPE_VIRTUAL_UV_PATH").is_ok_and(|path| !path.is_empty()) { return Err(("pypi_hatch_unsupported", "vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into())); @@ -516,6 +554,71 @@ mod tests { } } + /// #505: `load` and `preflight` refuse, before writing, a `[project]` + /// dependency or optional dependency of a member that a Hatch workspace + /// in an ancestor pyproject.toml or hatch.toml lists, since Hatch reads + /// those raw. + #[tokio::test] + async fn member_consumed_by_a_hatch_workspace_is_refused() { + let member = + "[project]\nname = \"lib1\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\ + [project.optional-dependencies]\nsec = [\"one==1\"]\n\ + [tool.hatch.envs.default]\ndependencies = [\"two==2\"]\n"; + let root = "[project]\nname = \"app\"\nversion = \"0.1.0\"\n"; + for (pyproject, hatch, refused) in [ + ( + format!("{root}[tool.hatch.envs.default.workspace]\nmembers = [\"libs/*\"]\n"), + None, + &["six", "one"][..], + ), + ( + format!( + "{root}[tool.hatch.envs.default.workspace]\n\ + members = [{{ path = \"libs/lib1\", features = [\"sec\"] }}]\n" + ), + None, + &["six", "one"][..], + ), + ( + root.to_string(), + Some("[envs.default.workspace]\nmembers = [\"libs/lib1\"]\n"), + &["six", "one"][..], + ), + ] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join(".git")).unwrap(); + std::fs::write(temp.path().join("pyproject.toml"), &pyproject).unwrap(); + if let Some(hatch) = hatch { + std::fs::write(temp.path().join("hatch.toml"), hatch).unwrap(); + } + let dir = temp.path().join("libs/lib1"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("pyproject.toml"), member).unwrap(); + for name in ["six", "one"] { + let load = load(&dir, name, "1", UUID).await.map(|_| ()); + let preflight = preflight(&dir, name).await; + if refused.contains(&name) { + let Err((code, detail)) = load else { + panic!("{pyproject}: {name} must be refused"); + }; + assert_eq!(code, "pypi_hatch_unsupported"); + assert!( + detail.contains("Hatch workspace") && detail.contains("{root:uri}"), + "{detail}" + ); + assert_eq!(preflight.map_err(|(code, _)| code), Err(code)); + } else { + assert!(load.is_ok(), "{pyproject}: {name}: {load:?}"); + assert!(preflight.is_ok(), "{pyproject}: {name}"); + } + } + assert_eq!( + std::fs::read_to_string(dir.join("pyproject.toml")).unwrap(), + member + ); + } + } + #[tokio::test] async fn recorded_pin_drift_missing_artifact_and_ledgerless_source() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 248089223..6ac4e078f 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -37,7 +37,17 @@ A range, transitive-only declaration, dynamic dependency metadata, custom environment plugin, source table or conditional override is refused before writing. Use agent mode (`scan --mode agent` + `socket-patch apply` in CI) for these shapes. Hosted PEP 735 groups are supported; vendored groups are refused because Hatch does not expand -`{root:uri}` within dependency groups. Unknown direct sources require an +`{root:uri}` within dependency groups. Vendoring a member of a Hatch 1.16+ +workspace is refused the same way when a workspace environment, declared +in a `pyproject.toml` or `hatch.toml` of an ancestor or of another project +in the same checkout (a root may list a sibling through `../`), lists the +member and the package is one of the member's static `[project]` +dependencies or optional dependencies. Hatch reads those strings raw, and +which extras it installs depends on the whole workspace (member +`features`, `[extra]` references from the root, environments, +templates, dependency groups and other members), so every optional +dependency of a listed member is treated as installed; hosted and agent +mode are unaffected (#505). Unknown direct sources require an explicit revert before patching. A hosted scan re-pins socket-patch's own earlier hosted reference (same package and version on the patch server) when the grant rotates or a newer patch supersedes it. A vendored scan