use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[error("refusing to write through symlink/reparse-point at {path:?}")]
pub struct SymlinkRefusal {
pub path: PathBuf,
}
fn is_reparse_point(path: &Path) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return false;
};
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return true;
}
}
false
}
fn find_reparse_point_ancestor(path: &Path) -> Option<PathBuf> {
let mut cur: Option<&Path> = Some(path);
while let Some(p) = cur {
if is_reparse_point(p) {
return Some(p.to_path_buf());
}
cur = p.parent();
}
None
}
pub fn safe_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(ancestor) = find_reparse_point_ancestor(path) {
return Err(std::io::Error::other(SymlinkRefusal { path: ancestor }));
}
std::fs::write(path, bytes)
}
pub fn safe_write_with_mkdir(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(ancestor) = find_reparse_point_ancestor(path) {
return Err(std::io::Error::other(SymlinkRefusal { path: ancestor }));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
safe_write(path, bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_write_creates_new_file_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("new.txt");
assert!(!path.exists(), "precondition: file must not exist");
safe_write(&path, b"hello").expect("write to new path must succeed");
let on_disk = std::fs::read(&path).expect("read back");
assert_eq!(on_disk, b"hello");
}
#[test]
fn safe_write_overwrites_regular_file_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
std::fs::write(&path, b"old content").expect("seed");
safe_write(&path, b"new content").expect("overwrite must succeed");
let on_disk = std::fs::read(&path).expect("read back");
assert_eq!(on_disk, b"new content");
}
#[test]
fn safe_write_with_mkdir_creates_parent_dirs_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("a").join("b").join("c").join("out.json");
assert!(!nested.parent().expect("has parent").exists());
safe_write_with_mkdir(&nested, b"{}").expect("nested mkdir+write");
assert!(nested.exists(), "destination must be created");
let on_disk = std::fs::read(&nested).expect("read back");
assert_eq!(on_disk, b"{}");
}
#[test]
fn safe_write_with_mkdir_overwrites_existing_regular_file_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nested").join("file.txt");
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, b"first").expect("seed");
safe_write_with_mkdir(&path, b"second").expect("overwrite");
assert_eq!(std::fs::read(&path).expect("read"), b"second");
}
#[test]
fn safe_write_errors_when_destination_is_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let subdir = dir.path().join("subdir");
std::fs::create_dir(&subdir).expect("mkdir");
let err = safe_write(&subdir, b"nope").expect_err("must error");
assert!(
err.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.is_none(),
"directory must not be reported as symlink refusal"
);
}
#[cfg(unix)]
#[test]
fn safe_write_refuses_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("target.txt");
std::fs::write(&dest, b"operator owned").expect("seed dest");
let link = dir.path().join("link.txt");
symlink(&dest, &link).expect("symlink");
let err = safe_write(&link, b"attacker bytes").expect_err("must refuse symlink");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(refusal.path, link, "refusal must name the link path");
let on_disk = std::fs::read(&dest).expect("read dest");
assert_eq!(
on_disk, b"operator owned",
"symlink target must NOT be overwritten"
);
}
#[cfg(unix)]
#[test]
fn safe_write_with_mkdir_refuses_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("real.txt");
std::fs::write(&dest, b"operator owned").expect("seed");
let link = dir.path().join("nested").join("link.txt");
std::fs::create_dir_all(link.parent().expect("parent")).expect("mkdir");
symlink(&dest, &link).expect("symlink");
let err =
safe_write_with_mkdir(&link, b"attacker").expect_err("must refuse symlink");
assert!(
err.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.is_some(),
"with_mkdir must also refuse symlinks"
);
assert_eq!(
std::fs::read(&dest).expect("read dest"),
b"operator owned"
);
}
#[cfg(windows)]
#[test]
fn safe_write_refuses_windows_symlink_file() {
use std::os::windows::fs::symlink_file;
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("real.txt");
std::fs::write(&dest, b"operator owned").expect("seed");
let link = dir.path().join("link.txt");
if symlink_file(&dest, &link).is_err() {
eprintln!(
"skipping: cannot create windows symlink (missing privilege / Developer Mode)"
);
return;
}
let err = safe_write(&link, b"attacker").expect_err("must refuse windows symlink");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(refusal.path, link);
assert_eq!(
std::fs::read(&dest).expect("read dest"),
b"operator owned"
);
}
#[cfg(windows)]
#[test]
fn safe_write_refuses_directory_junction() {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("real-dir");
std::fs::create_dir(&dest).expect("mkdir dest");
let junction = dir.path().join("junction");
let status = Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
junction.to_str().expect("utf8"),
dest.to_str().expect("utf8"),
])
.status();
let Ok(status) = status else {
eprintln!("skipping: cmd.exe unavailable");
return;
};
if !status.success() {
eprintln!("skipping: mklink /J failed");
return;
}
let err =
safe_write(&junction, b"attacker").expect_err("must refuse directory junction");
assert!(
err.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.is_some(),
"directory junction must be refused as reparse point"
);
}
#[cfg(unix)]
#[test]
fn safe_write_refuses_when_parent_dir_is_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape tempdir");
std::fs::write(escape.path().join("AGENTS.md"), b"operator owned")
.expect("seed escape file");
let sub = dir.path().join("sub");
symlink(escape.path(), &sub).expect("plant parent symlink");
let target = sub.join("AGENTS.md");
let err =
safe_write(&target, b"attacker bytes").expect_err("must refuse symlinked parent");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(
refusal.path, sub,
"refusal must name the symlinked ancestor, not the leaf"
);
assert_eq!(
std::fs::read(escape.path().join("AGENTS.md")).expect("read escape file"),
b"operator owned",
"operator-owned file must NOT be overwritten through the symlink"
);
}
#[cfg(unix)]
#[test]
fn safe_write_refuses_when_grandparent_is_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape tempdir");
std::fs::create_dir(escape.path().join("inner")).expect("seed escape/inner");
let junction = dir.path().join("junction");
symlink(escape.path(), &junction).expect("plant grandparent symlink");
let target = junction.join("inner").join("pack.json");
let err = safe_write(&target, b"attacker")
.expect_err("must refuse when any ancestor (not just parent) is a symlink");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(
refusal.path, junction,
"refusal must name the symlinked grandparent"
);
assert!(
!escape.path().join("inner").join("pack.json").exists(),
"no file may be written through the symlinked ancestor"
);
}
#[cfg(unix)]
#[test]
fn safe_write_with_mkdir_refuses_symlinked_parent() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape tempdir");
let dotcordance = dir.path().join(".cordance");
symlink(escape.path(), &dotcordance).expect("plant .cordance symlink");
let target = dotcordance.join("nested").join("pack.json");
let err = safe_write_with_mkdir(&target, b"attacker")
.expect_err("must refuse symlinked parent BEFORE create_dir_all");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(
refusal.path, dotcordance,
"refusal must name the symlinked parent"
);
let entries: Vec<_> = std::fs::read_dir(escape.path())
.expect("read escape")
.collect();
assert!(
entries.is_empty(),
"create_dir_all MUST NOT have run through the symlinked parent; \
escape tree must be empty, found: {entries:?}"
);
}
#[cfg(windows)]
#[test]
fn safe_write_refuses_when_parent_dir_is_junction() {
use std::os::windows::fs::symlink_dir;
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape tempdir");
std::fs::write(escape.path().join("operator.txt"), b"operator owned")
.expect("seed escape file");
let dotcordance = dir.path().join(".cordance");
if symlink_dir(escape.path(), &dotcordance).is_err() {
let status = Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
dotcordance.to_str().expect("utf8 junction path"),
escape.path().to_str().expect("utf8 escape path"),
])
.status();
let Ok(status) = status else {
eprintln!("skipping: cmd.exe unavailable");
return;
};
if !status.success() {
eprintln!("skipping: mklink /J failed");
return;
}
}
let target = dotcordance.join("pack.json");
let err = safe_write_with_mkdir(&target, b"attacker")
.expect_err("must refuse junction-redirected parent");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("error must carry SymlinkRefusal");
assert_eq!(
refusal.path, dotcordance,
"refusal must name the junction, not the leaf"
);
assert_eq!(
std::fs::read(escape.path().join("operator.txt")).expect("read operator file"),
b"operator owned",
"operator-owned file must NOT be overwritten through the junction"
);
assert!(
!escape.path().join("pack.json").exists(),
"attacker bytes must NOT have been written through the junction"
);
}
#[test]
fn safe_write_accepts_clean_ancestors() {
let dir = tempfile::tempdir().expect("tempdir");
let deep = dir
.path()
.join("a")
.join("b")
.join("c")
.join("d")
.join("e")
.join("pack.json");
assert!(
!deep.parent().expect("has parent").exists(),
"precondition: deep parent must not exist"
);
safe_write_with_mkdir(&deep, b"{\"ok\":true}")
.expect("clean ancestor chain must succeed");
assert_eq!(
std::fs::read(&deep).expect("read back"),
b"{\"ok\":true}",
"bytes must round-trip through clean ancestor chain"
);
}
#[test]
fn safe_write_accepts_real_directory_overwrite() {
let dir = tempfile::tempdir().expect("tempdir");
let parent = dir.path().join(".cordance");
std::fs::create_dir(&parent).expect("seed real parent dir");
let target = parent.join("pack.json");
std::fs::write(&target, b"old").expect("seed existing file");
safe_write_with_mkdir(&target, b"new").expect("real-dir overwrite must succeed");
assert_eq!(std::fs::read(&target).expect("read"), b"new");
}
}