diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index abceebe5b..f400c0483 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -6,12 +6,14 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::python_lock::preserve_line_endings; use crate::vendor::common::{pep508_name, pyproject_dependency_specs, DeclTable}; +pub const PYPROJECT: &str = "pyproject.toml"; +pub const HATCH_TOML: &str = "hatch.toml"; /// The two documents the hatch planner reads and rewrites, in the order /// [`plan`] and [`rewrite_all`] parse them. -pub const HATCH_FILES: [&str; 2] = ["pyproject.toml", "hatch.toml"]; +pub const HATCH_FILES: [&str; 2] = [PYPROJECT, HATCH_TOML]; /// The first character of `root`'s path that Hatch's `{root:uri}` expands -/// into a broken file URL (#547). Hatchling's `path_to_uri` escapes only +/// into a broken file URL. Hatchling's `path_to_uri` escapes only /// the space, so pip percent-decodes a `%XX` escape, cuts the path at a /// `#` fragment or `?` query, and the PEP 508 parser stops at a `;`, a tab /// or any other whitespace or control character. Hatch builds the URL from @@ -43,14 +45,16 @@ pub fn root_uri_unsafe_char(root: &std::path::Path) -> Option { }) } +/// pyproject's `[tool.hatch]` table. +fn tool_hatch(document: &DocumentMut) -> Option<&Item> { + document.get("tool")?.get("hatch") +} + pub fn is_hatch(files: &BTreeMap) -> bool { - files.contains_key("hatch.toml") - || files.get("pyproject.toml").is_some_and(|text| { + files.contains_key(HATCH_TOML) + || files.get(PYPROJECT).is_some_and(|text| { text.parse::().is_ok_and(|document| { - document - .get("tool") - .and_then(|tool| tool.get("hatch")) - .is_some() + tool_hatch(&document).is_some() || document .get("build-system") .and_then(|build| build.get("build-backend")) @@ -68,22 +72,11 @@ pub fn is_hatch(files: &BTreeMap) -> bool { /// environment and a hatch.toml `lock-envs` overrides pyproject's (the rule /// [`environment_specs`] applies to dependencies). fn locks_environments(files: &BTreeMap) -> bool { - let external = parsed(files, HATCH_FILES[1]); - let project = parsed(files, HATCH_FILES[0]); - let key = |name: &str| -> Option { - if let Some(item) = external.as_ref().and_then(|doc| doc.get(name)) { - return Some(item.clone()); - } - project - .as_ref() - .and_then(|doc| doc.get("tool")) - .and_then(|tool| tool.get("hatch")) - .and_then(|hatch| hatch.get(name)) - .cloned() - }; - key("lock-envs").and_then(|item| item.as_bool()) == Some(true) + let external = parsed(files, HATCH_TOML); + let project = parsed(files, PYPROJECT); + let key = |name| merged_key(project.as_ref(), external.as_ref(), name).1; + key("lock-envs").and_then(Item::as_bool) == Some(true) || key("envs") - .as_ref() .and_then(Item::as_table_like) .is_some_and(|envs| { envs.iter().any(|(_, env)| { @@ -93,7 +86,7 @@ fn locks_environments(files: &BTreeMap) -> bool { }) } -/// Whether `path` is a PEP 751 lock Hatch derives from pyproject (#479): +/// Whether `path` is a PEP 751 lock Hatch derives from pyproject: /// a `pylock.toml` / `pylock..toml` in a Hatch project whose /// environments are locked. Hatch regenerates it from pyproject whenever /// the dependency hash changes, so it is not an independent install @@ -129,7 +122,7 @@ pub(crate) fn dependency_specs<'d>( .into_iter() .flat_map(pyproject_dependency_specs) .map(|(table, spec)| HatchSpec { - file: HATCH_FILES[0], + file: PYPROJECT, table, spec, }) @@ -138,22 +131,19 @@ 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>( +/// The Hatch setting `key` and the [`HATCH_FILES`] entry holding it: a +/// hatch.toml top-level key replaces pyproject's `[tool.hatch]` key of the +/// same name, whatever its value. +pub(crate) fn merged_key<'d>( pyproject: Option<&'d DocumentMut>, hatch_toml: Option<&'d DocumentMut>, + key: &str, ) -> (&'static str, Option<&'d Item>) { - match hatch_toml.filter(|d| d.contains_key("envs")) { - Some(doc) => (HATCH_FILES[1], doc.get("envs")), + match hatch_toml.and_then(|d| d.get(key)) { + Some(item) => (HATCH_TOML, Some(item)), None => ( - HATCH_FILES[0], - pyproject - .and_then(|d| d.get("tool")) - .and_then(|t| t.get("hatch")) - .and_then(|h| h.get("envs")), + PYPROJECT, + pyproject.and_then(tool_hatch).and_then(|h| h.get(key)), ), } } @@ -166,7 +156,7 @@ fn environment_specs<'d>( pyproject: Option<&'d DocumentMut>, hatch_toml: Option<&'d DocumentMut>, ) -> Vec> { - let (file, envs) = environments(pyproject, hatch_toml); + let (file, envs) = merged_key(pyproject, hatch_toml, "envs"); envs.and_then(Item::as_table_like) .into_iter() .flat_map(|envs| envs.iter()) @@ -191,8 +181,8 @@ fn parsed(files: &BTreeMap, file: &str) -> Option { } pub fn has_environment_dependency(files: &BTreeMap, name: &str) -> bool { - let external = parsed(files, HATCH_FILES[1]); - let project = parsed(files, HATCH_FILES[0]); + let external = parsed(files, HATCH_TOML); + let project = parsed(files, PYPROJECT); environment_specs(project.as_ref(), external.as_ref()) .iter() .any(|s| canonicalize_pypi_name(pep508_name(s.spec)) == canonicalize_pypi_name(name)) @@ -294,6 +284,14 @@ struct Target<'a> { edits: Vec, } +impl Target<'_> { + /// A vendored wheel's `{root:uri}` reference, which only Hatch's pip + /// installer and the tables Hatch formats can take. + fn root_relative(&self) -> bool { + self.url.starts_with("{root:uri}") + } +} + /// The dependencies one walk of the Hatch documents rewrites, at most one /// per canonical name. Requirements of different names never touch each /// other's strings, so each target's outcome is the one a [`plan`] of it @@ -310,7 +308,35 @@ struct Round<'a> { uv_sources: HashSet, } -impl Round<'_> { +impl<'a> Round<'a> { + /// A round over `targets` (canonical name, version, url), whose names + /// are distinct. + fn new(targets: impl IntoIterator) -> Self { + let targets: Vec> = targets + .into_iter() + .map(|(name, version, url)| Target { + name, + version, + url, + error: None, + matched: 0, + project_matched: 0, + edits: Vec::new(), + }) + .collect(); + let index = targets + .iter() + .enumerate() + .map(|(index, target)| (target.name.clone(), index)) + .collect(); + Round { + targets, + index, + apply: false, + uv_sources: HashSet::new(), + } + } + /// A structural error stops the walk, as it stops a single [`plan`]: /// every target that has not already failed fails with it. fn fail_rest(&mut self, error: &str) { @@ -376,7 +402,7 @@ fn rewrite_array( } fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), String> { - let file = HATCH_FILES[0]; + let file = PYPROJECT; if let Some(project) = document.get_mut("project") { if project .get("dynamic") @@ -420,7 +446,7 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), { for index in rewrite_array(dependencies, round, file, true)? { let target = &mut round.targets[index]; - if target.url.starts_with("{root:uri}") { + if target.root_relative() { target.error.get_or_insert_with(|| "Hatch does not expand root placeholders in dependency groups; use environment dependencies or agent mode".into()); } } @@ -429,7 +455,7 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), Ok(()) } -/// #673: Hatch's uv installer runs `uv pip install -e .`, which resolves a +/// Hatch's uv installer runs `uv pip install -e .`, which resolves a /// project requirement through `[tool.uv.sources]` instead of its direct /// reference: a url or path source installs the unpatched copy, a git /// source makes every install fail on conflicting URLs. Environment @@ -477,7 +503,7 @@ fn uv_source_names( .is_some_and(|table| table.contains_key("index")), }; documents - .get(HATCH_FILES[0]) + .get(PYPROJECT) .and_then(|document| document.get("tool")) .and_then(|tool| tool.get("uv")) .and_then(|uv| uv.get("sources")) @@ -533,12 +559,15 @@ fn walk( documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Round, ) -> Result<(), String> { - if let Some(project) = documents.get_mut(HATCH_FILES[0]) { + if let Some(project) = documents.get_mut(PYPROJECT) { rewrite_project(project, round)?; } // Hatch merges external configuration by top-level key, not recursively. - let external_keys = external_keys(documents); - if let Some(document) = documents.get_mut(HATCH_FILES[0]) { + let external_keys: Vec = documents + .get(HATCH_TOML) + .map(|d| d.iter().map(|(k, _)| k.to_owned()).collect()) + .unwrap_or_default(); + if let Some(document) = documents.get_mut(PYPROJECT) { if let Some(hatch) = document.get_mut("tool").and_then(|tool| { tool.as_table_like_mut() .and_then(|table| table.get_mut("hatch")) @@ -549,7 +578,7 @@ fn walk( table.remove(key); } } - rewrite_environments(&mut effective, round, HATCH_FILES[0])?; + rewrite_environments(&mut effective, round, PYPROJECT)?; if round.apply { if let Some(table) = effective.as_table_like() { for (key, item) in table.iter() { @@ -559,9 +588,9 @@ fn walk( } } } - if let Some(document) = documents.get_mut(HATCH_FILES[1]) { + if let Some(document) = documents.get_mut(HATCH_TOML) { let mut hatch = Item::Table(document.as_table().clone()); - rewrite_environments(&mut hatch, round, HATCH_FILES[1])?; + rewrite_environments(&mut hatch, round, HATCH_TOML)?; if round.apply { *document.as_table_mut() = hatch .as_table() @@ -572,23 +601,20 @@ fn walk( Ok(()) } -fn external_keys(documents: &BTreeMap<&'static str, DocumentMut>) -> Vec { +/// Whether hatch.toml carries `metadata`, which then holds the +/// direct-reference permission instead of pyproject. +fn external_metadata(documents: &BTreeMap<&'static str, DocumentMut>) -> bool { documents - .get(HATCH_FILES[1]) - .map(|d| d.iter().map(|(k, _)| k.to_owned()).collect()) - .unwrap_or_default() + .get(HATCH_TOML) + .is_some_and(|d| d.contains_key("metadata")) } /// Run one [`Round`] over `documents`: check, then apply what passed. fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Round) { - if round - .targets - .iter() - .any(|t| t.url.starts_with("{root:uri}")) - { + if round.targets.iter().any(Target::root_relative) { if let Err(error) = require_pip_installer_documents(documents.values()) { for target in &mut round.targets { - if target.url.starts_with("{root:uri}") { + if target.root_relative() { target.error = Some(error.clone()); } } @@ -611,7 +637,7 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro // A project-table match needs Hatch's direct-reference permission, which // no requirement rewrite touches: whether it can be granted is judged // once, on a copy. - let external = external_keys(documents).iter().any(|key| key == "metadata"); + let external = external_metadata(documents); let permission_file = permission_file(external); let needs_permission = |target: &Target| target.error.is_none() && target.project_matched > 0; if round.targets.iter().any(needs_permission) { @@ -674,9 +700,9 @@ fn permission_setting(document: &DocumentMut, external: bool) -> Option { fn permission_file(external: bool) -> &'static str { if external { - HATCH_FILES[1] + HATCH_TOML } else { - HATCH_FILES[0] + PYPROJECT } } @@ -741,27 +767,13 @@ pub fn rewrite_all(files: &BTreeMap, deps: &[HatchDep]) -> Hatch let mut steps: Vec, String>> = deps.iter().map(|_| Ok(Vec::new())).collect(); for positions in rounds { - let mut round = Round { - targets: positions - .iter() - .map(|&position| Target { - name: names[position].clone(), - version: deps[position].version, - url: deps[position].url, - error: None, - matched: 0, - project_matched: 0, - edits: Vec::new(), - }) - .collect(), - index: positions - .iter() - .enumerate() - .map(|(index, &position)| (names[position].clone(), index)) - .collect(), - apply: false, - uv_sources: HashSet::new(), - }; + let mut round = Round::new(positions.iter().map(|&position| { + ( + names[position].clone(), + deps[position].version, + deps[position].url, + ) + })); run_round(&mut documents, &mut round); for (position, target) in positions.into_iter().zip(round.targets) { steps[position] = match target.error { @@ -780,6 +792,8 @@ pub fn rewrite_all(files: &BTreeMap, deps: &[HatchDep]) -> Hatch HatchBatch { files, steps } } +/// The whole permission file before and after granting the +/// direct-reference permission alone. pub struct HatchPermission { pub file: String, pub original: String, @@ -857,7 +871,7 @@ pub(crate) fn drop_direct_reference_permission(doc: &mut DocumentMut, keys: &[&s } pub fn has_project_direct_references(files: &BTreeMap) -> bool { - let Some(document) = parsed(files, HATCH_FILES[0]) else { + let Some(document) = parsed(files, PYPROJECT) else { return false; }; pyproject_dependency_specs(&document) @@ -873,26 +887,14 @@ pub fn has_project_direct_references(files: &BTreeMap) -> bool { /// (`installer = "uv"` or a non-empty `uv-path`): uv does not enforce local /// wheel fragment hashes, so a vendored wheel needs pip. pub fn require_pip_installer(files: &BTreeMap) -> Result<(), String> { - let mut documents = Vec::new(); - for file in HATCH_FILES { - if let Some(text) = files.get(file) { - documents.push( - text.parse::() - .map_err(|error| format!("{file}: {error}"))?, - ); - } - } - require_pip_installer_documents(documents.iter()) + require_pip_installer_documents(parse_documents(files)?.values()) } fn require_pip_installer_documents<'a>( documents: impl Iterator, ) -> Result<(), String> { for document in documents { - let hatch = document - .get("tool") - .and_then(|tool| tool.get("hatch")) - .unwrap_or(document.as_item()); + let hatch = tool_hatch(document).unwrap_or(document.as_item()); if hatch .get("envs") .and_then(Item::as_table_like) @@ -920,20 +922,7 @@ pub fn plan( ) -> Result { let name = canonicalize_pypi_name(name); let mut documents = parse_documents(files)?; - let mut round = Round { - targets: vec![Target { - name: name.clone(), - version, - url, - error: None, - matched: 0, - project_matched: 0, - edits: Vec::new(), - }], - index: HashMap::from([(name, 0)]), - apply: false, - uv_sources: HashSet::new(), - }; + let mut round = Round::new([(name, version, url)]); run_round(&mut documents, &mut round); let target = round.targets.pop().ok_or("missing Hatch target")?; if let Some(error) = target.error { @@ -942,9 +931,7 @@ pub fn plan( // `run_round` granted the permission in the rewritten documents; the // vendored ledger also records it alone over the original text. let permission = if target.project_matched > 0 { - let external = external_keys(&documents) - .iter() - .any(|key| key == "metadata"); + let external = external_metadata(&documents); let file = permission_file(external); let original = files[file].clone(); let mut permission_only = original @@ -969,7 +956,7 @@ pub fn plan( mod tests { use super::*; - /// #479 (Bugbot): Hatch merges hatch.toml over `[tool.hatch]` by + /// Hatch merges hatch.toml over `[tool.hatch]` by /// top-level key, so a shadowed `locked` / `lock-envs` does not make a /// pylock Hatch-derived, and one set in hatch.toml does. #[test] @@ -1054,7 +1041,7 @@ mod tests { } } - /// #650: a superseding patch (a new patch uuid for the same package and + /// A superseding patch (a new patch uuid for the same package and /// version) re-pins socket-patch's OWN earlier hosted direct reference — /// in `pyproject.toml` (project and env tables) and in `hatch.toml` — /// instead of refusing it as an unknown user source. @@ -1105,7 +1092,7 @@ mod tests { } } - /// #475: Hatch (via pip/uv) selects a release under PEP 440, so + /// Hatch (via pip/uv) selects a release under PEP 440, so /// `==1.26.18.0` and `==01.26.18` are exact pins of 1.26.18. #[test] fn pep440_equivalent_pins_are_exact_declarations() { @@ -1194,7 +1181,7 @@ mod tests { } } - /// #673: Hatch's uv installer runs `uv pip install -e .`, which applies + /// Hatch's uv installer runs `uv pip install -e .`, which applies /// the project's `[tool.uv.sources]` over a direct reference in the /// project tables, so a url, path or git source would keep the /// unpatched copy (or break every install). Environment dependencies @@ -1253,7 +1240,7 @@ mod tests { // file setting; an empty source array is not index-only. let sourced = |source: &str| -> BTreeMap<&'static str, DocumentMut> { [( - HATCH_FILES[0], + PYPROJECT, format!("[project]\ndependencies = [\"six==1.16.0\"]\n[tool.uv.sources]\nsix = {source}\n") .parse() .unwrap(), @@ -1447,7 +1434,7 @@ mod tests { const HOSTED_A: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/aaaaaaaa-0000-4000-8000-000000000001/six-1.16.0-py2.py3-none-any.whl#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const HOSTED_B: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/22222222-2222-4222-8222-222222222222/aaaaaaaa-0000-4000-8000-000000000004/six-1.16.0-py2.py3-none-any.whl#sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - /// #1471: one batch over many deps gives exactly what rewriting them + /// One batch over many deps gives exactly what rewriting them /// one at a time gives — successes, per-dependency refusals (inexact /// pin, no declaration), extras and markers, a name repeated with a /// superseding url (runs after the first, in input order), and a name @@ -1524,7 +1511,7 @@ mod tests { assert!(empty.files.is_empty() && empty.steps.is_empty()); } - /// #1471: hatch.toml's `envs` replaces pyproject's, a refusal found in + /// hatch.toml's `envs` replaces pyproject's, a refusal found in /// a later table leaves that dep's earlier declarations untouched, and /// a structural refusal fails every dep not already refused (each with /// the message a lone `rewrite` gives it). @@ -1627,7 +1614,7 @@ mod tests { .all(|step| step.as_ref().unwrap_err().starts_with("hatch.toml:"))); } - /// #1471: a re-run whose only change is restoring the direct-reference + /// A re-run whose only change is restoring the direct-reference /// permission the user removed still rewrites the file and records the /// permission edit, and an already-granted permission records nothing. #[test] @@ -1667,7 +1654,7 @@ mod tests { assert_eq!(edits[1].original, "allow-direct-references = false"); } - /// #1471: when no dep is wired, nothing is written, even for a + /// When no dep is wired, nothing is written, even for a /// hatch.toml whose render is not byte-identical (mixed line endings). #[test] fn batch_rewrite_writes_nothing_when_every_dep_is_refused() { @@ -1686,7 +1673,7 @@ mod tests { assert!(rewrite_all(&mixed, &[]).files.is_empty()); } - /// #1471: vendored `{root:uri}` deps keep their pip-installer and + /// Vendored `{root:uri}` deps keep their pip-installer and /// dependency-group refusals inside a batch, beside hosted deps that /// still wire. #[test] diff --git a/crates/socket-patch-core/src/utils/hatch_workspace.rs b/crates/socket-patch-core/src/utils/hatch_workspace.rs index 9068775c0..22807ff17 100644 --- a/crates/socket-patch-core/src/utils/hatch_workspace.rs +++ b/crates/socket-patch-core/src/utils/hatch_workspace.rs @@ -1,4 +1,4 @@ -//! Hatch workspaces (Hatch 1.16+, #505): an environment's +//! Hatch workspaces (Hatch 1.16+): 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 @@ -21,7 +21,7 @@ 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::hatch::{merged_key, HATCH_FILES, HATCH_TOML, PYPROJECT}; use crate::utils::repo_root; use crate::vendor::common::pep508_name; @@ -98,16 +98,17 @@ fn absolute(path: &Path) -> Option { 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; + if !lexical.join(".git").exists() { + for dir in lexical.ancestors().skip(1) { + 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 { @@ -162,9 +163,9 @@ fn parse(dir: &Path, file: &str) -> Option { /// 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 external = parse(root, HATCH_TOML); + let project = parse(root, PYPROJECT); + let (_, envs) = merged_key(project.as_ref(), external.as_ref(), "envs"); let Some(envs) = envs.and_then(Item::as_table_like) else { return Vec::new(); }; @@ -201,29 +202,27 @@ fn listing_environments(root: &Path, member: &Path) -> Vec { 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 +/// context fields Hatch formats (`{root}`, `{home}`) are filled in (a spec +/// with any other field lists nothing), 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 { + let mut spec = spec.replace("{root}", &root.to_string_lossy()); + if spec.contains("{home}") { + let Some(home) = crate::utils::fs::home_dir() else { + return Vec::new(); + }; + spec = spec.replace("{home}", &home.to_string_lossy()); + } + if spec.is_empty() || spec.contains('{') { return Vec::new(); - }; + } let Some(target) = absolute(&root.join(spec)) else { return Vec::new(); }; @@ -411,7 +410,7 @@ mod tests { format!("{ROOT}[tool.hatch.envs.default.workspace]\nmembers = {members}\n") } - /// #505: a member listed by a glob, a plain path, a `{path = ..}` + /// 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. @@ -454,7 +453,7 @@ mod tests { assert_eq!(consumer(&dir, MEMBER, "six"), None); } - /// #505 (review): a workspace root beside the member, listing it + /// 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() { @@ -469,7 +468,7 @@ mod tests { assert_eq!(consumer(&dir, MEMBER, "six"), None); } - /// #505 and review: once a workspace lists the member, every optional + /// 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); @@ -492,7 +491,7 @@ mod tests { } } - /// #505: a workspace declared in hatch.toml counts, and a hatch.toml + /// 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() { @@ -543,7 +542,7 @@ mod tests { assert_eq!(consumer(&top, MEMBER, "six"), None); } - /// Bugbot: a member Hatch reaches through a symlinked directory is + /// A member Hatch reaches through a symlinked directory is /// still listed, whichever spelling the scan was given. #[cfg(unix)] #[test] diff --git a/crates/socket-patch-core/src/utils/http.rs b/crates/socket-patch-core/src/utils/http.rs index a7e03d580..a113d9570 100644 --- a/crates/socket-patch-core/src/utils/http.rs +++ b/crates/socket-patch-core/src/utils/http.rs @@ -201,9 +201,6 @@ impl std::fmt::Display for ReadCappedError { /// an over-large declared `Content-Length` and an actual stream that /// exceeds the cap mid-flight. `what` names the payload in error messages /// ("vendor package", "release archive", …). -/// -/// Hoisted from `api/client.rs` so the self-update downloader shares the -/// exact cap semantics the vendor/artifact fetches already have. pub(crate) async fn read_capped( resp: reqwest::Response, max: u64, @@ -246,12 +243,31 @@ pub(crate) async fn read_capped_typed( #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - async fn get(server: &MockServer, route: &str) -> reqwest::Response { - reqwest::Client::new() - .get(format!("{}{route}", server.uri())) + /// Serve one raw HTTP/1.1 response on loopback and return a response + /// whose body is still unread. + async fn respond_raw(raw: &'static [u8]) -> reqwest::Response { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut tcp, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match tcp.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + } + let _ = tcp.write_all(raw).await; + let _ = tcp.shutdown().await; + }); + reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://{addr}/")) .send() .await .unwrap() @@ -259,27 +275,54 @@ mod tests { #[tokio::test] async fn declared_content_length_over_cap_is_refused() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/big")) - .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 64])) - .mount(&server) - .await; - let resp = get(&server, "/big").await; - let err = read_capped(resp, 16, "test payload").await.unwrap_err(); - assert!(err.contains("too large"), "{err}"); - assert!(err.contains("test payload"), "{err}"); + let resp = + respond_raw(b"HTTP/1.1 200 OK\r\ncontent-length: 64\r\nconnection: close\r\n\r\n") + .await; + assert_eq!( + read_capped_typed(resp, 16, "test payload").await, + Err(ReadCappedError::CapExceeded( + "test payload too large: declared 64 bytes > 16 cap".into() + )) + ); + } + + /// A chunked body declares no length, so only the streamed count + /// catches it. + #[tokio::test] + async fn streamed_body_over_cap_is_refused_mid_stream() { + let resp = respond_raw( + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n\ + a\r\n0123456789\r\na\r\n0123456789\r\n0\r\n\r\n", + ) + .await; + assert_eq!(resp.content_length(), None); + assert_eq!( + read_capped_typed(resp, 16, "test payload").await, + Err(ReadCappedError::CapExceeded( + "test payload exceeded 16-byte cap mid-stream".into() + )) + ); + } + + #[tokio::test] + async fn body_cut_short_of_its_content_length_is_truncated() { + let resp = + respond_raw(b"HTTP/1.1 200 OK\r\ncontent-length: 10\r\nconnection: close\r\n\r\nhello") + .await; + let err = read_capped_typed(resp, 16, "test payload") + .await + .unwrap_err(); + assert!( + matches!(&err, ReadCappedError::Truncated(m) if m.starts_with("error reading test payload body: ")), + "{err:?}" + ); } #[tokio::test] async fn body_within_cap_reads_fully() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/ok")) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec())) - .mount(&server) - .await; - let resp = get(&server, "/ok").await; + let resp = + respond_raw(b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\nconnection: close\r\n\r\nhello") + .await; assert_eq!( read_capped(resp, 16, "test payload").await.unwrap(), b"hello" @@ -288,21 +331,20 @@ mod tests { #[tokio::test] async fn exact_cap_boundary_is_allowed() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/edge")) - .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![7u8; 16])) - .mount(&server) - .await; - let resp = get(&server, "/edge").await; - assert_eq!( - read_capped(resp, 16, "test payload").await.unwrap().len(), - 16 - ); + for raw in [ + &b"HTTP/1.1 200 OK\r\ncontent-length: 16\r\nconnection: close\r\n\r\n\ + 0123456789abcdef"[..], + &b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n\ + 10\r\n0123456789abcdef\r\n0\r\n\r\n"[..], + ] { + let resp = respond_raw(raw).await; + assert_eq!( + read_capped_typed(resp, 16, "test payload").await.unwrap(), + b"0123456789abcdef" + ); + } } - // ── Trust roots (#1107) ─────────────────────────────────────────── - // // `tests/tls-ca/` holds a private CA (`ca.pem`) and a leaf for // 127.0.0.1 it signed (`leaf.pem` + `leaf.key`, P-256, valid for a // century): the shape of a TLS-inspecting proxy's re-signed traffic. @@ -480,11 +522,10 @@ mod tests { assert_eq!(resp.status(), 200, "the usable root still verifies"); } - /// The production builder accepts the shared rustls config (reqwest - /// refuses a config from a different rustls build at `build()`), and - /// building clients never reads the platform store. + /// reqwest refuses a rustls config from a different rustls build at + /// `build()`, so this fails if the shared config stops matching. #[test] - fn production_builder_builds() { + fn production_builder_accepts_the_shared_tls_config() { client_builder().build().unwrap(); client_builder().build().unwrap(); } diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 6d13f9fbb..c3bfbe2df 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -12,6 +12,8 @@ pub mod fs; pub(crate) mod go_env; pub mod go_installed_tools; pub mod group_commit; +pub mod hatch; +pub(crate) mod hatch_workspace; pub(crate) mod http; pub(crate) mod line_endings; pub mod lock_fragments; @@ -40,6 +42,3 @@ pub(crate) mod toml_edit_ext; pub mod uri; 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 2e3e751c3..672381616 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -149,7 +149,7 @@ async fn require_no_raw_workspace( files: &BTreeMap, name: &str, ) -> Result<(), Failure> { - let Some(pyproject) = files.get(hatch::HATCH_FILES[0]).cloned() else { + let Some(pyproject) = files.get(hatch::PYPROJECT).cloned() else { return Ok(()); }; let vendored = pyproject.contains("{root:uri}/.socket/vendor/"); @@ -418,7 +418,7 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R /// recorded before them. fn permission_held_by_live_references(files: &BTreeMap) -> bool { files - .get(hatch::HATCH_FILES[0]) + .get(hatch::PYPROJECT) .and_then(|text| crate::formats::text::strip_bom(text).parse().ok()) .is_some_and(|document: toml_edit::DocumentMut| { crate::vendor::common::pyproject_dependency_specs(&document) @@ -437,7 +437,7 @@ fn permission_held_by_live_references(files: &BTreeMap) -> bool fn drop_owned_permission(text: &str, file: &str) -> Option { let (bom, body) = crate::formats::text::split_bom(text); let mut document = body.parse::().ok()?; - let keys = hatch::permission_keys(file == hatch::HATCH_FILES[1]); + let keys = hatch::permission_keys(file == hatch::HATCH_TOML); hatch::drop_direct_reference_permission(&mut document, keys) .then(|| crate::utils::python_lock::preserve_line_endings(text, format!("{bom}{document}"))) }