use std::io;
use std::path::{Path, PathBuf};
#[cfg(unix)]
const DIR_MODE: u32 = 0o700;
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
pub(crate) fn create_dir_all_private(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(DIR_MODE)
.create(path)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(path)
}
}
pub(crate) fn harden_dir(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode();
if mode & 0o7777 & !DIR_MODE != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub(crate) fn create_new_private(path: &Path) -> io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(FILE_MODE);
}
options.open(path)
}
pub(crate) fn harden_file(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
if meta.permissions().mode() & 0o7777 & !FILE_MODE != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub const TEMP_INFIX: &str = ".faultbox-";
static TEMP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) fn create_temp_beside(
final_path: &Path,
tag: &str,
) -> io::Result<(std::fs::File, PathBuf)> {
let mut last = None;
for _ in 0..64 {
let path = temp_path(final_path, tag);
match create_new_private(&path) {
Ok(file) => return Ok((file, path)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
Err(e) => return Err(e),
}
}
Err(last.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::AlreadyExists,
"could not find a free temporary name",
)
}))
}
pub(crate) fn temp_path(final_path: &Path, tag: &str) -> PathBuf {
let sequence = TEMP_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut name = final_path.file_name().map_or_else(
|| std::ffi::OsString::from("faultbox"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(
"{TEMP_INFIX}{tag}.{}.{sequence}.{}",
std::process::id(),
crate::now_ms()
));
match final_path.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
pub(crate) fn create_temp_dir_beside(final_path: &Path, tag: &str) -> io::Result<PathBuf> {
let mut last = None;
for _ in 0..64 {
let path = temp_path(final_path, tag);
match create_dir_exclusive(&path) {
Ok(()) => return Ok(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
Err(e) => return Err(e),
}
}
Err(last.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::AlreadyExists,
"could not find a free temporary name",
)
}))
}
pub(crate) fn create_dir_exclusive(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(false)
.mode(DIR_MODE)
.create(path)
}
#[cfg(not(unix))]
{
std::fs::DirBuilder::new().recursive(false).create(path)
}
}
const RESERVED_NAMES: &[&str] = &[".lock", "report.json", "latest.json", "minidump"];
pub fn validate_artifact_name(name: &str) -> io::Result<()> {
let reject = |why: &str| {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid artifact name {name:?}: {why}"),
))
};
if name.is_empty() {
return reject("empty");
}
if name.len() > 255 {
return reject("longer than 255 bytes");
}
if name == "." || name == ".." {
return reject("a path traversal component");
}
if name.starts_with('.') {
return reject("names beginning with a dot are reserved");
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return reject("must be a single path component of ASCII letters, digits, '.', '_' or '-'");
}
if RESERVED_NAMES.iter().any(|r| name.eq_ignore_ascii_case(r)) {
return reject("reserved for the recorder's own files");
}
if name.contains(TEMP_INFIX) {
return reject("reserved: it marks the recorder's own temporary files");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn traversal_and_absolute_names_are_refused() {
for name in [
"../escape",
"../../escape",
"a/b",
"a\\b",
"/etc/passwd",
"C:\\windows",
"stream:ads",
".",
"..",
"",
".hidden",
] {
assert!(
validate_artifact_name(name).is_err(),
"{name:?} must be refused"
);
}
}
#[test]
fn the_recorders_own_files_cannot_be_claimed() {
for name in [".lock", "report.json", "latest.json", "REPORT.JSON"] {
assert!(
validate_artifact_name(name).is_err(),
"{name:?} must be refused"
);
}
}
#[test]
fn a_nul_byte_cannot_hide_inside_a_name() {
assert!(validate_artifact_name("snap\0.db").is_err());
}
#[test]
fn ordinary_artifact_names_are_accepted() {
for name in [
"snap",
"store.corrupt",
"main.db",
"seg-0_1.bin",
"db.tmp.1",
"restore.incoming.2",
] {
assert!(
validate_artifact_name(name).is_ok(),
"{name:?} must be accepted"
);
}
}
#[test]
fn an_artifact_cannot_carry_the_temporary_marker() {
assert!(validate_artifact_name("snap.faultbox-incoming.1").is_err());
assert!(
temp_path(Path::new("/r/snap.db"), "incoming")
.file_name()
.unwrap()
.to_string_lossy()
.contains(TEMP_INFIX),
"the sweep and the namer must agree on the marker"
);
}
#[cfg(unix)]
#[test]
fn created_directories_and_files_are_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("nested/group");
create_dir_all_private(&dir).unwrap();
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
0o700
);
let (file, path) = create_temp_beside(&dir.join("report.json"), "tmp").unwrap();
drop(file);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777,
0o600
);
}
#[cfg(unix)]
#[test]
fn a_planted_symlink_is_refused_rather_than_followed() {
let tmp = tempfile::tempdir().unwrap();
let victim = tmp.path().join("victim");
std::fs::write(&victim, b"original").unwrap();
let planted = tmp.path().join("planted");
std::os::unix::fs::symlink(&victim, &planted).unwrap();
let err = create_new_private(&planted).expect_err("must refuse a symlink");
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read(&victim).unwrap(),
b"original",
"the target must not have been written through"
);
}
#[cfg(unix)]
#[test]
fn harden_dir_narrows_a_world_readable_directory() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("legacy");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
harden_dir(&dir);
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
0o700
);
}
#[test]
fn temporary_names_do_not_repeat() {
let base = Path::new("/tmp/reports/report.json");
let a = temp_path(base, "tmp");
let b = temp_path(base, "tmp");
assert_ne!(a, b, "two temporaries must never collide");
assert_eq!(a.parent(), base.parent(), "staged beside the destination");
}
}