use std::path::{Path, PathBuf, Prefix};
#[derive(Debug, thiserror::Error)]
#[error("refusing to write through symlink/reparse-point at {}", path.display())]
pub struct SymlinkRefusal {
pub path: PathBuf,
}
#[must_use]
pub fn extract_symlink_refusal<'a>(
err: &'a (dyn std::error::Error + 'static),
) -> Option<&'a SymlinkRefusal> {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(cause) = current {
if let Some(refusal) = cause.downcast_ref::<SymlinkRefusal>() {
return Some(refusal);
}
if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
if let Some(inner) = io_err.get_ref() {
if let Some(refusal) = inner.downcast_ref::<SymlinkRefusal>() {
return Some(refusal);
}
}
}
current = cause.source();
}
None
}
fn is_unc_or_extended_length_prefix(p: &Path) -> bool {
let Some(first) = p.components().next() else {
return false;
};
let std::path::Component::Prefix(prefix) = first else {
return false;
};
let has_named_segment = p
.components()
.any(|c| matches!(c, std::path::Component::Normal(_)));
if has_named_segment {
return false;
}
matches!(
prefix.kind(),
Prefix::Verbatim(_)
| Prefix::VerbatimUNC(_, _)
| Prefix::VerbatimDisk(_)
| Prefix::UNC(_, _)
| Prefix::DeviceNS(_)
)
}
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_unc_or_extended_length_prefix(p) {
return None;
}
if is_reparse_point(p) {
return Some(p.to_path_buf());
}
cur = p.parent();
}
None
}
pub fn precheck_no_reparse_point_ancestor(path: &Path) -> std::io::Result<()> {
if let Some(ancestor) = find_reparse_point_ancestor(path) {
return Err(std::io::Error::other(SymlinkRefusal { path: ancestor }));
}
Ok(())
}
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");
}
#[cfg(windows)]
#[test]
fn ancestor_walk_terminates_on_verbatim_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
let std_path = dir.path();
let s = std_path.to_string_lossy();
let verbatim = if s.starts_with("\\\\?\\") {
std_path.to_path_buf()
} else {
std::path::PathBuf::from(format!("\\\\?\\{s}"))
};
let target = verbatim.join("file.txt");
std::fs::write(std_path.join("file.txt"), b"clean").expect("seed");
let result = find_reparse_point_ancestor(&target);
assert!(
result.is_none(),
"clean verbatim-prefix chain must return None; got {result:?}"
);
}
#[cfg(windows)]
#[test]
fn ancestor_walk_detects_reparse_under_verbatim_prefix() {
use std::os::windows::fs::symlink_dir;
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape tempdir");
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 s = dir.path().to_string_lossy();
let verbatim_root = if s.starts_with("\\\\?\\") {
dir.path().to_path_buf()
} else {
std::path::PathBuf::from(format!("\\\\?\\{s}"))
};
let target = verbatim_root.join(".cordance").join("pack.json");
let result = find_reparse_point_ancestor(&target);
assert!(
result.is_some(),
"junction-redirected verbatim-prefix path must be refused; got None"
);
}
#[cfg(windows)]
#[test]
fn is_unc_prefix_detects_bare_verbatim_disk() {
let p = std::path::PathBuf::from("\\\\?\\C:");
assert!(
is_unc_or_extended_length_prefix(&p),
"bare \\\\?\\C: must be detected as a stop-point prefix"
);
}
#[cfg(windows)]
#[test]
fn is_unc_prefix_does_not_match_real_paths() {
let p = std::path::PathBuf::from("\\\\?\\C:\\Users\\foo");
assert!(
!is_unc_or_extended_length_prefix(&p),
"real \\\\?\\C:\\Users\\foo must NOT be a stop-point (has components past prefix)"
);
}
#[cfg(windows)]
#[test]
fn is_unc_prefix_detects_bare_unc_share() {
let p = std::path::PathBuf::from("\\\\server\\share");
assert!(
is_unc_or_extended_length_prefix(&p),
"bare \\\\server\\share must be detected as a stop-point prefix"
);
}
#[cfg(not(windows))]
#[test]
fn is_unc_prefix_returns_false_on_posix() {
let p = std::path::PathBuf::from("/tmp/foo/bar");
assert!(
!is_unc_or_extended_length_prefix(&p),
"POSIX paths must never be classified as UNC/verbatim"
);
let bare = std::path::PathBuf::from("/");
assert!(
!is_unc_or_extended_length_prefix(&bare),
"POSIX root must never be classified as UNC/verbatim"
);
}
#[test]
fn extract_symlink_refusal_finds_direct_refusal() {
let refusal = SymlinkRefusal {
path: std::path::PathBuf::from("/tmp/junction"),
};
let extracted = extract_symlink_refusal(&refusal);
assert!(extracted.is_some(), "direct refusal must be extractable");
assert_eq!(
extracted.expect("some").path,
std::path::PathBuf::from("/tmp/junction")
);
}
#[test]
fn extract_symlink_refusal_finds_io_wrapped_refusal() {
let refusal = SymlinkRefusal {
path: std::path::PathBuf::from("/tmp/.cordance"),
};
let io_err = std::io::Error::other(refusal);
let extracted =
extract_symlink_refusal(&io_err).expect("io-wrapped refusal must be extractable");
assert_eq!(extracted.path, std::path::PathBuf::from("/tmp/.cordance"));
}
#[derive(Debug)]
struct WithContext<E: std::error::Error + 'static> {
msg: &'static str,
source: E,
}
impl<E: std::error::Error + 'static> std::fmt::Display for WithContext<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.msg)
}
}
impl<E: std::error::Error + 'static> std::error::Error for WithContext<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[test]
fn extract_symlink_refusal_walks_context_chain() {
let refusal = SymlinkRefusal {
path: std::path::PathBuf::from("/tmp/.cordance"),
};
let io_err = std::io::Error::other(refusal);
let wrapped = WithContext {
msg: "emitter 'claude-code:claude-md' failed",
source: io_err,
};
let extracted =
extract_symlink_refusal(&wrapped).expect("must extract through context chain");
assert_eq!(extracted.path, std::path::PathBuf::from("/tmp/.cordance"));
}
#[test]
fn extract_symlink_refusal_returns_none_for_unrelated_error() {
let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
assert!(extract_symlink_refusal(&err).is_none());
}
#[test]
fn symlink_refusal_display_includes_ancestor_path() {
let refusal = SymlinkRefusal {
path: std::path::PathBuf::from("/tmp/.cordance"),
};
let rendered = format!("{refusal}");
assert!(
rendered.contains("/tmp/.cordance") || rendered.contains("\\tmp\\.cordance"),
"Display must include ancestor path; got: {rendered}"
);
assert!(
rendered.contains("symlink") || rendered.contains("reparse-point"),
"Display must indicate the refusal class; got: {rendered}"
);
}
#[test]
fn precheck_returns_ok_for_clean_path() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("a").join("b").join("out.json");
precheck_no_reparse_point_ancestor(&target).expect("clean path must pre-check OK");
}
#[cfg(unix)]
#[test]
fn precheck_returns_err_for_symlinked_ancestor() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let escape = tempfile::tempdir().expect("escape");
let dotcordance = dir.path().join(".cordance");
symlink(escape.path(), &dotcordance).expect("plant symlink");
let target = dotcordance.join("pack.json");
let err = precheck_no_reparse_point_ancestor(&target)
.expect_err("planted symlink must fail pre-check");
let refusal = err
.get_ref()
.and_then(|e| e.downcast_ref::<SymlinkRefusal>())
.expect("pre-check err must carry SymlinkRefusal");
assert_eq!(refusal.path, dotcordance);
}
}