#![cfg(not(unix))]
use std::{
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Component, Path, PathBuf},
};
#[cfg(windows)]
use std::os::windows::{
fs::{MetadataExt, OpenOptionsExt},
io::{AsRawHandle, RawHandle},
};
#[cfg(windows)]
const OPEN_REPARSE_POINT: u32 = 0x0020_0000;
pub(crate) fn symlink_refused(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to follow a symlink or junction at {}",
path.display()
),
)
}
pub(crate) const NOT_REGULAR_PREFIX: &str = "not a regular file: ";
pub(crate) fn not_regular_refused(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{NOT_REGULAR_PREFIX}{}", path.display()),
)
}
pub(crate) fn is_link(path: &Path) -> io::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.file_type().is_symlink()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
#[allow(unreachable_code)]
pub(crate) fn reject_link_components(path: &Path) -> io::Result<()> {
#[cfg(windows)]
{
let handles = open_component_handles(path)?;
revalidate_component_handles(path, &handles)?;
return Ok(());
}
let mut walked = std::path::PathBuf::new();
for component in path.components() {
walked.push(component.as_os_str());
if matches!(component, Component::Prefix(_) | Component::RootDir)
|| walked.parent().is_none()
{
continue;
}
if is_link(&walked)? {
return Err(symlink_refused(&walked));
}
}
Ok(())
}
#[cfg(windows)]
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
#[cfg(windows)]
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
#[cfg(windows)]
#[repr(C)]
struct FileInformation {
file_attributes: u32,
creation_time_low: u32,
creation_time_high: u32,
last_access_time_low: u32,
last_access_time_high: u32,
last_write_time_low: u32,
last_write_time_high: u32,
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
#[cfg(windows)]
#[allow(unsafe_code)]
unsafe extern "system" {
fn GetFileInformationByHandle(handle: RawHandle, information: *mut FileInformation) -> i32;
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn windows_file_information(file: &File) -> io::Result<FileInformation> {
let mut information = std::mem::MaybeUninit::<FileInformation>::uninit();
let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) };
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { information.assume_init() })
}
#[cfg(windows)]
pub(crate) fn windows_file_id(file: &File) -> io::Result<(u64, u64)> {
let information = windows_file_information(file)?;
Ok((
u64::from(information.volume_serial_number),
(u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low),
))
}
#[cfg(windows)]
pub(crate) fn windows_file_links(file: &File) -> io::Result<u32> {
Ok(windows_file_information(file)?.number_of_links)
}
#[cfg(windows)]
fn open_component_handles(path: &Path) -> io::Result<Vec<(File, (u64, u64))>> {
let mut handles = Vec::new();
let mut walked = PathBuf::new();
for component in path.components() {
walked.push(component.as_os_str());
if matches!(component, Component::Prefix(_) | Component::RootDir)
|| walked.parent().is_none()
{
continue;
}
let metadata = match fs::symlink_metadata(&walked) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(error) => return Err(error),
};
if metadata.file_type().is_symlink()
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
{
return Err(symlink_refused(&walked));
}
let handle = OpenOptions::new()
.read(true)
.custom_flags(OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(&walked)?;
let identity = windows_file_id(&handle)?;
handles.push((handle, identity));
}
Ok(handles)
}
#[cfg(windows)]
fn revalidate_component_handles(path: &Path, handles: &[(File, (u64, u64))]) -> io::Result<()> {
let mut walked = PathBuf::new();
let mut index = 0;
for component in path.components() {
walked.push(component.as_os_str());
if matches!(component, Component::Prefix(_) | Component::RootDir)
|| walked.parent().is_none()
{
continue;
}
if index >= handles.len() {
break;
}
let metadata = handles[index].0.metadata()?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| windows_file_id(&handles[index].0)? != handles[index].1
{
return Err(symlink_refused(&walked));
}
index += 1;
}
Ok(())
}
pub(crate) fn open_regular_nofollow(path: &Path) -> io::Result<File> {
reject_link_components(path)?;
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(symlink_refused(path));
}
if !metadata.is_file() {
return Err(not_regular_refused(path));
}
open_leaf_nofollow(path)
}
#[cfg(windows)]
fn open_leaf_nofollow(path: &Path) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.custom_flags(OPEN_REPARSE_POINT)
.open(path)
}
#[cfg(not(windows))]
fn open_leaf_nofollow(path: &Path) -> io::Result<File> {
OpenOptions::new().read(true).open(path)
}
pub(crate) fn read_regular_nofollow(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
let file = open_regular_nofollow(path)?;
let mut bytes = Vec::new();
file.take(limit.saturating_add(1)).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("file exceeds {limit}-byte limit: {}", path.display()),
));
}
Ok(bytes)
}
pub(crate) fn regular_exists(path: &Path) -> io::Result<bool> {
if reject_link_components(path).is_err() {
return Ok(false);
}
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
pub(crate) fn directory_exists(path: &Path) -> io::Result<bool> {
if reject_link_components(path).is_err() {
return Ok(false);
}
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
pub(crate) fn regular_children(root: &Path) -> io::Result<Vec<String>> {
reject_link_components(root)?;
let mut names = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_owned());
}
}
names.sort_unstable();
Ok(names)
}
pub(crate) fn directory_children(root: &Path) -> io::Result<Vec<String>> {
reject_link_components(root)?;
let mut names = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_owned());
}
}
names.sort_unstable();
Ok(names)
}
pub(crate) fn write_no_clobber(path: &Path, bytes: &[u8]) -> io::Result<bool> {
reject_link_components(path)?;
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent directory: {}", path.display()),
)
})?;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no file name: {}", path.display()),
)
})?;
if is_link(path)? {
return Err(symlink_refused(path));
}
let temporary = parent.join(format!(
".{}.shepherd.tmp.{}",
name.to_string_lossy(),
std::process::id()
));
let _ = fs::remove_file(&temporary);
let publish = (|| -> io::Result<bool> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
match fs::hard_link(&temporary, path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false),
Err(error) => Err(error),
}
})();
let _ = fs::remove_file(&temporary);
publish
}
pub(crate) fn replace_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
reject_link_components(path)?;
if is_link(path)? {
return Err(symlink_refused(path));
}
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent directory: {}", path.display()),
)
})?;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no file name: {}", path.display()),
)
})?;
reject_link_components(parent)?;
let temporary = parent.join(format!(
".{}.shepherd.swap.{}",
name.to_string_lossy(),
std::process::id()
));
let _ = fs::remove_file(&temporary);
let result = (|| -> io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
pub(crate) fn ensure_directory(parent: &Path, name: &str) -> io::Result<bool> {
reject_link_components(parent)?;
let target = parent.join(name);
if is_link(&target)? {
return Err(symlink_refused(&target));
}
match fs::create_dir(&target) {
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
if fs::symlink_metadata(&target)?.is_dir() {
Ok(false)
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("not a directory: {}", target.display()),
))
}
}
Err(error) => Err(error),
}
}
pub(crate) fn remove_file_nofollow(path: &Path) -> io::Result<()> {
reject_link_components(path)?;
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
pub(crate) fn remove_directory_nofollow(path: &Path) -> io::Result<()> {
reject_link_components(path)?;
match fs::remove_dir(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn fixture(label: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"shepherd-safe-fs-{label}-{}-{nonce:x}",
std::process::id()
));
fs::create_dir_all(&root).expect("create fixture");
root
}
#[test]
fn no_clobber_publishes_once_and_reports_the_loser() {
let root = fixture("no-clobber");
let target = root.join("record.json");
assert!(write_no_clobber(&target, b"first").expect("first publish"));
assert!(!write_no_clobber(&target, b"second").expect("second publish"));
assert_eq!(fs::read(&target).expect("read back"), b"first");
let leftovers: Vec<_> = fs::read_dir(&root)
.expect("list")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains("shepherd.tmp"))
.collect();
assert!(leftovers.is_empty(), "left temporaries: {leftovers:?}");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn replace_atomic_overwrites_where_no_clobber_refuses() {
let root = fixture("replace");
let target = root.join("run.json");
assert!(write_no_clobber(&target, b"before").expect("publish"));
replace_atomic(&target, b"after").expect("replace");
assert_eq!(fs::read(&target).expect("read back"), b"after");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn read_refuses_an_over_limit_file_instead_of_truncating() {
let root = fixture("limit");
let target = root.join("big.json");
fs::write(&target, vec![b'x'; 64]).expect("write");
assert_eq!(
read_regular_nofollow(&target, 64)
.expect("at the limit")
.len(),
64
);
let error = read_regular_nofollow(&target, 63).expect_err("over the limit");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn absence_and_wrong_type_are_distinguishable() {
let root = fixture("kinds");
let absent = read_regular_nofollow(&root.join("nope.json"), 16).expect_err("absent");
assert_eq!(absent.kind(), io::ErrorKind::NotFound);
let directory = root.join("a-directory");
fs::create_dir(&directory).expect("mkdir");
let wrong = read_regular_nofollow(&directory, 16).expect_err("not a regular file");
assert_eq!(wrong.kind(), io::ErrorKind::InvalidInput);
assert!(
wrong.to_string().starts_with(NOT_REGULAR_PREFIX),
"not-regular refusal must carry the prefix the CLI layer matches: {wrong}"
);
assert!(!regular_exists(&directory).expect("probe"));
assert!(directory_exists(&directory).expect("probe"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn ensure_directory_reports_only_what_it_created() {
let root = fixture("ensure");
assert!(ensure_directory(&root, "runs").expect("first"));
assert!(!ensure_directory(&root, "runs").expect("second"));
fs::write(root.join("occupied"), b"x").expect("write");
let error = ensure_directory(&root, "occupied").expect_err("occupied by a file");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn children_are_sorted_and_split_by_kind() {
let root = fixture("children");
for name in ["c.json", "a.json", "b.json"] {
fs::write(root.join(name), b"{}").expect("write");
}
fs::create_dir(root.join("nested")).expect("mkdir");
assert_eq!(
regular_children(&root).expect("files"),
vec!["a.json", "b.json", "c.json"]
);
assert_eq!(directory_children(&root).expect("dirs"), vec!["nested"]);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn removal_is_idempotent() {
let root = fixture("remove");
let file = root.join("gone.json");
fs::write(&file, b"x").expect("write");
remove_file_nofollow(&file).expect("remove");
remove_file_nofollow(&file).expect("removing an absent file is success");
let directory = root.join("gone-dir");
fs::create_dir(&directory).expect("mkdir");
remove_directory_nofollow(&directory).expect("remove");
remove_directory_nofollow(&directory).expect("removing an absent directory is success");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn a_link_is_refused_rather_than_followed() {
let root = fixture("links");
let secret = root.join("secret.json");
fs::write(&secret, b"{\"secret\":true}").expect("write");
let link = root.join("link.json");
#[cfg(windows)]
let created = std::os::windows::fs::symlink_file(&secret, &link).is_ok();
#[cfg(not(windows))]
let created = false;
if !created {
eprintln!("skipped: this environment cannot create a symlink");
fs::remove_dir_all(root).expect("cleanup");
return;
}
let error = read_regular_nofollow(&link, 64).expect_err("must refuse a link");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(is_link(&link).expect("probe"));
assert!(!regular_exists(&link).expect("probe"));
let through = link.join("child.json");
assert!(reject_link_components(&through).is_err());
let ancestor = read_regular_nofollow(&through, 64).expect_err("ancestor link");
assert_eq!(ancestor.kind(), io::ErrorKind::InvalidInput);
assert!(
!ancestor.to_string().starts_with(NOT_REGULAR_PREFIX),
"an ancestor link must read as a refused link, not as a wrong file type: {ancestor}"
);
fs::remove_dir_all(root).expect("cleanup");
}
}