Menu
AkurAI-Build
publicLatest change fe39ce51fc9c202d5411f39fd9a8a2feeca4a85e - Harden test suite: 16→179 hermetic tests, race-free integration harness by Ólafur Búi Ólafsson
//! Shared hardened filesystem helpers.
//!
//! One implementation of symlink-free opens and descriptor/path identity
//! pinning, used by the artifact, database, and server layers so future
//! fixes to these security-critical checks cannot diverge (architecture
//! review 10, finding 6).
use std::{
fs::{self, File, OpenOptions},
path::Path,
};
use anyhow::{Context, Result, ensure};
/// Open an existing file without following symlinks and require it to be
/// a regular file. `write` additionally requests write access.
pub(crate) fn open_nofollow(path: &Path, write: bool) -> Result<File> {
let mut options = OpenOptions::new();
options.read(true).write(write);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let file = options
.open(path)
.with_context(|| format!("open {} without following symlinks", path.display()))?;
ensure!(
file.metadata()?.is_file(),
"{} is not a regular file",
path.display()
);
Ok(file)
}
/// Re-check that `path` still names exactly the open descriptor `file`
/// (same device and inode, still a regular non-symlink file), closing the
/// verify-then-use window against path swaps.
pub(crate) fn ensure_path_matches_file(path: &Path, file: &File, label: &str) -> Result<()> {
let path_metadata = fs::symlink_metadata(path)
.with_context(|| format!("inspect {label} {}", path.display()))?;
ensure!(
path_metadata.is_file() && !path_metadata.file_type().is_symlink(),
"{label} path changed while in use: {}",
path.display()
);
let file_metadata = file.metadata()?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
ensure!(
path_metadata.dev() == file_metadata.dev()
&& path_metadata.ino() == file_metadata.ino(),
"{label} path changed while in use: {}",
path.display()
);
}
#[cfg(not(unix))]
let _ = file_metadata;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn opens_regular_file() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("data.txt");
fs::write(&path, b"hello")?;
let file = open_nofollow(&path, false)?;
assert!(file.metadata()?.is_file());
Ok(())
}
#[test]
fn opens_file_for_writing() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("data.txt");
fs::write(&path, b"hello")?;
let _file = open_nofollow(&path, true)?;
Ok(())
}
#[test]
fn rejects_symlink() -> Result<()> {
let dir = tempfile::tempdir()?;
let real = dir.path().join("real.txt");
let link = dir.path().join("link.txt");
fs::write(&real, b"hello")?;
symlink(&real, &link)?;
assert!(open_nofollow(&link, false).is_err());
Ok(())
}
#[test]
fn rejects_directory() -> Result<()> {
let dir = tempfile::tempdir()?;
assert!(open_nofollow(dir.path(), false).is_err());
Ok(())
}
#[test]
fn rejects_nonexistent_file() {
let path = std::path::Path::new("/nonexistent/path/file.txt");
assert!(open_nofollow(path, false).is_err());
}
#[test]
fn ensure_path_matches_file_succeeds_for_unchanged() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("data.txt");
fs::write(&path, b"hello")?;
let file = open_nofollow(&path, false)?;
ensure_path_matches_file(&path, &file, "test")?;
Ok(())
}
#[test]
fn ensure_path_matches_file_rejects_swapped_symlink() -> Result<()> {
let dir = tempfile::tempdir()?;
let original = dir.path().join("original.txt");
let outside = std::env::temp_dir().join("fsguard-outside-target.txt");
let _cleanup = std::fs::File::create(&outside)?;
// Write to the real file first, then open it
fs::write(&original, b"original")?;
let file = open_nofollow(&original, false)?;
// Replace the original with a symlink pointing outside
fs::remove_file(&original)?;
symlink(&outside, &original)?;
// The path now points to outside content — should be rejected
assert!(ensure_path_matches_file(&original, &file, "test").is_err());
// Cleanup
let _ = fs::remove_file(&outside);
Ok(())
}
}