diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6419bb2d..50fb9605 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -35,7 +35,10 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "requirements.txt", "uv.lock", "poetry.lock", + "pdm.lock", + "Pipfile.lock", "pyproject.toml", + "hatch.toml", "Cargo.toml", "Cargo.lock", ".cargo/config.toml", @@ -1682,6 +1685,16 @@ pub(crate) async fn run_redirect_selected( .iter() .filter( |(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| { + if rewrite.python_lock_uuids.contains(uuid) { + return rewrite.confirmed_python_lock_uuids.contains(uuid) + && !rewrite.refused_python_lock_uuids.contains(uuid); + } + if rewrite.hatch_uuids.contains(uuid) { + return rewrite.confirmed_hatch_uuids.contains(uuid); + } + if purl.starts_with("pkg:pypi/") { + return rewrite.confirmed_requirements_uuids.contains(uuid); + } if rewrite.refused_pnpm_uuids.contains(uuid) { return false; } diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index be22628d..f59c6822 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::utils::fs::read_regular_to_string; use crate::utils::process::{CommandRunner, SystemCommandRunner}; // --------------------------------------------------------------------------- @@ -530,7 +531,7 @@ fn expand_home(raw: &str, var: &impl Fn(&str) -> Option) -> PathBuf { pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec { let var = |name: &str| std::env::var(name).ok(); let has = |leaf: &str| cwd.join(leaf).is_file(); - let pyproject = match tokio::fs::read_to_string(cwd.join("pyproject.toml")).await { + let pyproject = match read_regular_to_string(&cwd.join("pyproject.toml")).await { Ok(text) => text, Err(_) => return Vec::new(), }; @@ -542,12 +543,12 @@ pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec { if names.is_empty() { return Vec::new(); } - let local = match tokio::fs::read_to_string(cwd.join("poetry.toml")).await { + let local = match read_regular_to_string(&cwd.join("poetry.toml")).await { Ok(text) => PoetryVirtualenvConfig::from_toml(&text), Err(_) => PoetryVirtualenvConfig::default(), }; let user = match poetry_user_config_path(&var) { - Some(path) => match tokio::fs::read_to_string(&path).await { + Some(path) => match read_regular_to_string(&path).await { Ok(text) => PoetryVirtualenvConfig::from_toml(&text), Err(_) => PoetryVirtualenvConfig::default(), }, @@ -1055,6 +1056,42 @@ mod tests { use super::*; use crate::utils::purl::parse_pypi_purl; + #[cfg(unix)] + #[tokio::test] + async fn hatch_discovery_does_not_block_on_fifo_configuration() { + for filename in ["pyproject.toml", "poetry.toml"] { + let directory = tempfile::tempdir().unwrap(); + let fifo = directory.path().join(filename); + if filename == "poetry.toml" { + std::fs::write( + directory.path().join("pyproject.toml"), + "[tool.poetry]\nname='hatch-project'\n", + ) + .unwrap(); + } + assert!(tokio::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .await + .unwrap() + .success()); + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + find_poetry_virtualenv_site_packages(directory.path()), + ) + .await; + if result.is_err() { + let release = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&fifo) + .unwrap(); + drop(release); + } + assert!(result.unwrap().is_empty(), "{filename}"); + } + } + // ── Poetry out-of-tree virtualenv discovery ───────────────────────────── /// Known-answer vectors computed with Poetry's own algorithm diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e604b749..2805012e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -175,6 +175,12 @@ 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 python_lock_uuids: std::collections::BTreeSet, + pub confirmed_python_lock_uuids: std::collections::BTreeSet, + pub refused_python_lock_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. @@ -215,6 +221,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); poetry::rewrite_poetry(files, overrides, &mut result); rewrite_cargo(files, overrides, &mut result); @@ -226,6 +233,72 @@ 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) { + return; + } + result.hatch_uuids.extend( + overrides + .iter() + .filter(|dep| dep.ecosystem == "pypi") + .map(|dep| dep.patch_uuid.clone()), + ); + if 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; + } + if files.contains_key("requirements.txt") { + 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") { + 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, @@ -2587,6 +2660,7 @@ fn rewrite_uv_lock( // missing-integrity warning three times. let mut usable: Vec<(&DepOverride, &str)> = Vec::new(); for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + result.python_lock_uuids.insert(dep.patch_uuid.clone()); match dep.integrity.sha256.as_deref() { Some(sha256) => usable.push((dep, sha256)), None => result.warnings.push(RewriteWarning { @@ -2614,6 +2688,7 @@ fn rewrite_uv_lock( continue; } Err(detail) => { + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2625,6 +2700,7 @@ fn rewrite_uv_lock( match plan_python_metadata(path, &content, files, dep, result) { Ok(plan) => plan, Err(warning) => { + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(warning); continue; } @@ -2639,6 +2715,7 @@ fn rewrite_uv_lock( ) { Ok(rewritten) => rewritten, Err(detail) => { + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_metadata_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2646,6 +2723,7 @@ fn rewrite_uv_lock( continue; } }; + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); if let Some(edit) = metadata_edit { record_python_metadata_edit(edit, dep, result); } @@ -13132,3 +13210,107 @@ 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-1.26.18-py2.py3-none-any.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")); + 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] + fn hatch_confirmation_uses_successful_lock_writers() { + let base: BTreeMap = [ + ("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}\"]\n", patch().artifact_url)), + ("hatch.toml".into(), "[envs.default]\ndependencies=[\"urllib3>=1\"]\n".into()), + ].into_iter().collect(); + for (filename, text) in [ + ("uv.lock", "version = 2"), + ("pylock.toml", "lock-version = '2.0'"), + ("pdm.lock", "[metadata]\nlock_version = '4.5.1'"), + ("Pipfile.lock", "{}"), + ( + "poetry.lock", + include_str!("../../../tests/fixtures/poetry/0.12.17/poetry.lock"), + ), + ] { + let mut files = base.clone(); + files.insert(filename.into(), text.into()); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.hatch_uuids.contains("test-uuid"), "{filename}"); + assert!(result.confirmed_hatch_uuids.is_empty(), "{filename}"); + assert!(result.confirmed_python_lock_uuids.is_empty(), "{filename}"); + } + let mut files = base; + files.insert( + "poetry.lock".into(), + include_str!("../../../tests/fixtures/poetry/1.0.10/poetry.lock").into(), + ); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.confirmed_python_lock_uuids.contains("test-uuid")); + assert!(result.refused_python_lock_uuids.is_empty()); + files.extend(result.files); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.confirmed_python_lock_uuids.contains("test-uuid")); + assert!(result.files.is_empty()); + files.insert("uv.lock".into(), "version = 2".into()); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.refused_python_lock_uuids.contains("test-uuid")); + } + + #[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/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index 5e9b7d23..963d769e 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -46,6 +46,7 @@ pub(super) fn rewrite_poetry( // Intake gate ONCE per dep, not once per lock file (uv parity). let mut usable: Vec<(&DepOverride, &str)> = Vec::new(); for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + result.python_lock_uuids.insert(dep.patch_uuid.clone()); match dep.integrity.sha256.as_deref() { Some(sha256) => usable.push((dep, sha256)), None => result.warnings.push(RewriteWarning { @@ -83,6 +84,7 @@ pub(super) fn rewrite_poetry( } } Err(detail) => { + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -90,6 +92,7 @@ pub(super) fn rewrite_poetry( continue; } } + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); content = rewritten; if !stale_warned { if let Some(format) = pre_1_4_writer(&content) { @@ -122,15 +125,20 @@ pub(super) fn rewrite_poetry( } } // Already redirected to this artifact (idempotent re-scan). - Ok(Some(_)) => {} + Ok(Some(_)) => { + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), - Err(detail) => result.warnings.push(RewriteWarning { - code: "redirect_poetry_lock_unsupported".into(), - detail: format!("{path}: {detail}"), - }), + Err(detail) => { + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_poetry_lock_unsupported".into(), + detail: format!("{path}: {detail}"), + }); + } } } if content != *original { diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index a35f29cd..e58bbfb4 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). @@ -89,8 +90,11 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { "redirect_requirements_line" | "redirect_uv_lock_wheel" | "redirect_poetry_lock_package" => { ("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" { @@ -408,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 { @@ -435,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) { @@ -730,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/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 new file mode 100644 index 00000000..762725e3 --- /dev/null +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -0,0 +1,541 @@ +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") + }) + }) +} + +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 { + 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 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"] { + if let Some(text) = files.get(file) { + documents.insert( + file, + text.parse::() + .map_err(|error| format!("{file}: {error}"))?, + ); + } + } + 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") + || 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()); + } + } + } + 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")); + } + let permission = if project_matched > 0 { + let external = external_keys.iter().any(|key| key == "metadata"); + let file = if external { + "hatch.toml" + } else { + "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(); + Ok(HatchPlan { files, permission }) +} + +#[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")); + let document = result["hatch.toml"].parse::().unwrap(); + assert_eq!( + document["metadata"]["allow-direct-references"].as_bool(), + Some(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\"]\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}"); + } + } + + #[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\"]"); + 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 e83802f9..30cbe466 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -19,3 +19,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..8d82760b --- /dev/null +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -0,0 +1,467 @@ +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?; + 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 + .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))?; + 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, + in_sync, + pin, + }) +} + +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, + 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 plan = hatch::plan(&project.files, name, version, &url) + .map_err(|error| ("pypi_hatch_unsupported", error))?; + 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))?; + 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, + originals.get(&file).cloned(), + new, + ) + }) + .collect(); + records.extend(permission_record); + Ok(records) +} + +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() + .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"); + } + 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 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 { + return RevertOutcome::failed(error); + } + } + RevertOutcome::ok() +} + +#[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); + } + + #[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(); + 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_survives_selective_and_preserved_rollback() { + 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); + 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); + + 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..2d730358 --- /dev/null +++ b/docs/testing/hatch.md @@ -0,0 +1,46 @@ +# 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. +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. +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. 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: + +```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`.