Skip to content

Commit 6aa4b77

Browse files
committed
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
1 parent 3bc3bb5 commit 6aa4b77

4 files changed

Lines changed: 99 additions & 0 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,28 @@ mod tests {
748748
tokio::fs::read_to_string(root.join(rel)).await.unwrap()
749749
}
750750

751+
#[tokio::test]
752+
async fn hatch_documents_revert_after_checkout_newline_conversion() {
753+
let original = "[project]\ndependencies=[\"one==1\"]\n[tool.hatch.envs.default]\n";
754+
let files = [("pyproject.toml".to_owned(), original.to_owned())].into_iter().collect();
755+
let patched = crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl").unwrap().remove("pyproject.toml").unwrap();
756+
for drift in [false, true] {
757+
let dir = TempDir::new().unwrap();
758+
let live = if drift {patched.replace("one.whl", "changed.whl")} else {patched.replace('\n', "\r\n")};
759+
write(dir.path(), "pyproject.toml", &live).await;
760+
let mut state = state_with(vec![edit("pyproject.toml", "redirect_hatch_document", "rewritten", Some(original), Some(&patched))], &["pkg:pypi/one@1"]);
761+
let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
762+
assert_eq!(outcome.fully_reverted(), !drift);
763+
if drift {
764+
assert_eq!(read(dir.path(), "pyproject.toml").await, live);
765+
assert_eq!(state.edits.len(), 1);
766+
} else {
767+
assert_eq!(read(dir.path(), "pyproject.toml").await, original.replace('\n', "\r\n"));
768+
assert!(state.edits.is_empty());
769+
}
770+
}
771+
}
772+
751773
// ---------- ReplaceFragment ----------
752774

753775
#[tokio::test]

‎crates/socket-patch-core/src/utils/hatch.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,42 @@ pub fn is_hatch(files: &BTreeMap<String, String>) -> bool {
2323
})
2424
}
2525

26+
pub fn has_environment_dependency(files: &BTreeMap<String, String>, name: &str) -> bool {
27+
let external = files
28+
.get("hatch.toml")
29+
.and_then(|text| text.parse::<DocumentMut>().ok());
30+
let project = files
31+
.get("pyproject.toml")
32+
.and_then(|text| text.parse::<DocumentMut>().ok());
33+
let environments = external
34+
.as_ref()
35+
.and_then(|document| document.get("envs"))
36+
.or_else(|| {
37+
project
38+
.as_ref()
39+
.and_then(|document| document.get("tool"))
40+
.and_then(|tool| tool.get("hatch"))
41+
.and_then(|hatch| hatch.get("envs"))
42+
});
43+
environments
44+
.and_then(Item::as_table_like)
45+
.is_some_and(|environments| {
46+
environments.iter().any(|(_, environment)| {
47+
["dependencies", "extra-dependencies"].iter().any(|key| {
48+
environment
49+
.get(key)
50+
.and_then(Item::as_array)
51+
.is_some_and(|dependencies| {
52+
dependencies.iter().filter_map(Value::as_str).any(|spec| {
53+
canonicalize_pypi_name(pep508_name(spec))
54+
== canonicalize_pypi_name(name)
55+
})
56+
})
57+
})
58+
})
59+
})
60+
}
61+
2662
fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result<Option<String>, String> {
2763
let declared = pep508_name(spec);
2864
if canonicalize_pypi_name(declared) != name {

‎crates/socket-patch-core/src/vendor/pypi_hatch.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ pub(super) async fn load(
9898
});
9999
let changes = hatch::rewrite(&files, name, version, &url)
100100
.map_err(|error| ("pypi_hatch_unsupported", error))?;
101+
if hatch::has_environment_dependency(&files, name) {
102+
require_environment_context_support(root).await?;
103+
}
101104
let in_sync = pin.is_some() && changes.is_empty();
102105
Ok(HatchProject {
103106
files,
@@ -106,6 +109,30 @@ pub(super) async fn load(
106109
})
107110
}
108111

112+
async fn require_environment_context_support(root: &Path) -> Result<(), Failure> {
113+
let output = tokio::time::timeout(
114+
std::time::Duration::from_secs(10),
115+
tokio::process::Command::new("hatch")
116+
.arg("--version")
117+
.current_dir(root)
118+
.stdin(std::process::Stdio::null())
119+
.kill_on_drop(true)
120+
.output(),
121+
)
122+
.await;
123+
if let Ok(Ok(output)) = output {
124+
if output.status.success()
125+
&& String::from_utf8_lossy(&output.stdout)
126+
.split_whitespace()
127+
.filter_map(|word| semver::Version::parse(word).ok())
128+
.any(|version| version >= semver::Version::new(1, 2, 0))
129+
{
130+
return Ok(());
131+
}
132+
}
133+
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()))
134+
}
135+
109136
async fn write_files(
110137
root: &Path,
111138
original: &BTreeMap<String, String>,
@@ -317,6 +344,18 @@ mod tests {
317344
assert!(load(root, "one", "1", UUID).await.unwrap().in_sync);
318345
}
319346

347+
#[cfg(unix)]
348+
#[tokio::test]
349+
async fn symlinked_configuration_is_refused_without_touching_target() {
350+
let temp = tempfile::tempdir().unwrap();
351+
let external = tempfile::tempdir().unwrap();
352+
let target = external.path().join("pyproject.toml");
353+
tokio::fs::write(&target, ORIGINAL).await.unwrap();
354+
std::os::unix::fs::symlink(&target, temp.path().join("pyproject.toml")).unwrap();
355+
assert!(load(temp.path(), "one", "1", UUID).await.is_err());
356+
assert_eq!(tokio::fs::read_to_string(target).await.unwrap(), ORIGINAL);
357+
}
358+
320359
#[tokio::test]
321360
async fn concurrent_edits_are_not_overwritten() {
322361
let temp = tempfile::tempdir().unwrap();

‎docs/testing/hatch.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Hatch environment `dependencies` / `extra-dependencies`. External
66
`hatch.toml` tables override the corresponding top-level `tool.hatch` keys.
77
Project references enable Hatchling's `allow-direct-references` setting.
88
Vendored references use `{root:uri}` so checkouts remain relocatable.
9+
Environment references require Hatch >=1.2 on PATH; preflight verifies the
10+
installed version because Hatch 1.0 and 1.1 do not expand that context.
911
Both modes pin the wheel SHA-256, preserve extras, markers, comments and
1012
line endings, and record reversible document edits.
1113

0 commit comments

Comments
 (0)