Watch
0
0
Fork
You've already forked hyperhive
0

refuse a diff that makes agents.md anything but a regular file

This commit is contained in:
atlas 2026-10-18 05:37:28 +02:00 • committed by mara
commit e59c01059a

View file

@ -204,6 +204,13 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply {
if at != apply.base_revision {
return ApplyReply::Stale { revision: at };
}
if let Some(mode) = non_regular_mode(&apply.diff) {
return ApplyReply::Rejected {
error: format!(
"the diff sets file mode {mode}; AGENTS.md must stay a regular file (100644)"
),
};
}
let work = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => return failed("creating a directory to apply in", &e),
@ -234,6 +241,12 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply {
error: format!("the diff must change AGENTS.md and nothing else; it left {left:?}"),
};
}
// Belt and braces for the mode check above: never read through a link.
if !std::fs::symlink_metadata(&copy).is_ok_and(|m| m.file_type().is_file()) {
return ApplyReply::Rejected {
error: "the diff left AGENTS.md as something other than a regular file".to_owned(),
};
}
let edited = match std::fs::read(&copy) {
Ok(bytes) => bytes,
Err(e) => return failed("reading the applied AGENTS.md", &e),
@ -248,8 +261,20 @@ fn apply_diff(state_dir: &Path, apply: &Apply) -> ApplyReply {
/// Run `git <args>` in `dir` with `diff` on stdin. The outer error is git not
/// running; the inner one is git's stderr when it exits non-zero.
/// The first file mode a diff header sets that is not `100644`, if any.
fn non_regular_mode(diff: &str) -> Option<&str> {
diff.lines()
.filter_map(|l| {
l.strip_prefix("new file mode ")
.or_else(|| l.strip_prefix("new mode "))
})
.map(str::trim)
.find(|m| *m != "100644")
}
fn git(dir: &Path, args: &[&str], diff: &str) -> std::io::Result<Result<(), String>> {
let mut child = std::process::Command::new("git")
.args(["-c", "core.symlinks=false"])
.args(args)
.current_dir(dir)
// Outside a repository `git apply` patches plain files, as `patch`
@ -396,6 +421,50 @@ mod tests {
assert_eq!(file(&dir), "# atlas\n");
}
/// A symlink diff must not make the harness read the link's target into
/// AGENTS.md, which the mirror then publishes.
#[test]
fn a_diff_that_creates_a_symlink_is_refused_and_nothing_is_read_through_it() {
let secret = tempfile::tempdir().expect("tempdir");
let target = secret.path().join("secret");
std::fs::write(&target, "do not publish\n").expect("write");
let link = format!(
"diff --git a/AGENTS.md b/AGENTS.md\nnew file mode 120000\n--- /dev/null\n+++ b/AGENTS.md\n@@ -0,0 +1 @@\n+{}\n\\ No newline at end of file\n",
target.display()
);
// Fresh create.
let dir = tempfile::tempdir().expect("tempdir");
let reply = apply_diff(dir.path(), &apply("", &link));
assert!(matches!(reply, ApplyReply::Rejected { .. }), "{reply:?}");
assert!(!dir.path().join("AGENTS.md").exists());
// Typechange of an existing file.
let dir = state(BEFORE);
let typechange = format!(
"diff --git a/AGENTS.md b/AGENTS.md\ndeleted file mode 100644\n--- a/AGENTS.md\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-# atlas\n-keep notes short\n{link}"
);
let reply = apply_diff(dir.path(), &apply(BEFORE, &typechange));
assert!(matches!(reply, ApplyReply::Rejected { .. }), "{reply:?}");
assert_eq!(file(&dir), BEFORE);
}
/// The mode check is the first layer; with it out of the way git must
/// still not write a link (`core.symlinks=false`) and the regular-file
/// check must hold.
#[test]
fn a_link_never_survives_the_apply_even_without_the_mode_check() {
let dir = tempfile::tempdir().expect("tempdir");
let work = tempfile::tempdir().expect("tempdir");
let diff = "diff --git a/AGENTS.md b/AGENTS.md\nnew file mode 120000\n--- /dev/null\n+++ b/AGENTS.md\n@@ -0,0 +1 @@\n+/etc/hostname\n\\ No newline at end of file\n";
super::git(work.path(), &["apply"], diff)
.expect("git runs")
.expect("applies");
let meta = std::fs::symlink_metadata(work.path().join("AGENTS.md")).expect("exists");
assert!(meta.file_type().is_file(), "git wrote a link");
drop(dir);
}
/// The next mirror write, or `None` after ten seconds without one.
async fn next(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Mirror>) -> Option<Mirror> {
tokio::time::timeout(Duration::from_secs(10), rx.recv())