From 3bc3bb569d5235781aee5988310a166e07a761a1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 17 Sep 2026 17:58:08 -0400 Subject: [PATCH 1/5] Support hosted and vendored Hatch patches Wire exact Hatch requirements to hash-pinned wheels and guard repeat scans and rollback against configuration or artifact drift. Assisted-by: Codex:gpt-6-astra --- .../src/commands/scan/hosted.rs | 4 + .../src/patch/redirect/mod.rs | 96 +++++ .../src/patch/redirect/replay.rs | 26 +- crates/socket-patch-core/src/utils/hatch.rs | 370 ++++++++++++++++ crates/socket-patch-core/src/utils/mod.rs | 2 + crates/socket-patch-core/src/vendor/mod.rs | 2 + crates/socket-patch-core/src/vendor/pypi.rs | 74 +++- .../src/vendor/pypi_hatch.rs | 394 ++++++++++++++++++ .../socket-patch-core/src/vendor/pypi_lock.rs | 6 +- docs/testing/hatch.md | 41 ++ 10 files changed, 1003 insertions(+), 12 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/hatch.rs create mode 100644 crates/socket-patch-core/src/vendor/pypi_hatch.rs create mode 100644 docs/testing/hatch.md diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 2060cac0..f5e4c25a 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -28,6 +28,7 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "requirements.txt", "uv.lock", "pyproject.toml", + "hatch.toml", "Cargo.toml", "Cargo.lock", ".cargo/config.toml", @@ -1675,6 +1676,9 @@ pub(crate) async fn run_redirect_selected( .iter() .filter( |(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| { + if rewrite.hatch_uuids.contains(uuid) { + return rewrite.confirmed_hatch_uuids.contains(uuid); + } if rewrite.refused_pnpm_uuids.contains(uuid) { return false; } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d291c7b9..16c16e65 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -174,6 +174,8 @@ pub struct RewriteResult { /// An incomplete pnpm rewrite must not be confirmed by finding its URL /// in another instance, a comment, or another lockfile. pub refused_pnpm_uuids: std::collections::BTreeSet, + pub hatch_uuids: std::collections::BTreeSet, + pub confirmed_hatch_uuids: std::collections::BTreeSet, } /// Combined name as it appears in registry coordinates / lock keys. @@ -214,6 +216,7 @@ pub fn rewrite_registry_redirect_with_python_metadata( rewrite_yarn_berry(files, overrides, &mut result); rewrite_bun_lock(files, overrides, &mut result); rewrite_pypi_requirements(files, overrides, &mut result); + rewrite_hatch(files, overrides, &mut result); rewrite_uv_lock(files, overrides, python_metadata, &mut result); rewrite_cargo(files, overrides, &mut result); rewrite_composer_lock(files, overrides, &mut result); @@ -224,6 +227,61 @@ pub fn rewrite_registry_redirect_with_python_metadata( result } +fn rewrite_hatch( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + if !crate::utils::hatch::is_hatch(files) + || files.keys().any(|file| file.starts_with("requirements") && file.ends_with(".txt")) + || files.keys().any(|file| { + matches!( + file.as_str(), + "uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock" + ) || crate::utils::python_lock::is_python_lock_name(file) + }) + { + return; + } + let mut current = files.clone(); + for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + result.hatch_uuids.insert(dep.patch_uuid.clone()); + let Some(hash) = + dep.integrity.sha256.as_ref().filter(|hash| { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + else { + result.warnings.push(RewriteWarning { + code: "redirect_hatch_missing_sha256".into(), + detail: format!("{} has no valid wheel digest", dep.name), + }); + continue; + }; + let url = format!("{}#sha256={hash}", dep.artifact_url); + match crate::utils::hatch::rewrite(¤t, &dep.name, &dep.version, &url) { + Ok(edits) => { + result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone()); + for (path, new) in edits { + result.edits.push(FileEdit { + path: path.clone(), + kind: "redirect_hatch_document".into(), + action: "rewritten".into(), + key: Some(format!("{}@{}", dep.name, dep.version)), + original: current.get(&path).cloned().map(Value::String), + new: Some(Value::String(new.clone())), + }); + current.insert(path.clone(), new.clone()); + result.files.insert(path, new); + } + } + Err(detail) => result.warnings.push(RewriteWarning { + code: "redirect_hatch_unsupported".into(), + detail, + }), + } + } +} + // ── npm package-lock.json / npm-shrinkwrap.json ───────────────────────────── fn rewrite_npm_lock( files: &BTreeMap, @@ -13130,3 +13188,41 @@ mod python_lock_warning_tests { ); } } + +#[cfg(test)] +mod hatch_tests { + use super::*; + + fn patch() -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), name: "urllib3".into(), namespace: None, + version: "1.26.18".into(), token: String::new(), patch_uuid: "test-uuid".into(), + artifact_url: "https://patch.test/urllib3.whl".into(), berry_zip_url: None, + registry_override: None, integrity: Integrity {sha256: Some("a".repeat(64)), ..Default::default()}, + } + } + + #[test] + fn hatch_confirmation_ignores_inactive_sources_and_comments() { + let dep = patch(); + let files = [ + ("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}#sha256={}\"]\n", dep.artifact_url, "a".repeat(64))), + ("hatch.toml".into(), format!("[envs.default]\ndependencies=[\"urllib3>=1\"]\n# {}\n", dep.artifact_url)), + ].into_iter().collect(); + let result = rewrite_registry_redirect(&files, &[dep]); + assert!(result.hatch_uuids.contains("test-uuid")); + assert!(result.confirmed_hatch_uuids.is_empty()); + assert!(result.files.is_empty()); + assert!(result.warnings.iter().any(|warning| warning.code == "redirect_hatch_unsupported")); + } + + #[test] + fn hatch_confirmation_requires_success_and_reruns_stay_confirmed() { + let files = [("pyproject.toml".into(), "[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n".into())].into_iter().collect(); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.confirmed_hatch_uuids.contains("test-uuid")); + let second = rewrite_registry_redirect(&result.files, &[patch()]); + assert!(second.confirmed_hatch_uuids.contains("test-uuid")); + assert!(second.files.is_empty()); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index af29f644..eafc6d3d 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -52,6 +52,7 @@ enum Inverse { /// writers record an `original` that is a substring of `new` (the /// Cargo.toml insert variant, the maven version suffix). ReplaceFragment, + HatchDocument, /// action `added` with only `new` recorded: the redirect inserted the /// fragment into a pre-existing file, so the inverse removes it once /// (an absent fragment is the desired end state — no-op). @@ -86,9 +87,14 @@ enum Inverse { /// Gemfile.lock) revert together or not at all. fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { match kind { - "redirect_requirements_line" | "redirect_uv_lock_wheel" => ("pypi", Inverse::ReplaceFragment), + "redirect_requirements_line" | "redirect_uv_lock_wheel" => { + ("pypi", Inverse::ReplaceFragment) + } + "redirect_hatch_document" => ("pypi", Inverse::HatchDocument), "redirect_composer_dist" => ("composer", Inverse::ReplaceFragment), - "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => ("cargo", Inverse::ReplaceFragment), + "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { + ("cargo", Inverse::ReplaceFragment) + } "redirect_cargo_registry" => ( "cargo", if action == "added" { @@ -406,7 +412,7 @@ pub async fn revert_remaining_redirect_edits( refused_groups.insert(group); continue 'group; } - Inverse::ReplaceFragment => { + Inverse::ReplaceFragment | Inverse::HatchDocument => { let (Some(original), Some(new)) = (str_payload(&edit.original), str_payload(&edit.new)) else { @@ -433,6 +439,20 @@ pub async fn revert_remaining_redirect_edits( continue 'group; } }; + if inverse == Inverse::HatchDocument { + match crate::vendor::restore_python_document(&content, original, new) { + Ok((restored, false)) => { + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + _ => { + refuse(format!("{}: Hatch configuration drifted", edit.path), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + continue; + } // `new` before `original`: original may be a substring // of new (Cargo.toml insert, maven version suffix). if content.contains(new) { diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs new file mode 100644 index 00000000..f07e7f3e --- /dev/null +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -0,0 +1,370 @@ +use std::collections::BTreeMap; + +use toml_edit::{DocumentMut, Item, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::python_lock::preserve_line_endings; +use crate::vendor::common::pep508_name; + +pub fn is_hatch(files: &BTreeMap) -> bool { + files.contains_key("hatch.toml") + || files.get("pyproject.toml").is_some_and(|text| { + text.parse::().is_ok_and(|document| { + document + .get("tool") + .and_then(|tool| tool.get("hatch")) + .is_some() + || document + .get("build-system") + .and_then(|build| build.get("build-backend")) + .and_then(Item::as_str) + == Some("hatchling.build") + }) + }) +} + +fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result, String> { + let declared = pep508_name(spec); + if canonicalize_pypi_name(declared) != name { + return Ok(None); + } + if spec.contains(['\r', '\n']) { + return Err(format!("{name}: multiline requirements are unsupported")); + } + let mut rest = spec.trim_start()[declared.len()..].trim_start(); + let extras = if rest.starts_with('[') { + let end = rest + .find(']') + .ok_or_else(|| format!("{name}: invalid extras"))?; + let extras = &rest[..=end]; + rest = rest[end + 1..].trim_start(); + extras + } else { + "" + }; + let (constraint, marker) = rest + .split_once(';') + .map_or((rest, ""), |(left, right)| (left, right)); + let constraint: String = constraint + .trim() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + if constraint.starts_with('@') { + let existing = constraint.trim_start_matches('@'); + if existing == url { + return Ok(Some(spec.to_owned())); + } + return Err(format!( + "{name}: an existing direct source must be reverted before patching" + )); + } + if constraint != format!("=={version}") { + return Err(format!( + "{name}: Hatch patching requires an exact =={version} declaration" + )); + } + let suffix = if marker.trim().is_empty() { + String::new() + } else { + format!(" ; {}", marker.trim()) + }; + Ok(Some(format!("{declared}{extras} @ {url}{suffix}"))) +} + +fn rewrite_array(item: &mut Item, name: &str, version: &str, url: &str) -> Result { + let array = item + .as_array_mut() + .ok_or("dependency declarations must be arrays")?; + let mut matched = 0; + for entry in array.iter_mut() { + if entry.is_inline_table() { + continue; + } + let text = entry + .as_str() + .ok_or("dependency declarations must contain strings")?; + if let Some(rewritten) = replacement(text, name, version, url)? { + matched += 1; + if rewritten != text { + let decoration = entry.decor().clone(); + *entry = Value::from(rewritten); + *entry.decor_mut() = decoration; + } + } + } + Ok(matched) +} + +fn rewrite_project( + document: &mut DocumentMut, + name: &str, + version: &str, + url: &str, +) -> Result { + let mut matched = 0; + if let Some(project) = document.get_mut("project") { + if project + .get("dynamic") + .and_then(Item::as_array) + .is_some_and(|values| { + values + .iter() + .any(|v| matches!(v.as_str(), Some("dependencies" | "optional-dependencies"))) + }) + { + return Err("dynamic project dependencies require the install hook".into()); + } + if let Some(dependencies) = project + .as_table_like_mut() + .and_then(|table| table.get_mut("dependencies")) + { + matched += rewrite_array(dependencies, name, version, url)?; + } + if let Some(groups) = project + .as_table_like_mut() + .and_then(|table| table.get_mut("optional-dependencies")) + { + for (_, dependencies) in groups + .as_table_like_mut() + .ok_or("optional dependencies must be a table")? + .iter_mut() + { + matched += rewrite_array(dependencies, name, version, url)?; + } + } + } + if let Some(groups) = document.get_mut("dependency-groups") { + for (_, dependencies) in groups + .as_table_like_mut() + .ok_or("dependency groups must be a table")? + .iter_mut() + { + let group_matches = rewrite_array(dependencies, name, version, url)?; + if group_matches > 0 && url.starts_with("{root:uri}") { + return Err("Hatch does not expand root placeholders in dependency groups; use environment dependencies or the install hook".into()); + } + matched += group_matches; + } + } + Ok(matched) +} + +fn rewrite_environments( + hatch: &mut Item, + name: &str, + version: &str, + url: &str, +) -> Result { + if hatch.get("sources").is_some() || hatch.get("env").is_some() { + return Err("Hatch sources and environment plugins require the install hook".into()); + } + let mut matched = 0; + if let Some(environments) = hatch + .as_table_like_mut() + .and_then(|table| table.get_mut("envs")) + { + for (_, environment) in environments + .as_table_like_mut() + .ok_or("Hatch environments must be a table")? + .iter_mut() + { + if environment.get("sources").is_some() + || environment.get("overrides").is_some() + || environment + .get("type") + .and_then(Item::as_str) + .is_some_and(|kind| kind != "virtual") + { + return Err( + "Hatch sources, overrides and custom environments require the install hook" + .into(), + ); + } + for key in ["dependencies", "extra-dependencies"] { + if let Some(dependencies) = environment + .as_table_like_mut() + .and_then(|table| table.get_mut(key)) + { + matched += rewrite_array(dependencies, name, version, url)?; + } + } + } + } + Ok(matched) +} + +pub fn rewrite( + files: &BTreeMap, + name: &str, + version: &str, + url: &str, +) -> Result, String> { + let name = canonicalize_pypi_name(name); + let mut documents = BTreeMap::new(); + for file in ["pyproject.toml", "hatch.toml"] { + if let Some(text) = files.get(file) { + documents.insert( + file, + text.parse::() + .map_err(|error| format!("{file}: {error}"))?, + ); + } + } + let mut matched = 0; + let mut project_matched = 0; + if let Some(project) = documents.get_mut("pyproject.toml") { + project_matched = rewrite_project(project, &name, version, url)?; + matched += project_matched; + } + // Hatch merges external configuration by top-level key, not recursively. + 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.toml") { + if let Some(hatch) = document.get_mut("tool").and_then(|tool| { + tool.as_table_like_mut() + .and_then(|table| table.get_mut("hatch")) + }) { + let mut effective = hatch.clone(); + if let Some(table) = effective.as_table_like_mut() { + for key in &external_keys { + table.remove(key); + } + } + matched += rewrite_environments(&mut effective, &name, version, url)?; + if let Some(table) = effective.as_table_like() { + for (key, item) in table.iter() { + hatch[key] = item.clone(); + } + } + } + } + if let Some(document) = documents.get_mut("hatch.toml") { + let mut hatch = Item::Table(document.as_table().clone()); + matched += rewrite_environments(&mut hatch, &name, version, url)?; + *document.as_table_mut() = hatch + .as_table() + .ok_or("invalid Hatch configuration")? + .clone(); + } + if matched == 0 { + return Err(format!("{name}=={version} has no explicit Hatch declaration; transitive-only dependencies require the install hook")); + } + if project_matched > 0 { + if external_keys.iter().any(|key| key == "metadata") { + documents + .get_mut("hatch.toml") + .ok_or("missing Hatch configuration")?["metadata"]["allow-direct-references"] = + toml_edit::value(true); + } else { + documents + .get_mut("pyproject.toml") + .ok_or("missing project")?["tool"]["hatch"]["metadata"] + ["allow-direct-references"] = toml_edit::value(true); + } + } + Ok(documents + .into_iter() + .filter_map(|(name, document)| { + let original = &files[name]; + let new = preserve_line_endings(original, document.to_string()); + (new != *original).then_some((name.to_owned(), new)) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn files(text: &str) -> BTreeMap { + [("pyproject.toml".into(), text.into())] + .into_iter() + .collect() + } + + #[test] + fn exact_sources_extras_markers_and_newlines() { + for newline in ["\n", "\r\n"] { + let original = "[project]\ndependencies = [\"Urllib3[socks]==1.26.18 ; sys_platform == 'win32'\"] # keep\n[tool.hatch.envs.default]\ndependencies = [\"urllib3==1.26.18\"]\n".replace('\n', newline); + let inputs = files(&original); + let result = rewrite( + &inputs, + "urllib3", + "1.26.18", + "https://patch.test/one.whl#sha256=abc", + ) + .unwrap(); + let patched = &result["pyproject.toml"]; + assert!(patched.contains( + "Urllib3[socks] @ https://patch.test/one.whl#sha256=abc ; sys_platform == 'win32'" + )); + assert!(patched.contains("# keep")); + assert!(patched.contains("allow-direct-references = true")); + assert!(rewrite( + &result, + "urllib3", + "1.26.18", + "https://patch.test/one.whl#sha256=abc" + ) + .unwrap() + .is_empty()); + if newline == "\r\n" { + assert!(!patched.replace("\r\n", "").contains('\n')); + } + let (restored, drifted) = crate::vendor::restore_python_document( + &patched.replace("\r\n", "\n").replace('\n', "\r\n"), + &original, + patched, + ) + .unwrap(); + assert!(!drifted); + assert_eq!( + restored.replace("\r\n", "\n"), + original.replace("\r\n", "\n") + ); + } + } + + #[test] + fn external_tables_override_inline_and_metadata_permissions() { + let mut inputs = files("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3>=0\"]\n"); + inputs.insert("hatch.toml".into(), "[metadata]\nallow-direct-references=false\n[envs.default]\ndependencies=[\"urllib3==1.26.18\"]\n".into()); + let result = rewrite(&inputs, "urllib3", "1.26.18", "https://patch.test/a.whl").unwrap(); + assert!(result["pyproject.toml"].contains("urllib3>=0")); + assert!(!result["pyproject.toml"].contains("allow-direct-references")); + assert!(result["hatch.toml"].contains("allow-direct-references= true")); + } + + #[test] + fn unsupported_shapes_never_return_partial_edits() { + for text in [ + "[project]\ndependencies=[\"urllib3>=1\"]", + "[project]\ndependencies=[\"urllib3==1.26.18\", \"urllib3==2.0.0\"]", + "[project]\ndynamic=[\"dependencies\"]", + "[project]\ndependencies=[\"urllib3 @ https://foreign.test/x.whl\"]", + "[tool.hatch.envs.default]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default.overrides]\nplatform.windows.dependencies=[\"urllib3==1.26.18\"]", + "[tool.hatch.envs.default]\ndependencies=[\"requests==2.31.0\"]", + "[project]\ndependencies=[false]", + "[project]\ndependencies=[\"urllib3==1.26.18\\nidna==3.6\"]", + ] { + assert!(rewrite(&files(text), "urllib3", "1.26.18", "https://patch.test/a.whl").is_err(), "{text}"); + } + } + + #[test] + fn groups_accept_hosted_and_refuse_unexpanded_vendor_context() { + let inputs = files("[dependency-groups]\nqa=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\ndependency-groups=[\"qa\"]"); + assert!(rewrite(&inputs, "urllib3", "1.26.18", "https://patch.test/a.whl").is_ok()); + assert!(rewrite( + &inputs, + "urllib3", + "1.26.18", + "{root:uri}/.socket/vendor/a.whl" + ) + .unwrap_err() + .contains("does not expand")); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index d50be2dd..1959e8fc 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -18,3 +18,5 @@ pub use crate::api::date; pub use crate::crawlers::fuzzy_match; pub use crate::manifest::cleanup_blobs; pub use crate::telemetry; + +pub mod hatch; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 9da89589..0902e712 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -67,6 +67,8 @@ pub mod nuget_feed; pub mod pnpm_lock; pub mod pnpm_lock_legacy; pub mod pypi; +mod pypi_hatch; +pub(crate) use pypi_lock::restore_document as restore_python_document; mod pypi_lock; pub mod pypi_pdm; pub mod pypi_pipenv; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index d6c1c8f5..4bfb4d30 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -53,6 +53,7 @@ enum PypiFlavor { Pipenv, /// Plain `requirements.txt` (pip / `uv pip`) → line rewriting. Requirements, + Hatch, } impl PypiFlavor { @@ -64,6 +65,7 @@ impl PypiFlavor { PypiFlavor::Pdm => "pdm", PypiFlavor::Pipenv => "pipenv", PypiFlavor::Requirements => "requirements", + PypiFlavor::Hatch => "hatch", } } } @@ -304,6 +306,17 @@ async fn detect_pypi_flavor( if has_requirements { return Ok((PypiFlavor::Requirements, warnings)); } + if exists("hatch.toml").await + || has_pyproject_table("tool.hatch") + || pyproject_text.as_ref().is_some_and(|text| { + let files = [("pyproject.toml".to_owned(), text.clone())] + .into_iter() + .collect(); + crate::utils::hatch::is_hatch(&files) + }) + { + return Ok((PypiFlavor::Hatch, warnings)); + } if pyproject_text.is_some() { return Err(( "pypi_pyproject_only", @@ -328,6 +341,7 @@ enum WiringPlan { Uv(Box), PythonLocks(super::pypi_lock::PythonLocks), Requirements, + Hatch(super::pypi_hatch::HatchProject), Poetry(Box), Pdm(Box), Pipenv(Box), @@ -517,6 +531,16 @@ pub async fn vendor_pypi( WiringPlan::PythonLocks(project) } } + PypiFlavor::Hatch => { + match super::pypi_hatch::load(project_root, &canon_name, version, &record.uuid).await { + Ok(project) if project.in_sync => { + wired_pin = project.pin; + WiringPlan::InSync + } + Ok(project) => WiringPlan::Hatch(project), + Err((code, detail)) => return refused(code, detail), + } + } PypiFlavor::Requirements => { match preflight_requirements(project_root, &canon_name, version, &record.uuid).await { Ok(RequirementsTarget::InSync { pin }) => { @@ -594,7 +618,16 @@ pub async fn vendor_pypi( // not-installed re-run stays green). Missing artifact → rebuild the // wheel only; the wiring is correct and re-running it would re-record // live vendored fragments as pre-vendor originals. - if uuid_dir_has_wheel(&project_root.join(&uuid_dir_rel)).await || dry_run { + let artifact_present = if flavor == PypiFlavor::Hatch { + if let Some((wheel, _)) = &wired_pin { + project_root.join(wheel).is_file() + } else { + false + } + } else { + uuid_dir_has_wheel(&project_root.join(&uuid_dir_rel)).await + }; + if artifact_present || dry_run { return done( already_patched_result(base, Path::new(""), &record.files), None, @@ -684,6 +717,7 @@ pub async fn vendor_pypi( PypiFlavor::Pipenv => { "Pipfile.lock now resolves it from this single-platform wheel only" } + PypiFlavor::Hatch => "Hatch now installs this single-platform wheel only", PypiFlavor::Requirements => { "the requirements.txt path line installs on this platform only" } @@ -777,6 +811,16 @@ pub async fn vendor_pypi( ) .await .map(|wiring| (wiring, MetaSlot::None)), + WiringPlan::Hatch(project) => super::pypi_hatch::wire( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|wiring| (wiring, MetaSlot::None)), WiringPlan::Requirements => wire_requirements( project_root, &canon_name, @@ -929,6 +973,7 @@ async fn unwired_pypi_reference_clause(project_root: &Path, uuid: &str) -> Optio let needle = format!(".socket/vendor/pypi/{uuid}/"); let mut names: Vec = [ "pyproject.toml", + "hatch.toml", "uv.lock", "pylock.toml", "poetry.lock", @@ -1018,10 +1063,11 @@ async fn unwired_pypi_reference_clause(project_root: &Path, uuid: &str) -> Optio /// `VendorEntry::flavor` values the dispatch below knows how to revert — /// the set an UNWIRED entry must belong to (or be `None`) before it is /// treated as a reclaimable orphan. -const KNOWN_PYPI_FLAVORS: [&str; 6] = [ +const KNOWN_PYPI_FLAVORS: [&str; 7] = [ "uv", "python-lock", "requirements", + "hatch", "poetry", "pdm", "pipenv", @@ -1081,14 +1127,11 @@ pub async fn revert_pypi_opts( Some("python-lock") => { super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await } + Some("hatch") => super::pypi_hatch::revert(entry, project_root, dry_run).await, Some("requirements") => revert_requirements(entry, project_root, dry_run).await, - Some("poetry") => { - super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await - } + Some("poetry") => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, Some("pdm") => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, - Some("pipenv") => { - super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await - } + Some("pipenv") => super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await, other => { return RevertOutcome::failed(format!( "unknown pypi vendor flavor {other:?}; cannot revert" @@ -5637,3 +5680,18 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hash = "sha256:upstre .any(|warning| warning.code == "pypi_unmatched_lockfiles")); } } + +#[cfg(test)] +mod hatch_routing_tests { + use super::*; + + #[tokio::test] + async fn hatchling_with_requirements_preserves_pip_routing() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("pyproject.toml"), "[build-system]\nbuild-backend=\"hatchling.build\"\n[project]\ndependencies=[\"urllib3==1.26.18\"]\n").await.unwrap(); + tokio::fs::write(dir.path().join("requirements.txt"), "urllib3==1.26.18\n").await.unwrap(); + assert_eq!(detect_pypi_flavor(dir.path(), Some(("urllib3", "1.26.18"))).await.unwrap().0, PypiFlavor::Requirements); + tokio::fs::remove_file(dir.path().join("requirements.txt")).await.unwrap(); + assert_eq!(detect_pypi_flavor(dir.path(), Some(("urllib3", "1.26.18"))).await.unwrap().0, PypiFlavor::Hatch); + } +} diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs new file mode 100644 index 00000000..d34ae7ad --- /dev/null +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -0,0 +1,394 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use crate::utils::fs::{atomic_write_bytes_preserving_mode, is_symlink, read_regular_to_string}; +use crate::utils::hatch; +use crate::vendor::common::record; +use crate::vendor::state::{VendorEntry, WiringAction, WiringRecord}; +use crate::vendor::RevertOutcome; + +type Failure = (&'static str, String); +const KIND: &str = "hatch_document"; + +pub(super) struct HatchProject { + files: BTreeMap, + pub in_sync: bool, + pub pin: Option<(String, String)>, +} + +async fn read_files(root: &Path) -> Result, Failure> { + let mut files = BTreeMap::new(); + for file in ["pyproject.toml", "hatch.toml"] { + if is_symlink(&root.join(file)).await { + return Err(("pypi_hatch_symlink", format!("{file} is a symbolic link"))); + } + match read_regular_to_string(&root.join(file)).await { + Ok(text) => { + files.insert(file.to_owned(), text); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(("pypi_hatch_read_failed", format!("{file}: {error}"))), + } + } + Ok(files) +} + +pub(super) async fn load( + root: &Path, + name: &str, + version: &str, + uuid: &str, +) -> Result { + let files = read_files(root).await?; + let prefix = format!("{{root:uri}}/.socket/vendor/pypi/{uuid}/"); + let state = super::state::load_state(root) + .await + .map_err(|error| ("pypi_hatch_ledger_invalid", error.to_string()))?; + let entry = state + .entries + .values() + .find(|entry| entry.ecosystem == "pypi" && entry.uuid == uuid); + let pin = entry.map(|entry| (entry.artifact.path.clone(), entry.artifact.sha256.clone())); + if let Some((wheel, hash)) = &pin { + let relative_prefix = format!(".socket/vendor/pypi/{uuid}/"); + let leaf = wheel.strip_prefix(&relative_prefix).ok_or_else(|| { + ( + "pypi_hatch_pin_invalid", + "wheel path does not match patch".to_owned(), + ) + })?; + if leaf.contains(['/', '\\', '%', ':']) + || !leaf.ends_with(".whl") + || hash.len() != 64 + || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(( + "pypi_hatch_pin_invalid", + "invalid recorded wheel pin".into(), + )); + } + let path = root.join(wheel); + let mut component_path = root.to_path_buf(); + for component in wheel.split('/') { + component_path.push(component); + if is_symlink(&component_path).await { + return Err(( + "pypi_hatch_pin_invalid", + "wheel path contains a symlink".into(), + )); + } + } + if tokio::fs::try_exists(&path).await.unwrap_or(true) + && super::verify::file_sha256_hex(&path).await.as_deref() != Some(hash.as_str()) + { + return Err(( + "pypi_hatch_pin_invalid", + "wheel digest does not match the recorded artifact".into(), + )); + } + } + let url = pin + .as_ref() + .map(|(wheel, hash)| format!("{{root:uri}}/{wheel}#sha256={hash}")) + .unwrap_or_else(|| { + format!( + "{prefix}{name}-{version}-py3-none-any.whl#sha256={}", + "0".repeat(64) + ) + }); + let changes = hatch::rewrite(&files, name, version, &url) + .map_err(|error| ("pypi_hatch_unsupported", error))?; + let in_sync = pin.is_some() && changes.is_empty(); + Ok(HatchProject { + files, + in_sync, + pin, + }) +} + +async fn write_files( + root: &Path, + original: &BTreeMap, + edits: &BTreeMap, +) -> Result<(), String> { + let live = read_files(root).await.map_err(|(_, error)| error)?; + if live != *original { + return Err("Hatch configuration changed during patching".into()); + } + let mut written = Vec::new(); + for (file, new) in edits { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), new.as_bytes()).await + { + let mut failures = Vec::new(); + for file in written.into_iter().rev() { + if let Err(error) = + atomic_write_bytes_preserving_mode(&root.join(file), original[file].as_bytes()) + .await + { + failures.push(format!("{file}: {error}")); + } + } + return Err(format!( + "{file}: {error}; rollback failures: {}", + failures.join(", ") + )); + } + written.push(file); + } + Ok(()) +} + +pub(super) async fn wire( + project: &HatchProject, + root: &Path, + name: &str, + version: &str, + wheel: &str, + hash: &str, +) -> Result, Failure> { + let url = format!("{{root:uri}}/{wheel}#sha256={hash}"); + let edits = hatch::rewrite(&project.files, name, version, &url) + .map_err(|error| ("pypi_hatch_unsupported", error))?; + write_files(root, &project.files, &edits) + .await + .map_err(|error| ("pypi_hatch_write_failed", error))?; + Ok(edits + .into_iter() + .map(|(file, new)| { + record( + &file, + KIND, + WiringAction::Rewritten, + name, + project.files.get(&file).cloned(), + new, + ) + }) + .collect()) +} + +pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { + let files = match read_files(root).await { + Ok(files) => files, + Err((_, error)) => return RevertOutcome::failed(error), + }; + let mut edits = BTreeMap::new(); + for record in entry.wiring.iter().rev() { + if !matches!(record.file.as_str(), "pyproject.toml" | "hatch.toml") || record.kind != KIND { + return RevertOutcome::failed("invalid Hatch wiring record"); + } + let (Some(original), Some(new), Some(live)) = ( + record.original.as_ref().and_then(serde_json::Value::as_str), + record.new.as_ref().and_then(serde_json::Value::as_str), + files.get(&record.file), + ) else { + return RevertOutcome::failed("missing Hatch wiring document"); + }; + match super::pypi_lock::restore_document(live, original, new) { + Ok((restored, false)) => { + edits.insert(record.file.clone(), restored); + } + Ok((_, true)) => { + return RevertOutcome::failed(format!("{} changed since patching", record.file)) + } + Err(error) => return RevertOutcome::failed(error), + } + } + let state = match super::state::load_state(root).await { + Ok(state) => state, + Err(error) => return RevertOutcome::failed(error.to_string()), + }; + let other_hatch_patch = state + .entries + .values() + .any(|other| other.uuid != entry.uuid && other.flavor.as_deref() == Some("hatch")); + if other_hatch_patch + && edits.iter().any(|(file, restored)| { + files + .get(file) + .is_some_and(|live| permission_enabled(live, file)) + && !permission_enabled(restored, file) + }) + { + return RevertOutcome::failed( + "revert later Hatch patches first: they share the direct-reference permission", + ); + } + if !dry_run { + if let Err(error) = write_files(root, &files, &edits).await { + return RevertOutcome::failed(error); + } + } + RevertOutcome::ok() +} + +fn permission_enabled(text: &str, file: &str) -> bool { + text.parse::() + .is_ok_and(|document| { + let hatch = if file == "hatch.toml" { + Some(document.as_item()) + } else { + document.get("tool").and_then(|tool| tool.get("hatch")) + }; + hatch + .and_then(|hatch| hatch.get("metadata")) + .and_then(|metadata| metadata.get("allow-direct-references")) + .and_then(toml_edit::Item::as_bool) + == Some(true) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::state::{save_state, VendorState}; + use serde_json::json; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIGINAL: &str = + "[project]\ndependencies=[\"one==1\", \"two==2\"]\n[tool.hatch.envs.default]\n"; + + fn entry( + uuid: &str, + name: &str, + wheel: &str, + hash: &str, + wiring: Vec, + ) -> VendorEntry { + serde_json::from_value(json!({ + "ecosystem": "pypi", "basePurl": format!("pkg:pypi/{name}@1"), + "uuid": uuid, "flavor": "hatch", "wiring": wiring, + "artifact": {"path": wheel, "sha256": hash} + })) + .unwrap() + } + + #[tokio::test] + async fn recorded_pin_drift_missing_artifact_and_ledgerless_source() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + tokio::fs::write(root.join("pyproject.toml"), ORIGINAL) + .await + .unwrap(); + let project = load(root, "one", "1", UUID).await.unwrap(); + let wheel = format!(".socket/vendor/pypi/{UUID}/one-1-py3-none-any.whl"); + tokio::fs::create_dir_all(root.join(&wheel).parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(root.join(&wheel), b"real file bytes") + .await + .unwrap(); + let hash = crate::vendor::verify::file_sha256_hex(&root.join(&wheel)) + .await + .unwrap(); + let wiring = wire(&project, root, "one", "1", &wheel, &hash) + .await + .unwrap(); + let patched = tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(); + assert!(load(root, "one", "1", UUID).await.is_err()); + let mut state = VendorState::default(); + state.entries.insert( + "pkg:pypi/one@1".into(), + entry(UUID, "one", &wheel, &hash, wiring), + ); + save_state(root, &state).await.unwrap(); + assert!(load(root, "one", "1", UUID).await.unwrap().in_sync); + for changed in [ + patched.replace(&hash, &"a".repeat(64)), + patched.replace("one-1-py3", "other-1-py3"), + patched.replace("one-1-py3", "../one-1-py3"), + ] { + tokio::fs::write(root.join("pyproject.toml"), changed) + .await + .unwrap(); + assert!(load(root, "one", "1", UUID).await.is_err()); + } + tokio::fs::write(root.join("pyproject.toml"), &patched) + .await + .unwrap(); + tokio::fs::write(root.join(&wheel), b"corrupt") + .await + .unwrap(); + assert!(load(root, "one", "1", UUID).await.is_err()); + tokio::fs::remove_file(root.join(&wheel)).await.unwrap(); + assert!(load(root, "one", "1", UUID).await.unwrap().in_sync); + } + + #[tokio::test] + async fn concurrent_edits_are_not_overwritten() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + tokio::fs::write(root.join("pyproject.toml"), ORIGINAL) + .await + .unwrap(); + let project = load(root, "one", "1", UUID).await.unwrap(); + let changed = format!("{ORIGINAL}# concurrent edit\n"); + tokio::fs::write(root.join("pyproject.toml"), &changed) + .await + .unwrap(); + assert!(wire( + &project, + root, + "one", + "1", + ".socket/vendor/a.whl", + &"0".repeat(64) + ) + .await + .is_err()); + assert_eq!( + tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(), + changed + ); + } + + #[tokio::test] + async fn shared_permission_refuses_out_of_order_and_lifo_restores() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + tokio::fs::write(root.join("pyproject.toml"), ORIGINAL) + .await + .unwrap(); + let mut state = VendorState::default(); + let mut entries = Vec::new(); + for (name, version, uuid) in [ + ("one", "1", UUID), + ("two", "2", "a0f74f9a-ce65-4451-ab60-025159b4d410"), + ] { + let project = load(root, name, version, uuid).await.unwrap(); + let wheel = format!(".socket/vendor/pypi/{uuid}/{name}-{version}-py3-none-any.whl"); + let wiring = wire(&project, root, name, version, &wheel, &"0".repeat(64)) + .await + .unwrap(); + let entry = entry(uuid, name, &wheel, &"0".repeat(64), wiring); + state.entries.insert(name.into(), entry.clone()); + entries.push(entry); + save_state(root, &state).await.unwrap(); + } + let both = tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(); + assert!(!revert(&entries[0], root, false).await.success); + assert_eq!( + tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(), + both + ); + assert!(revert(&entries[1], root, false).await.success); + state.entries.remove("two"); + save_state(root, &state).await.unwrap(); + assert!(revert(&entries[0], root, false).await.success); + assert_eq!( + tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(), + ORIGINAL + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 9c38ad7b..054a20d1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -505,7 +505,11 @@ fn restore_item(live: &mut Item, original: &Item, new: &Item) -> bool { true } -fn restore_document(live: &str, original: &str, new: &str) -> Result<(String, bool), String> { +pub(crate) fn restore_document( + live: &str, + original: &str, + new: &str, +) -> Result<(String, bool), String> { if live == new || live == original { return Ok((original.to_string(), false)); } diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md new file mode 100644 index 00000000..cb79a5e7 --- /dev/null +++ b/docs/testing/hatch.md @@ -0,0 +1,41 @@ +# Hatch patch compatibility + +Hatch 1.x projects support hosted wheels and vendored wheels through exact +PEP 508 declarations in `project.dependencies`, optional dependencies, and +Hatch environment `dependencies` / `extra-dependencies`. External +`hatch.toml` tables override the corresponding top-level `tool.hatch` keys. +Project references enable Hatchling's `allow-direct-references` setting. +Vendored references use `{root:uri}` so checkouts remain relocatable. +Both modes pin the wheel SHA-256, preserve extras, markers, comments and +line endings, and record reversible document edits. + +Hatch 0.x continues to use the requirements/pip backend. Existing lockfile +and requirements routing retains precedence over Hatchling's build marker. +A build backend alone does not change which existing pip inputs are wired. + +A range, transitive-only declaration, dynamic dependency metadata, custom +environment plugin, source table or conditional override is refused before +writing. Use the install hook 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 +explicit revert before patching. + +Repeated vendored scans compare the declared source with the committed +artifact path and digest, and verify an existing wheel's bytes. Missing +wheels may be rebuilt only against that recorded pin. Ledgerless direct +references and drifted sources are refused. Concurrent manifest edits and +symlinks are also refused. Revert later Hatch patches first when an older +patch owns a direct-reference permission that they still need; attempting +that selective rollback out of order leaves every file unchanged. + +Focused Rust checks: + +```sh +cargo test --locked -p socket-patch-core --lib hatch +``` + +The depscan companion PR runs real released Hatch binaries, actual CLI +scans, fresh native installs, installed patch-file hash verification, +wrong-digest rejection, repeated scans and rollback on Linux and Windows. +It also feeds captured manifests through the real SBOM pipeline and seeded +metadata service. Its runner is `tools/pipeline/hatch-patch-backtest.py`. From 6aa4b77e6fe4e9b1e2eeb5a9e0684cdca2c82fa8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 17 Sep 2026 18:05:16 -0400 Subject: [PATCH 2/5] Guard old Hatch environment installers Require Hatch 1.2 or later for vendored environment dependencies, whose root placeholders older releases cannot expand. Exercise rollback after newline conversion and refuse symlinked manifests. Assisted-by: Codex:gpt-6-astra --- .../src/patch/redirect/replay.rs | 22 +++++++++++ crates/socket-patch-core/src/utils/hatch.rs | 36 +++++++++++++++++ .../src/vendor/pypi_hatch.rs | 39 +++++++++++++++++++ docs/testing/hatch.md | 2 + 4 files changed, 99 insertions(+) diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index eafc6d3d..7db41ae8 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -748,6 +748,28 @@ mod tests { tokio::fs::read_to_string(root.join(rel)).await.unwrap() } + #[tokio::test] + async fn hatch_documents_revert_after_checkout_newline_conversion() { + let original = "[project]\ndependencies=[\"one==1\"]\n[tool.hatch.envs.default]\n"; + let files = [("pyproject.toml".to_owned(), original.to_owned())].into_iter().collect(); + let patched = crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl").unwrap().remove("pyproject.toml").unwrap(); + for drift in [false, true] { + let dir = TempDir::new().unwrap(); + let live = if drift {patched.replace("one.whl", "changed.whl")} else {patched.replace('\n', "\r\n")}; + write(dir.path(), "pyproject.toml", &live).await; + let mut state = state_with(vec![edit("pyproject.toml", "redirect_hatch_document", "rewritten", Some(original), Some(&patched))], &["pkg:pypi/one@1"]); + let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(outcome.fully_reverted(), !drift); + if drift { + assert_eq!(read(dir.path(), "pyproject.toml").await, live); + assert_eq!(state.edits.len(), 1); + } else { + assert_eq!(read(dir.path(), "pyproject.toml").await, original.replace('\n', "\r\n")); + assert!(state.edits.is_empty()); + } + } + } + // ---------- ReplaceFragment ---------- #[tokio::test] diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index f07e7f3e..a9efb8ed 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -23,6 +23,42 @@ pub fn is_hatch(files: &BTreeMap) -> bool { }) } +pub fn has_environment_dependency(files: &BTreeMap, name: &str) -> bool { + let external = files + .get("hatch.toml") + .and_then(|text| text.parse::().ok()); + let project = files + .get("pyproject.toml") + .and_then(|text| text.parse::().ok()); + let environments = external + .as_ref() + .and_then(|document| document.get("envs")) + .or_else(|| { + project + .as_ref() + .and_then(|document| document.get("tool")) + .and_then(|tool| tool.get("hatch")) + .and_then(|hatch| hatch.get("envs")) + }); + environments + .and_then(Item::as_table_like) + .is_some_and(|environments| { + environments.iter().any(|(_, environment)| { + ["dependencies", "extra-dependencies"].iter().any(|key| { + environment + .get(key) + .and_then(Item::as_array) + .is_some_and(|dependencies| { + dependencies.iter().filter_map(Value::as_str).any(|spec| { + canonicalize_pypi_name(pep508_name(spec)) + == canonicalize_pypi_name(name) + }) + }) + }) + }) + }) +} + fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result, String> { let declared = pep508_name(spec); if canonicalize_pypi_name(declared) != name { diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index d34ae7ad..886b8338 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -98,6 +98,9 @@ pub(super) async fn load( }); let changes = hatch::rewrite(&files, name, version, &url) .map_err(|error| ("pypi_hatch_unsupported", error))?; + if hatch::has_environment_dependency(&files, name) { + require_environment_context_support(root).await?; + } let in_sync = pin.is_some() && changes.is_empty(); Ok(HatchProject { files, @@ -106,6 +109,30 @@ pub(super) async fn load( }) } +async fn require_environment_context_support(root: &Path) -> Result<(), Failure> { + let output = tokio::time::timeout( + std::time::Duration::from_secs(10), + tokio::process::Command::new("hatch") + .arg("--version") + .current_dir(root) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await; + if let Ok(Ok(output)) = output { + if output.status.success() + && String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|word| semver::Version::parse(word).ok()) + .any(|version| version >= semver::Version::new(1, 2, 0)) + { + return Ok(()); + } + } + Err(("pypi_hatch_unsupported", "vendored environment dependencies require Hatch >=1.2 on PATH for root URI expansion; upgrade Hatch or use the install hook".into())) +} + async fn write_files( root: &Path, original: &BTreeMap, @@ -317,6 +344,18 @@ mod tests { assert!(load(root, "one", "1", UUID).await.unwrap().in_sync); } + #[cfg(unix)] + #[tokio::test] + async fn symlinked_configuration_is_refused_without_touching_target() { + let temp = tempfile::tempdir().unwrap(); + let external = tempfile::tempdir().unwrap(); + let target = external.path().join("pyproject.toml"); + tokio::fs::write(&target, ORIGINAL).await.unwrap(); + std::os::unix::fs::symlink(&target, temp.path().join("pyproject.toml")).unwrap(); + assert!(load(temp.path(), "one", "1", UUID).await.is_err()); + assert_eq!(tokio::fs::read_to_string(target).await.unwrap(), ORIGINAL); + } + #[tokio::test] async fn concurrent_edits_are_not_overwritten() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index cb79a5e7..2f7265a4 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -6,6 +6,8 @@ Hatch environment `dependencies` / `extra-dependencies`. External `hatch.toml` tables override the corresponding top-level `tool.hatch` keys. Project references enable Hatchling's `allow-direct-references` setting. Vendored references use `{root:uri}` so checkouts remain relocatable. +Environment references require Hatch >=1.2 on PATH; preflight verifies the +installed version because Hatch 1.0 and 1.1 do not expand that context. Both modes pin the wheel SHA-256, preserve extras, markers, comments and line endings, and record reversible document edits. From c84f476a553b9940d50e41c5a1b375d167ae4217 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 17 Sep 2026 18:12:48 -0400 Subject: [PATCH 3/5] Preserve shared Hatch permissions on rollback Restore direct-reference settings after the last project source is unwired, including selective and preserved rollback. Refuse malformed metadata and local uv wheels whose digest fragments are ignored. Confirm requirements-driven projects from active declarations. Assisted-by: Codex:gpt-6-astra --- .../src/patch/redirect/mod.rs | 15 +- .../src/patch/redirect/requirements.rs | 1 + crates/socket-patch-core/src/utils/hatch.rs | 144 ++++++++++++++++-- .../src/vendor/pypi_hatch.rs | 136 ++++++++++------- docs/testing/hatch.md | 9 +- 5 files changed, 235 insertions(+), 70 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 16c16e65..8ac16763 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -176,6 +176,7 @@ pub struct RewriteResult { pub refused_pnpm_uuids: std::collections::BTreeSet, pub hatch_uuids: std::collections::BTreeSet, pub confirmed_hatch_uuids: std::collections::BTreeSet, + pub confirmed_requirements_uuids: std::collections::BTreeSet, } /// Combined name as it appears in registry coordinates / lock keys. @@ -233,7 +234,6 @@ fn rewrite_hatch( result: &mut RewriteResult, ) { if !crate::utils::hatch::is_hatch(files) - || files.keys().any(|file| file.starts_with("requirements") && file.ends_with(".txt")) || files.keys().any(|file| { matches!( file.as_str(), @@ -243,6 +243,11 @@ fn rewrite_hatch( { return; } + if files.contains_key("requirements.txt") { + result.hatch_uuids.extend(overrides.iter().filter(|dep| dep.ecosystem == "pypi").map(|dep| dep.patch_uuid.clone())); + result.confirmed_hatch_uuids.extend(result.confirmed_requirements_uuids.iter().cloned()); + return; + } let mut current = files.clone(); for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { result.hatch_uuids.insert(dep.patch_uuid.clone()); @@ -13214,6 +13219,14 @@ mod hatch_tests { assert!(result.confirmed_hatch_uuids.is_empty()); assert!(result.files.is_empty()); assert!(result.warnings.iter().any(|warning| warning.code == "redirect_hatch_unsupported")); + let mut files = files; + files.insert("requirements.txt".into(), String::new()); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.hatch_uuids.contains("test-uuid")); + assert!(result.confirmed_hatch_uuids.is_empty()); + files.insert("requirements.txt".into(), "urllib3==1.26.18\n".into()); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.confirmed_hatch_uuids.contains("test-uuid")); } #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 79769584..6f3a4bfe 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -277,6 +277,7 @@ pub(super) fn rewrite( } } matched = true; + result.confirmed_requirements_uuids.insert(dep.patch_uuid.clone()); let options = requirement_tokens(specifier) .into_iter() .skip_while(|token| !token.starts_with("--")) diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index a9efb8ed..e93a8f5a 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -230,12 +230,90 @@ fn rewrite_environments( Ok(matched) } +pub struct HatchPermission { + pub file: String, + pub original: String, + pub new: String, +} + +pub struct HatchPlan { + pub files: BTreeMap, + pub permission: Option, +} + pub fn rewrite( files: &BTreeMap, name: &str, version: &str, url: &str, ) -> Result, String> { + plan(files, name, version, url).map(|plan| plan.files) +} + +fn enable_permission(document: &mut DocumentMut, external: bool) -> Result<(), String> { + let keys: &[&str] = if external { + &["metadata"] + } else { + &["tool", "hatch", "metadata"] + }; + let mut table: &mut dyn toml_edit::TableLike = document.as_table_mut(); + for key in keys { + if !table.contains_key(key) { + table.insert(key, Item::Table(toml_edit::Table::new())); + } + table = table + .get_mut(key) + .and_then(Item::as_table_like_mut) + .ok_or_else(|| format!("{key} must be a TOML table"))?; + } + if table + .get("allow-direct-references") + .is_some_and(|item| !item.is_bool()) + { + return Err("allow-direct-references must be a boolean".into()); + } + table.insert("allow-direct-references", toml_edit::value(true)); + Ok(()) +} + +pub fn has_project_direct_references(files: &BTreeMap) -> bool { + let Some(document) = files + .get("pyproject.toml") + .and_then(|text| text.parse::().ok()) + else { + return false; + }; + let project = document.get("project"); + let mut arrays = Vec::new(); + if let Some(dependencies) = project + .and_then(|project| project.get("dependencies")) + .and_then(Item::as_array) + { + arrays.push(dependencies); + } + for groups in [ + project.and_then(|project| project.get("optional-dependencies")), + document.get("dependency-groups"), + ] { + if let Some(groups) = groups.and_then(Item::as_table_like) { + arrays.extend(groups.iter().filter_map(|(_, value)| value.as_array())); + } + } + arrays.iter().any(|array| { + array.iter().filter_map(Value::as_str).any(|spec| { + spec.split(';') + .next() + .is_some_and(|requirement| requirement.contains('@')) + }) + }) +} + +pub fn plan( + files: &BTreeMap, + name: &str, + version: &str, + url: &str, +) -> Result { let name = canonicalize_pypi_name(name); let mut documents = BTreeMap::new(); for file in ["pyproject.toml", "hatch.toml"] { @@ -247,6 +325,24 @@ pub fn rewrite( ); } } + if url.starts_with("{root:uri}") { + for document in documents.values() { + let hatch = document + .get("tool") + .and_then(|tool| tool.get("hatch")) + .unwrap_or(document.as_item()); + if hatch + .get("envs") + .and_then(Item::as_table_like) + .is_some_and(|envs| { + envs.iter() + .any(|(_, env)| env.get("installer").and_then(Item::as_str) == Some("uv")) + }) + { + return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into()); + } + } + } let mut matched = 0; let mut project_matched = 0; if let Some(project) = documents.get_mut("pyproject.toml") { @@ -288,27 +384,39 @@ pub fn rewrite( if matched == 0 { return Err(format!("{name}=={version} has no explicit Hatch declaration; transitive-only dependencies require the install hook")); } - if project_matched > 0 { - if external_keys.iter().any(|key| key == "metadata") { - documents - .get_mut("hatch.toml") - .ok_or("missing Hatch configuration")?["metadata"]["allow-direct-references"] = - toml_edit::value(true); + let permission = if project_matched > 0 { + let external = external_keys.iter().any(|key| key == "metadata"); + let file = if external { + "hatch.toml" } else { - documents - .get_mut("pyproject.toml") - .ok_or("missing project")?["tool"]["hatch"]["metadata"] - ["allow-direct-references"] = toml_edit::value(true); - } - } - Ok(documents + "pyproject.toml" + }; + let document = documents + .get_mut(file) + .ok_or("missing Hatch configuration")?; + enable_permission(document, external)?; + let original = files[file].clone(); + let mut permission_only = original + .parse::() + .map_err(|error| error.to_string())?; + enable_permission(&mut permission_only, external)?; + Some(HatchPermission { + file: file.into(), + new: preserve_line_endings(&original, permission_only.to_string()), + original, + }) + } else { + None + }; + let files = documents .into_iter() .filter_map(|(name, document)| { let original = &files[name]; let new = preserve_line_endings(original, document.to_string()); (new != *original).then_some((name.to_owned(), new)) }) - .collect()) + .collect(); + Ok(HatchPlan { files, permission }) } #[cfg(test)] @@ -371,7 +479,11 @@ mod tests { let result = rewrite(&inputs, "urllib3", "1.26.18", "https://patch.test/a.whl").unwrap(); assert!(result["pyproject.toml"].contains("urllib3>=0")); assert!(!result["pyproject.toml"].contains("allow-direct-references")); - assert!(result["hatch.toml"].contains("allow-direct-references= true")); + let document = result["hatch.toml"].parse::().unwrap(); + assert_eq!( + document["metadata"]["allow-direct-references"].as_bool(), + Some(true) + ); } #[test] @@ -384,6 +496,8 @@ mod tests { "[tool.hatch.envs.default]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default.overrides]\nplatform.windows.dependencies=[\"urllib3==1.26.18\"]", "[tool.hatch.envs.default]\ndependencies=[\"requests==2.31.0\"]", "[project]\ndependencies=[false]", + "[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch]\nmetadata=false", + "[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool]\nhatch=false", "[project]\ndependencies=[\"urllib3==1.26.18\\nidna==3.6\"]", ] { assert!(rewrite(&files(text), "urllib3", "1.26.18", "https://patch.test/a.whl").is_err(), "{text}"); diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index 886b8338..8d82760b 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -40,6 +40,9 @@ pub(super) async fn load( uuid: &str, ) -> Result { let files = read_files(root).await?; + 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())); + } let prefix = format!("{{root:uri}}/.socket/vendor/pypi/{uuid}/"); let state = super::state::load_state(root) .await @@ -175,24 +178,54 @@ pub(super) async fn wire( hash: &str, ) -> Result, Failure> { let url = format!("{{root:uri}}/{wheel}#sha256={hash}"); - let edits = hatch::rewrite(&project.files, name, version, &url) + let plan = hatch::plan(&project.files, name, version, &url) .map_err(|error| ("pypi_hatch_unsupported", error))?; - write_files(root, &project.files, &edits) + let mut originals = project.files.clone(); + let mut permission_record = None; + if let Some(permission) = plan.permission { + originals.insert(permission.file.clone(), permission.new.clone()); + let state = super::state::load_state(root) + .await + .map_err(|error| ("pypi_hatch_ledger_invalid", error.to_string()))?; + permission_record = Some( + state + .entries + .values() + .flat_map(|entry| &entry.wiring) + .find(|record| record.kind == "hatch_permission" && record.file == permission.file) + .cloned() + .unwrap_or_else(|| { + record( + &permission.file, + "hatch_permission", + WiringAction::Rewritten, + "allow-direct-references", + Some(permission.original), + permission.new, + ) + }), + ); + } + write_files(root, &project.files, &plan.files) .await .map_err(|error| ("pypi_hatch_write_failed", error))?; - Ok(edits + let mut records: Vec = plan + .files .into_iter() + .filter(|(file, new)| originals.get(file) != Some(new)) .map(|(file, new)| { record( &file, KIND, WiringAction::Rewritten, name, - project.files.get(&file).cloned(), + originals.get(&file).cloned(), new, ) }) - .collect()) + .collect(); + records.extend(permission_record); + Ok(records) } pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { @@ -201,7 +234,12 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R Err((_, error)) => return RevertOutcome::failed(error), }; let mut edits = BTreeMap::new(); - for record in entry.wiring.iter().rev() { + for record in entry + .wiring + .iter() + .rev() + .filter(|record| record.kind != "hatch_permission") + { if !matches!(record.file.as_str(), "pyproject.toml" | "hatch.toml") || record.kind != KIND { return RevertOutcome::failed("invalid Hatch wiring record"); } @@ -222,25 +260,38 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R Err(error) => return RevertOutcome::failed(error), } } - let state = match super::state::load_state(root).await { - Ok(state) => state, - Err(error) => return RevertOutcome::failed(error.to_string()), - }; - let other_hatch_patch = state - .entries - .values() - .any(|other| other.uuid != entry.uuid && other.flavor.as_deref() == Some("hatch")); - if other_hatch_patch - && edits.iter().any(|(file, restored)| { - files - .get(file) - .is_some_and(|live| permission_enabled(live, file)) - && !permission_enabled(restored, file) - }) - { - return RevertOutcome::failed( - "revert later Hatch patches first: they share the direct-reference permission", - ); + let mut restored_files = files.clone(); + restored_files.extend(edits.clone()); + if !hatch::has_project_direct_references(&restored_files) { + for permission in entry + .wiring + .iter() + .filter(|record| record.kind == "hatch_permission") + { + if !matches!(permission.file.as_str(), "pyproject.toml" | "hatch.toml") { + return RevertOutcome::failed("invalid Hatch permission record"); + } + let (Some(original), Some(new), Some(live)) = ( + permission + .original + .as_ref() + .and_then(serde_json::Value::as_str), + permission.new.as_ref().and_then(serde_json::Value::as_str), + restored_files.get(&permission.file), + ) else { + return RevertOutcome::failed("missing Hatch permission document"); + }; + match super::pypi_lock::restore_document(live, original, new) { + Ok((restored, false)) => { + edits.insert(permission.file.clone(), restored); + } + _ => { + return RevertOutcome::failed( + "Hatch direct-reference permission changed since patching", + ) + } + } + } } if !dry_run { if let Err(error) = write_files(root, &files, &edits).await { @@ -250,22 +301,6 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R RevertOutcome::ok() } -fn permission_enabled(text: &str, file: &str) -> bool { - text.parse::() - .is_ok_and(|document| { - let hatch = if file == "hatch.toml" { - Some(document.as_item()) - } else { - document.get("tool").and_then(|tool| tool.get("hatch")) - }; - hatch - .and_then(|hatch| hatch.get("metadata")) - .and_then(|metadata| metadata.get("allow-direct-references")) - .and_then(toml_edit::Item::as_bool) - == Some(true) - }) -} - #[cfg(test)] mod tests { use super::*; @@ -387,7 +422,7 @@ mod tests { } #[tokio::test] - async fn shared_permission_refuses_out_of_order_and_lifo_restores() { + async fn shared_permission_survives_selective_and_preserved_rollback() { let temp = tempfile::tempdir().unwrap(); let root = temp.path(); tokio::fs::write(root.join("pyproject.toml"), ORIGINAL) @@ -412,16 +447,15 @@ mod tests { let both = tokio::fs::read_to_string(root.join("pyproject.toml")) .await .unwrap(); - assert!(!revert(&entries[0], root, false).await.success); - assert_eq!( - tokio::fs::read_to_string(root.join("pyproject.toml")) - .await - .unwrap(), - both - ); + assert!(revert(&entries[0], root, false).await.success); + let remaining = tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(); + assert_ne!(remaining, both); + assert!(remaining.contains("allow-direct-references = true")); + assert!(remaining.contains("two-2-py3-none-any.whl")); assert!(revert(&entries[1], root, false).await.success); - state.entries.remove("two"); - save_state(root, &state).await.unwrap(); + assert!(revert(&entries[0], root, false).await.success); assert_eq!( tokio::fs::read_to_string(root.join("pyproject.toml")) diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 2f7265a4..f30e74f5 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -8,6 +8,8 @@ Project references enable Hatchling's `allow-direct-references` setting. Vendored references use `{root:uri}` so checkouts remain relocatable. Environment references require Hatch >=1.2 on PATH; preflight verifies the installed version because Hatch 1.0 and 1.1 do not expand that context. +Vendored Hatch requires the pip installer; uv currently ignores hash +fragments for local wheels. Hosted Hatch supports both pip and uv. Both modes pin the wheel SHA-256, preserve extras, markers, comments and line endings, and record reversible document edits. @@ -26,9 +28,10 @@ Repeated vendored scans compare the declared source with the committed artifact path and digest, and verify an existing wheel's bytes. Missing wheels may be rebuilt only against that recorded pin. Ledgerless direct references and drifted sources are refused. Concurrent manifest edits and -symlinks are also refused. Revert later Hatch patches first when an older -patch owns a direct-reference permission that they still need; attempting -that selective rollback out of order leaves every file unchanged. +symlinks are also refused. Each project patch records shared ownership of the direct-reference +permission. Selective and preserved rollback retain the setting while any +project direct reference remains, and restore its original value after the +last reference is unwired. Focused Rust checks: From ece539390fb38dab39d0f017c018c9b78854c856 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 17 Sep 2026 18:16:17 -0400 Subject: [PATCH 4/5] Check explicit Hatch uv executable settings Refuse vendored local wheels when uv is enabled through uv-path as well as installer or environment configuration. Assisted-by: Codex:gpt-6-astra --- crates/socket-patch-core/src/utils/hatch.rs | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index e93a8f5a..762725e3 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -335,8 +335,13 @@ pub fn plan( .get("envs") .and_then(Item::as_table_like) .is_some_and(|envs| { - envs.iter() - .any(|(_, env)| env.get("installer").and_then(Item::as_str) == Some("uv")) + envs.iter().any(|(_, env)| { + env.get("installer").and_then(Item::as_str) == Some("uv") + || env + .get("uv-path") + .and_then(Item::as_str) + .is_some_and(|path| !path.is_empty()) + }) }) { return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into()); @@ -504,6 +509,22 @@ mod tests { } } + #[test] + fn uv_installer_and_explicit_path_refuse_local_wheels() { + for setting in ["installer='uv'", "uv-path='uv'"] { + let inputs = files(&format!("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n{setting}\n")); + assert!(rewrite(&inputs, "urllib3", "1.26.18", "https://patch.test/a.whl").is_ok()); + assert!(rewrite( + &inputs, + "urllib3", + "1.26.18", + "{root:uri}/.socket/vendor/a.whl" + ) + .unwrap_err() + .contains("pip installer")); + } + } + #[test] fn groups_accept_hosted_and_refuse_unexpanded_vendor_context() { let inputs = files("[dependency-groups]\nqa=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\ndependency-groups=[\"qa\"]"); From b456a8ecd80b63aa3c1e7bc67aacc8af828aff0b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 17 Sep 2026 18:28:23 -0400 Subject: [PATCH 5/5] Clarify vendored Hatch requirements Distinguish the environment version floor from hosted support and clarify shared permission ownership during rollback. Assisted-by: Codex:gpt-6-astra --- docs/testing/hatch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index f30e74f5..2d730358 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -6,7 +6,7 @@ Hatch environment `dependencies` / `extra-dependencies`. External `hatch.toml` tables override the corresponding top-level `tool.hatch` keys. Project references enable Hatchling's `allow-direct-references` setting. Vendored references use `{root:uri}` so checkouts remain relocatable. -Environment references require Hatch >=1.2 on PATH; preflight verifies the +Vendored environment references require Hatch >=1.2 on PATH; preflight verifies the installed version because Hatch 1.0 and 1.1 do not expand that context. Vendored Hatch requires the pip installer; uv currently ignores hash fragments for local wheels. Hosted Hatch supports both pip and uv. @@ -28,8 +28,8 @@ Repeated vendored scans compare the declared source with the committed artifact path and digest, and verify an existing wheel's bytes. Missing wheels may be rebuilt only against that recorded pin. Ledgerless direct references and drifted sources are refused. Concurrent manifest edits and -symlinks are also refused. Each project patch records shared ownership of the direct-reference -permission. Selective and preserved rollback retain the setting while any +symlinks are also refused. Each project patch records shared ownership of +the direct-reference permission. Selective and preserved rollback retain the setting while any project direct reference remains, and restore its original value after the last reference is unwired.