pub use imp::*;
#[cfg(not(target_family = "wasm"))]
mod imp {
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
pub fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
fn refused(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to write through a symlink at {} (nothing under a Treeship store may point elsewhere)",
path.display()
),
)
}
fn refused_hardlink(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to write to {}: the file has more than one name (a hard link), so the write would land elsewhere too",
path.display()
),
)
}
pub fn refuse_symlink(path: &Path) -> io::Result<()> {
if is_symlink(path) {
return Err(refused(path));
}
Ok(())
}
pub fn refuse_symlinks_below(root: &Path, path: &Path) -> io::Result<()> {
refuse_symlink(root)?;
let Ok(rest) = path.strip_prefix(root) else {
return refuse_symlink(path);
};
let mut prefix = root.to_path_buf();
for component in rest.components() {
prefix.push(component);
refuse_symlink(&prefix)?;
}
Ok(())
}
pub fn refuse_symlinks_from(path: &Path, anchor: &str) -> io::Result<()> {
let mut prefix = PathBuf::new();
let mut checking = false;
for component in path.components() {
prefix.push(component);
if !checking {
match component {
Component::Normal(name) if name == anchor => checking = true,
_ => continue,
}
}
refuse_symlink(&prefix)?;
}
if !checking {
refuse_symlink(path)?;
}
Ok(())
}
pub fn create_dir_all_below(root: &Path, dir: &Path) -> io::Result<()> {
refuse_symlinks_below(root, dir)?;
fs::create_dir_all(dir)
}
fn refuse_hardlinked(file: &fs::File, path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if file.metadata()?.nlink() > 1 {
return Err(refused_hardlink(path));
}
}
#[cfg(not(unix))]
let _ = (file, path);
Ok(())
}
fn exclusive_options(mode: u32) -> fs::OpenOptions {
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(mode).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(not(unix))]
let _ = mode;
opts
}
pub fn open_rw_nofollow(path: &Path, mode: u32) -> io::Result<fs::File> {
let mut opts = fs::OpenOptions::new();
opts.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(mode).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(not(unix))]
let _ = mode;
opts.open(path)
.map_err(|e| if is_symlink(path) { refused(path) } else { e })
}
pub fn open_append_nofollow(path: &Path, mode: u32) -> io::Result<fs::File> {
let mut opts = fs::OpenOptions::new();
opts.append(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(mode).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(not(unix))]
let _ = mode;
let file = opts
.open(path)
.map_err(|e| if is_symlink(path) { refused(path) } else { e })?;
refuse_hardlinked(&file, path)?;
Ok(file)
}
pub fn write_atomic(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> {
use std::io::Write as _;
refuse_symlink(path)?;
let dir = path.parent().unwrap_or(Path::new("."));
let stem = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into());
let mut last_err = None;
for _ in 0..16 {
let tmp = dir.join(format!(".{stem}.{}.tmp", random_hex(8)));
match exclusive_options(mode).open(&tmp) {
Ok(mut f) => {
let result = f
.write_all(bytes)
.and_then(|_| f.sync_all())
.and_then(|_| fs::rename(&tmp, path));
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
return Err(e);
}
return Ok(());
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_err = Some(e),
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| io::Error::other("could not create a temp file")))
}
pub fn copy_nofollow(from: &Path, to: &Path, mode: u32) -> io::Result<()> {
refuse_symlink(from)?;
let bytes = fs::read(from)?;
write_atomic(to, &bytes, mode)
}
pub fn set_mode_nofollow(path: &Path, mode: u32) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|e| if is_symlink(path) { refused(path) } else { e })?;
if file.metadata()?.is_file() {
refuse_hardlinked(&file, path)?;
}
file.set_permissions(fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
{
let _ = (path, mode);
Ok(())
}
}
fn random_hex(n: usize) -> String {
use rand::RngCore;
let mut b = vec![0u8; n];
rand::rngs::OsRng.fill_bytes(&mut b);
b.iter().map(|x| format!("{x:02x}")).collect()
}
}
#[cfg(target_family = "wasm")]
mod imp {
use std::fs;
use std::io;
use std::path::Path;
pub fn is_symlink(_path: &Path) -> bool {
false
}
pub fn refuse_symlink(_path: &Path) -> io::Result<()> {
Ok(())
}
pub fn refuse_symlinks_below(_root: &Path, _path: &Path) -> io::Result<()> {
Ok(())
}
pub fn refuse_symlinks_from(_path: &Path, _anchor: &str) -> io::Result<()> {
Ok(())
}
pub fn create_dir_all_below(_root: &Path, dir: &Path) -> io::Result<()> {
fs::create_dir_all(dir)
}
pub fn open_rw_nofollow(path: &Path, _mode: u32) -> io::Result<fs::File> {
fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
pub fn open_append_nofollow(path: &Path, _mode: u32) -> io::Result<fs::File> {
fs::OpenOptions::new().append(true).create(true).open(path)
}
pub fn write_atomic(path: &Path, bytes: &[u8], _mode: u32) -> io::Result<()> {
fs::write(path, bytes)
}
pub fn copy_nofollow(from: &Path, to: &Path, _mode: u32) -> io::Result<()> {
fs::copy(from, to).map(|_| ())
}
pub fn set_mode_nofollow(_path: &Path, _mode: u32) -> io::Result<()> {
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs;
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn a_linked_final_component_is_refused_everywhere() {
let d = tmp();
let victim = d.path().join("victim");
fs::write(&victim, b"keep").unwrap();
let link = d.path().join("link");
std::os::unix::fs::symlink(&victim, &link).unwrap();
assert!(write_atomic(&link, b"x", 0o600).is_err());
assert!(open_rw_nofollow(&link, 0o600).is_err());
assert!(open_append_nofollow(&link, 0o600).is_err());
assert!(set_mode_nofollow(&link, 0o600).is_err());
assert!(copy_nofollow(&victim, &link, 0o600).is_err());
assert_eq!(fs::read(&victim).unwrap(), b"keep");
assert!(link.is_symlink(), "the link itself was replaced");
use std::os::unix::fs::PermissionsExt;
assert_ne!(
fs::metadata(&victim).unwrap().permissions().mode() & 0o777,
0o600,
"chmod reached the victim through the link"
);
}
#[test]
fn a_hard_link_is_replaced_by_atomic_writes_and_refused_by_appends() {
let d = tmp();
let victim = d.path().join("victim");
fs::write(&victim, b"keep").unwrap();
let ours = d.path().join("ours");
fs::hard_link(&victim, &ours).unwrap();
assert!(open_append_nofollow(&ours, 0o600).is_err());
assert_eq!(fs::read(&victim).unwrap(), b"keep");
write_atomic(&ours, b"new", 0o600).unwrap();
assert_eq!(fs::read(&ours).unwrap(), b"new");
assert_eq!(fs::read(&victim).unwrap(), b"keep");
use std::os::unix::fs::MetadataExt;
assert_eq!(fs::metadata(&victim).unwrap().nlink(), 1);
let hard2 = d.path().join("hard2");
fs::hard_link(&victim, &hard2).unwrap();
assert!(set_mode_nofollow(&hard2, 0o600).is_err());
let log = d.path().join("log");
open_append_nofollow(&log, 0o600).unwrap();
open_append_nofollow(&log, 0o600).unwrap();
}
#[test]
fn links_below_a_root_are_refused_but_the_root_may_sit_under_a_link() {
let d = tmp();
let real = d.path().join("real");
fs::create_dir_all(real.join("store")).unwrap();
let linked_home = d.path().join("home");
std::os::unix::fs::symlink(&real, &linked_home).unwrap();
let root = linked_home.join("store");
assert!(refuse_symlinks_below(&root, &root.join("a.json")).is_ok());
let linked_root = d.path().join("store-link");
std::os::unix::fs::symlink(real.join("store"), &linked_root).unwrap();
assert!(refuse_symlinks_below(&linked_root, &linked_root.join("a.json")).is_err());
std::os::unix::fs::symlink(&real, real.join("store").join("sub")).unwrap();
assert!(refuse_symlinks_below(&root, &root.join("sub").join("x")).is_err());
assert!(create_dir_all_below(&root, &root.join("sub").join("deeper")).is_err());
assert!(create_dir_all_below(&root, &root.join("ok").join("deeper")).is_ok());
}
#[test]
fn anchor_form_checks_from_the_anchor_only() {
let d = tmp();
let real = d.path().join("real");
fs::create_dir_all(real.join(".treeship")).unwrap();
let home = d.path().join("home");
std::os::unix::fs::symlink(&real, &home).unwrap();
assert!(
refuse_symlinks_from(&home.join(".treeship").join("config.json"), ".treeship").is_ok()
);
let elsewhere = d.path().join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
let repo = d.path().join("repo");
fs::create_dir_all(&repo).unwrap();
std::os::unix::fs::symlink(&elsewhere, repo.join(".treeship")).unwrap();
assert!(
refuse_symlinks_from(&repo.join(".treeship").join("config.json"), ".treeship").is_err()
);
let repo2 = d.path().join("repo2");
fs::create_dir_all(repo2.join(".treeship")).unwrap();
std::os::unix::fs::symlink(&elsewhere, repo2.join(".treeship").join("sessions")).unwrap();
assert!(refuse_symlinks_from(
&repo2
.join(".treeship")
.join("sessions")
.join("ssn_x")
.join("events.jsonl"),
".treeship"
)
.is_err());
assert!(refuse_symlinks_from(
&repo2.join(".treeship").join("keys").join("manifest.json"),
".treeship"
)
.is_ok());
}
#[test]
fn atomic_writes_replace_files_and_ignore_planted_temp_links() {
let d = tmp();
let target = d.path().join("data.json");
let victim = d.path().join("victim");
fs::write(&victim, b"keep").unwrap();
std::os::unix::fs::symlink(&victim, d.path().join("data.json.tmp")).unwrap();
write_atomic(&target, b"one", 0o600).unwrap();
write_atomic(&target, b"two", 0o600).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"two");
assert_eq!(fs::read(&victim).unwrap(), b"keep");
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
let leftovers: Vec<_> = fs::read_dir(d.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp") && n != "data.json.tmp")
.collect();
assert!(
leftovers.is_empty(),
"temp files left behind: {leftovers:?}"
);
}
#[test]
fn set_mode_nofollow_works_on_directories_and_files() {
let d = tmp();
let dir = d.path().join("keys");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("k.json");
fs::write(&file, b"{}").unwrap();
set_mode_nofollow(&dir, 0o700).unwrap();
set_mode_nofollow(&file, 0o600).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
}
}