Skip to content

Commit 649d457

Browse files
Support hosted and vendored Hatch patches (#244)
* 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 * 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 * 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 * 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 * 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
1 parent ff6aaae commit 649d457

13 files changed

Lines changed: 1420 additions & 19 deletions

File tree

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[
3535
"requirements.txt",
3636
"uv.lock",
3737
"poetry.lock",
38+
"pdm.lock",
39+
"Pipfile.lock",
3840
"pyproject.toml",
41+
"hatch.toml",
3942
"Cargo.toml",
4043
"Cargo.lock",
4144
".cargo/config.toml",
@@ -1682,6 +1685,16 @@ pub(crate) async fn run_redirect_selected(
16821685
.iter()
16831686
.filter(
16841687
|(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| {
1688+
if rewrite.python_lock_uuids.contains(uuid) {
1689+
return rewrite.confirmed_python_lock_uuids.contains(uuid)
1690+
&& !rewrite.refused_python_lock_uuids.contains(uuid);
1691+
}
1692+
if rewrite.hatch_uuids.contains(uuid) {
1693+
return rewrite.confirmed_hatch_uuids.contains(uuid);
1694+
}
1695+
if purl.starts_with("pkg:pypi/") {
1696+
return rewrite.confirmed_requirements_uuids.contains(uuid);
1697+
}
16851698
if rewrite.refused_pnpm_uuids.contains(uuid) {
16861699
return false;
16871700
}

‎crates/socket-patch-core/src/crawlers/python_crawler.rs‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
22
use std::path::{Path, PathBuf};
33

44
use super::types::{CrawledPackage, CrawlerOptions};
5+
use crate::utils::fs::read_regular_to_string;
56
use crate::utils::process::{CommandRunner, SystemCommandRunner};
67

78
// ---------------------------------------------------------------------------
@@ -530,7 +531,7 @@ fn expand_home(raw: &str, var: &impl Fn(&str) -> Option<String>) -> PathBuf {
530531
pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec<PathBuf> {
531532
let var = |name: &str| std::env::var(name).ok();
532533
let has = |leaf: &str| cwd.join(leaf).is_file();
533-
let pyproject = match tokio::fs::read_to_string(cwd.join("pyproject.toml")).await {
534+
let pyproject = match read_regular_to_string(&cwd.join("pyproject.toml")).await {
534535
Ok(text) => text,
535536
Err(_) => return Vec::new(),
536537
};
@@ -542,12 +543,12 @@ pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec<PathBuf> {
542543
if names.is_empty() {
543544
return Vec::new();
544545
}
545-
let local = match tokio::fs::read_to_string(cwd.join("poetry.toml")).await {
546+
let local = match read_regular_to_string(&cwd.join("poetry.toml")).await {
546547
Ok(text) => PoetryVirtualenvConfig::from_toml(&text),
547548
Err(_) => PoetryVirtualenvConfig::default(),
548549
};
549550
let user = match poetry_user_config_path(&var) {
550-
Some(path) => match tokio::fs::read_to_string(&path).await {
551+
Some(path) => match read_regular_to_string(&path).await {
551552
Ok(text) => PoetryVirtualenvConfig::from_toml(&text),
552553
Err(_) => PoetryVirtualenvConfig::default(),
553554
},
@@ -1055,6 +1056,42 @@ mod tests {
10551056
use super::*;
10561057
use crate::utils::purl::parse_pypi_purl;
10571058

1059+
#[cfg(unix)]
1060+
#[tokio::test]
1061+
async fn hatch_discovery_does_not_block_on_fifo_configuration() {
1062+
for filename in ["pyproject.toml", "poetry.toml"] {
1063+
let directory = tempfile::tempdir().unwrap();
1064+
let fifo = directory.path().join(filename);
1065+
if filename == "poetry.toml" {
1066+
std::fs::write(
1067+
directory.path().join("pyproject.toml"),
1068+
"[tool.poetry]\nname='hatch-project'\n",
1069+
)
1070+
.unwrap();
1071+
}
1072+
assert!(tokio::process::Command::new("mkfifo")
1073+
.arg(&fifo)
1074+
.status()
1075+
.await
1076+
.unwrap()
1077+
.success());
1078+
let result = tokio::time::timeout(
1079+
std::time::Duration::from_secs(2),
1080+
find_poetry_virtualenv_site_packages(directory.path()),
1081+
)
1082+
.await;
1083+
if result.is_err() {
1084+
let release = std::fs::OpenOptions::new()
1085+
.read(true)
1086+
.write(true)
1087+
.open(&fifo)
1088+
.unwrap();
1089+
drop(release);
1090+
}
1091+
assert!(result.unwrap().is_empty(), "{filename}");
1092+
}
1093+
}
1094+
10581095
// ── Poetry out-of-tree virtualenv discovery ─────────────────────────────
10591096

10601097
/// Known-answer vectors computed with Poetry's own algorithm

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

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ pub struct RewriteResult {
175175
/// An incomplete pnpm rewrite must not be confirmed by finding its URL
176176
/// in another instance, a comment, or another lockfile.
177177
pub refused_pnpm_uuids: std::collections::BTreeSet<String>,
178+
pub python_lock_uuids: std::collections::BTreeSet<String>,
179+
pub confirmed_python_lock_uuids: std::collections::BTreeSet<String>,
180+
pub refused_python_lock_uuids: std::collections::BTreeSet<String>,
181+
pub hatch_uuids: std::collections::BTreeSet<String>,
182+
pub confirmed_hatch_uuids: std::collections::BTreeSet<String>,
183+
pub confirmed_requirements_uuids: std::collections::BTreeSet<String>,
178184
}
179185

180186
/// Combined name as it appears in registry coordinates / lock keys.
@@ -215,6 +221,7 @@ pub fn rewrite_registry_redirect_with_python_metadata(
215221
rewrite_yarn_berry(files, overrides, &mut result);
216222
rewrite_bun_lock(files, overrides, &mut result);
217223
rewrite_pypi_requirements(files, overrides, &mut result);
224+
rewrite_hatch(files, overrides, &mut result);
218225
rewrite_uv_lock(files, overrides, python_metadata, &mut result);
219226
poetry::rewrite_poetry(files, overrides, &mut result);
220227
rewrite_cargo(files, overrides, &mut result);
@@ -226,6 +233,72 @@ pub fn rewrite_registry_redirect_with_python_metadata(
226233
result
227234
}
228235

236+
fn rewrite_hatch(
237+
files: &BTreeMap<String, String>,
238+
overrides: &[DepOverride],
239+
result: &mut RewriteResult,
240+
) {
241+
if !crate::utils::hatch::is_hatch(files) {
242+
return;
243+
}
244+
result.hatch_uuids.extend(
245+
overrides
246+
.iter()
247+
.filter(|dep| dep.ecosystem == "pypi")
248+
.map(|dep| dep.patch_uuid.clone()),
249+
);
250+
if files.keys().any(|file| {
251+
matches!(
252+
file.as_str(),
253+
"uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock"
254+
) || crate::utils::python_lock::is_python_lock_name(file)
255+
}) {
256+
return;
257+
}
258+
if files.contains_key("requirements.txt") {
259+
result
260+
.confirmed_hatch_uuids
261+
.extend(result.confirmed_requirements_uuids.iter().cloned());
262+
return;
263+
}
264+
let mut current = files.clone();
265+
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
266+
let Some(hash) =
267+
dep.integrity.sha256.as_ref().filter(|hash| {
268+
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
269+
})
270+
else {
271+
result.warnings.push(RewriteWarning {
272+
code: "redirect_hatch_missing_sha256".into(),
273+
detail: format!("{} has no valid wheel digest", dep.name),
274+
});
275+
continue;
276+
};
277+
let url = format!("{}#sha256={hash}", dep.artifact_url);
278+
match crate::utils::hatch::rewrite(&current, &dep.name, &dep.version, &url) {
279+
Ok(edits) => {
280+
result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone());
281+
for (path, new) in edits {
282+
result.edits.push(FileEdit {
283+
path: path.clone(),
284+
kind: "redirect_hatch_document".into(),
285+
action: "rewritten".into(),
286+
key: Some(format!("{}@{}", dep.name, dep.version)),
287+
original: current.get(&path).cloned().map(Value::String),
288+
new: Some(Value::String(new.clone())),
289+
});
290+
current.insert(path.clone(), new.clone());
291+
result.files.insert(path, new);
292+
}
293+
}
294+
Err(detail) => result.warnings.push(RewriteWarning {
295+
code: "redirect_hatch_unsupported".into(),
296+
detail,
297+
}),
298+
}
299+
}
300+
}
301+
229302
// ── npm package-lock.json / npm-shrinkwrap.json ─────────────────────────────
230303
fn rewrite_npm_lock(
231304
files: &BTreeMap<String, String>,
@@ -2587,6 +2660,7 @@ fn rewrite_uv_lock(
25872660
// missing-integrity warning three times.
25882661
let mut usable: Vec<(&DepOverride, &str)> = Vec::new();
25892662
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
2663+
result.python_lock_uuids.insert(dep.patch_uuid.clone());
25902664
match dep.integrity.sha256.as_deref() {
25912665
Some(sha256) => usable.push((dep, sha256)),
25922666
None => result.warnings.push(RewriteWarning {
@@ -2614,6 +2688,7 @@ fn rewrite_uv_lock(
26142688
continue;
26152689
}
26162690
Err(detail) => {
2691+
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26172692
result.warnings.push(RewriteWarning {
26182693
code: "redirect_uv_lock_unsupported".into(),
26192694
detail: format!("{path}: {detail}"),
@@ -2625,6 +2700,7 @@ fn rewrite_uv_lock(
26252700
match plan_python_metadata(path, &content, files, dep, result) {
26262701
Ok(plan) => plan,
26272702
Err(warning) => {
2703+
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26282704
result.warnings.push(warning);
26292705
continue;
26302706
}
@@ -2639,13 +2715,15 @@ fn rewrite_uv_lock(
26392715
) {
26402716
Ok(rewritten) => rewritten,
26412717
Err(detail) => {
2718+
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26422719
result.warnings.push(RewriteWarning {
26432720
code: "redirect_uv_metadata_unsupported".into(),
26442721
detail: format!("{path}: {detail}"),
26452722
});
26462723
continue;
26472724
}
26482725
};
2726+
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
26492727
if let Some(edit) = metadata_edit {
26502728
record_python_metadata_edit(edit, dep, result);
26512729
}
@@ -13132,3 +13210,107 @@ mod python_lock_warning_tests {
1313213210
);
1313313211
}
1313413212
}
13213+
13214+
#[cfg(test)]
13215+
mod hatch_tests {
13216+
use super::*;
13217+
13218+
fn patch() -> DepOverride {
13219+
DepOverride {
13220+
ecosystem: "pypi".into(),
13221+
name: "urllib3".into(),
13222+
namespace: None,
13223+
version: "1.26.18".into(),
13224+
token: String::new(),
13225+
patch_uuid: "test-uuid".into(),
13226+
artifact_url: "https://patch.test/urllib3-1.26.18-py2.py3-none-any.whl".into(),
13227+
berry_zip_url: None,
13228+
registry_override: None,
13229+
integrity: Integrity {
13230+
sha256: Some("a".repeat(64)),
13231+
..Default::default()
13232+
},
13233+
}
13234+
}
13235+
13236+
#[test]
13237+
fn hatch_confirmation_ignores_inactive_sources_and_comments() {
13238+
let dep = patch();
13239+
let files = [
13240+
("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}#sha256={}\"]\n", dep.artifact_url, "a".repeat(64))),
13241+
("hatch.toml".into(), format!("[envs.default]\ndependencies=[\"urllib3>=1\"]\n# {}\n", dep.artifact_url)),
13242+
].into_iter().collect();
13243+
let result = rewrite_registry_redirect(&files, &[dep]);
13244+
assert!(result.hatch_uuids.contains("test-uuid"));
13245+
assert!(result.confirmed_hatch_uuids.is_empty());
13246+
assert!(result.files.is_empty());
13247+
assert!(result
13248+
.warnings
13249+
.iter()
13250+
.any(|warning| warning.code == "redirect_hatch_unsupported"));
13251+
let mut files = files;
13252+
files.insert("requirements.txt".into(), String::new());
13253+
let result = rewrite_registry_redirect(&files, &[patch()]);
13254+
assert!(result.hatch_uuids.contains("test-uuid"));
13255+
assert!(result.confirmed_hatch_uuids.is_empty());
13256+
files.insert("requirements.txt".into(), "urllib3==1.26.18\n".into());
13257+
let result = rewrite_registry_redirect(&files, &[patch()]);
13258+
assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
13259+
}
13260+
13261+
#[test]
13262+
fn hatch_confirmation_uses_successful_lock_writers() {
13263+
let base: BTreeMap<String, String> = [
13264+
("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}\"]\n", patch().artifact_url)),
13265+
("hatch.toml".into(), "[envs.default]\ndependencies=[\"urllib3>=1\"]\n".into()),
13266+
].into_iter().collect();
13267+
for (filename, text) in [
13268+
("uv.lock", "version = 2"),
13269+
("pylock.toml", "lock-version = '2.0'"),
13270+
("pdm.lock", "[metadata]\nlock_version = '4.5.1'"),
13271+
("Pipfile.lock", "{}"),
13272+
(
13273+
"poetry.lock",
13274+
include_str!("../../../tests/fixtures/poetry/0.12.17/poetry.lock"),
13275+
),
13276+
] {
13277+
let mut files = base.clone();
13278+
files.insert(filename.into(), text.into());
13279+
let result = rewrite_registry_redirect(&files, &[patch()]);
13280+
assert!(result.hatch_uuids.contains("test-uuid"), "{filename}");
13281+
assert!(result.confirmed_hatch_uuids.is_empty(), "{filename}");
13282+
assert!(result.confirmed_python_lock_uuids.is_empty(), "{filename}");
13283+
}
13284+
let mut files = base;
13285+
files.insert(
13286+
"poetry.lock".into(),
13287+
include_str!("../../../tests/fixtures/poetry/1.0.10/poetry.lock").into(),
13288+
);
13289+
let result = rewrite_registry_redirect(&files, &[patch()]);
13290+
assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
13291+
assert!(result.refused_python_lock_uuids.is_empty());
13292+
files.extend(result.files);
13293+
let result = rewrite_registry_redirect(&files, &[patch()]);
13294+
assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
13295+
assert!(result.files.is_empty());
13296+
files.insert("uv.lock".into(), "version = 2".into());
13297+
let result = rewrite_registry_redirect(&files, &[patch()]);
13298+
assert!(result.refused_python_lock_uuids.contains("test-uuid"));
13299+
}
13300+
13301+
#[test]
13302+
fn hatch_confirmation_requires_success_and_reruns_stay_confirmed() {
13303+
let files = [(
13304+
"pyproject.toml".into(),
13305+
"[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n".into(),
13306+
)]
13307+
.into_iter()
13308+
.collect();
13309+
let result = rewrite_registry_redirect(&files, &[patch()]);
13310+
assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
13311+
let second = rewrite_registry_redirect(&result.files, &[patch()]);
13312+
assert!(second.confirmed_hatch_uuids.contains("test-uuid"));
13313+
assert!(second.files.is_empty());
13314+
}
13315+
}
13316+

‎crates/socket-patch-core/src/patch/redirect/poetry.rs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub(super) fn rewrite_poetry(
4646
// Intake gate ONCE per dep, not once per lock file (uv parity).
4747
let mut usable: Vec<(&DepOverride, &str)> = Vec::new();
4848
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
49+
result.python_lock_uuids.insert(dep.patch_uuid.clone());
4950
match dep.integrity.sha256.as_deref() {
5051
Some(sha256) => usable.push((dep, sha256)),
5152
None => result.warnings.push(RewriteWarning {
@@ -83,13 +84,15 @@ pub(super) fn rewrite_poetry(
8384
}
8485
}
8586
Err(detail) => {
87+
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
8688
result.warnings.push(RewriteWarning {
8789
code: "redirect_poetry_lock_unsupported".into(),
8890
detail: format!("{path}: {detail}"),
8991
});
9092
continue;
9193
}
9294
}
95+
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
9396
content = rewritten;
9497
if !stale_warned {
9598
if let Some(format) = pre_1_4_writer(&content) {
@@ -122,15 +125,20 @@ pub(super) fn rewrite_poetry(
122125
}
123126
}
124127
// Already redirected to this artifact (idempotent re-scan).
125-
Ok(Some(_)) => {}
128+
Ok(Some(_)) => {
129+
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
130+
}
126131
Ok(None) => result.warnings.push(RewriteWarning {
127132
code: "redirect_poetry_entry_not_found".into(),
128133
detail: format!("no {path} entry for {}@{}", dep.name, dep.version),
129134
}),
130-
Err(detail) => result.warnings.push(RewriteWarning {
131-
code: "redirect_poetry_lock_unsupported".into(),
132-
detail: format!("{path}: {detail}"),
133-
}),
135+
Err(detail) => {
136+
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
137+
result.warnings.push(RewriteWarning {
138+
code: "redirect_poetry_lock_unsupported".into(),
139+
detail: format!("{path}: {detail}"),
140+
});
141+
}
134142
}
135143
}
136144
if content != *original {

0 commit comments

Comments
 (0)