use bevy::prelude::Resource;
use std::{
error::Error,
ffi::{OsStr, OsString},
fmt::{Display, Formatter},
fs::{self, File, Metadata},
io::{self, Read, Write},
path::{Component, Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use rustix::fs::{Mode, OFlags, fchmod, fsync, openat, renameat, unlinkat};
#[cfg(not(unix))]
use std::fs::OpenOptions;
pub const DEFAULT_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq, Resource)]
pub struct FilesystemConfig {
pub workspace_root: PathBuf,
pub max_file_bytes: u64,
}
impl FilesystemConfig {
pub fn from_workspace_root(
workspace_root: impl AsRef<Path>,
) -> Result<Self, FilesystemConfigError> {
let workspace_root = workspace_root
.as_ref()
.canonicalize()
.map_err(|source| FilesystemConfigError::CanonicalizeRoot { source })?;
if !workspace_root.is_dir() {
return Err(FilesystemConfigError::RootIsNotDirectory { workspace_root });
}
Ok(Self {
workspace_root,
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
})
}
pub fn discover() -> Result<Self, FilesystemConfigError> {
let current_dir = std::env::current_dir()
.map_err(|source| FilesystemConfigError::CurrentDir { source })?;
Self::from_workspace_root(discover_workspace_root(current_dir)?)
}
}
impl Default for FilesystemConfig {
fn default() -> Self {
Self::discover().unwrap_or_else(|_error| Self {
workspace_root: PathBuf::from("."),
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReadTextFile {
pub path: PathBuf,
pub bytes: Vec<u8>,
}
pub fn read_text_file(
requested_path: impl AsRef<Path>,
config: &FilesystemConfig,
) -> Result<ReadTextFile, FileReadError> {
let resolved =
resolve_buffer_path(requested_path.as_ref(), config).map_err(FileReadError::Policy)?;
if !resolved.exists {
return Ok(ReadTextFile {
path: resolved.path,
bytes: Vec::new(),
});
}
let (mut file, metadata) = open_existing_file(&resolved.path, config)?;
if !metadata.is_file() {
return Err(FileReadError::UnsupportedFileType {
path: resolved.path,
});
}
if metadata.len() > config.max_file_bytes {
return Err(FileReadError::TooLarge {
path: resolved.path,
size: metadata.len(),
max_size: config.max_file_bytes,
});
}
let mut bytes = Vec::new();
let mut bounded = (&mut file).take(config.max_file_bytes.saturating_add(1));
let _bytes_read = bounded
.read_to_end(&mut bytes)
.map_err(|source| FileReadError::Read {
path: resolved.path.clone(),
source,
})?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > config.max_file_bytes {
return Err(FileReadError::TooLarge {
path: resolved.path,
size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
max_size: config.max_file_bytes,
});
}
Ok(ReadTextFile {
path: resolved.path,
bytes,
})
}
pub fn atomic_write(
requested_path: impl AsRef<Path>,
bytes: &[u8],
config: &FilesystemConfig,
) -> Result<PathBuf, FileWriteError> {
let resolved =
resolve_buffer_path(requested_path.as_ref(), config).map_err(FileWriteError::Policy)?;
let parent = resolved
.path
.parent()
.ok_or_else(|| FileWriteError::MissingParent {
path: resolved.path.clone(),
})?;
let temp_name = PathBuf::from(unique_temp_file_name(&resolved.path));
let temp_path = parent.join(&temp_name);
let write_result = write_temp_and_rename(
config,
parent,
&temp_name,
&temp_path,
&resolved.path,
bytes,
resolved.exists,
);
if write_result.is_err() {
cleanup_temp_file(config, parent, &temp_name, &temp_path);
}
write_result?;
Ok(resolved.path)
}
#[derive(Debug)]
pub enum FilesystemConfigError {
CurrentDir {
source: io::Error,
},
CanonicalizeRoot {
source: io::Error,
},
RootIsNotDirectory {
workspace_root: PathBuf,
},
}
impl Display for FilesystemConfigError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CurrentDir { source } => {
write!(formatter, "failed to read current directory: {source}")
}
Self::CanonicalizeRoot { source } => {
write!(formatter, "failed to canonicalize workspace root: {source}")
}
Self::RootIsNotDirectory { workspace_root } => {
write!(formatter, "{} is not a directory", workspace_root.display())
}
}
}
}
impl Error for FilesystemConfigError {}
#[derive(Debug)]
pub enum PathPolicyError {
CurrentDir {
source: io::Error,
},
MissingParent {
path: PathBuf,
},
Parent {
path: PathBuf,
source: io::Error,
},
Unresolvable {
path: PathBuf,
source: io::Error,
},
OutsideWorkspace {
path: PathBuf,
workspace_root: PathBuf,
},
}
impl Display for PathPolicyError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CurrentDir { source } => {
write!(formatter, "failed to read current directory: {source}")
}
Self::MissingParent { path } => {
write!(formatter, "{} has no parent directory", path.display())
}
Self::Parent { path, source } => {
write!(
formatter,
"failed to resolve parent for {}: {source}",
path.display()
)
}
Self::Unresolvable { path, source } => {
write!(
formatter,
"failed to safely resolve {}: {source}",
path.display()
)
}
Self::OutsideWorkspace {
path,
workspace_root,
} => write!(
formatter,
"{} is outside workspace root {}",
path.display(),
workspace_root.display()
),
}
}
}
impl Error for PathPolicyError {}
#[derive(Debug)]
pub enum FileReadError {
Policy(PathPolicyError),
Metadata {
path: PathBuf,
source: io::Error,
},
UnsupportedFileType {
path: PathBuf,
},
TooLarge {
path: PathBuf,
size: u64,
max_size: u64,
},
Open {
path: PathBuf,
source: io::Error,
},
Read {
path: PathBuf,
source: io::Error,
},
}
impl Display for FileReadError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Policy(error) => error.fmt(formatter),
Self::Metadata { path, source } => {
write!(
formatter,
"failed to read metadata for {}: {source}",
path.display()
)
}
Self::UnsupportedFileType { path } => {
write!(formatter, "{} is not a regular file", path.display())
}
Self::TooLarge {
path,
size,
max_size,
} => write!(
formatter,
"{} is {size} bytes, exceeding the {max_size} byte limit",
path.display()
),
Self::Open { path, source } => {
write!(formatter, "failed to open {}: {source}", path.display())
}
Self::Read { path, source } => {
write!(formatter, "failed to read {}: {source}", path.display())
}
}
}
}
impl Error for FileReadError {}
#[derive(Debug)]
pub enum FileWriteError {
Policy(PathPolicyError),
Metadata {
path: PathBuf,
source: io::Error,
},
UnsupportedFileType {
path: PathBuf,
},
MissingParent {
path: PathBuf,
},
CreateTemp {
path: PathBuf,
source: io::Error,
},
WriteTemp {
path: PathBuf,
source: io::Error,
},
SyncTemp {
path: PathBuf,
source: io::Error,
},
SetTempPermissions {
path: PathBuf,
source: io::Error,
},
Rename {
temp_path: PathBuf,
target_path: PathBuf,
source: io::Error,
},
}
impl Display for FileWriteError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Policy(error) => error.fmt(formatter),
Self::Metadata { path, source } => {
write!(
formatter,
"failed to read metadata for {}: {source}",
path.display()
)
}
Self::UnsupportedFileType { path } => {
write!(formatter, "{} is not a regular file", path.display())
}
Self::MissingParent { path } => {
write!(formatter, "{} has no parent directory", path.display())
}
Self::CreateTemp { path, source } => {
write!(
formatter,
"failed to create temporary file {}: {source}",
path.display()
)
}
Self::WriteTemp { path, source } => {
write!(
formatter,
"failed to write temporary file {}: {source}",
path.display()
)
}
Self::SyncTemp { path, source } => {
write!(
formatter,
"failed to sync temporary file {}: {source}",
path.display()
)
}
Self::SetTempPermissions { path, source } => {
write!(
formatter,
"failed to set permissions on temporary file {}: {source}",
path.display()
)
}
Self::Rename {
temp_path,
target_path,
source,
} => write!(
formatter,
"failed to rename {} to {}: {source}",
temp_path.display(),
target_path.display()
),
}
}
}
impl Error for FileWriteError {}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ResolvedBufferPath {
path: PathBuf,
exists: bool,
}
fn discover_workspace_root(start: impl AsRef<Path>) -> Result<PathBuf, FilesystemConfigError> {
let start = start
.as_ref()
.canonicalize()
.map_err(|source| FilesystemConfigError::CanonicalizeRoot { source })?;
for ancestor in start.ancestors() {
if ancestor.join(".git").exists() {
return Ok(ancestor.to_owned());
}
}
Ok(start)
}
fn resolve_buffer_path(
requested_path: &Path,
config: &FilesystemConfig,
) -> Result<ResolvedBufferPath, PathPolicyError> {
let current_dir =
std::env::current_dir().map_err(|source| PathPolicyError::CurrentDir { source })?;
resolve_buffer_path_from(requested_path, config, ¤t_dir)
}
fn resolve_buffer_path_from(
requested_path: &Path,
config: &FilesystemConfig,
base_dir: &Path,
) -> Result<ResolvedBufferPath, PathPolicyError> {
let absolute_path = if requested_path.is_absolute() {
requested_path.to_owned()
} else {
base_dir.join(requested_path)
};
match absolute_path.canonicalize() {
Ok(canonical) => {
require_under_root(&canonical, config)?;
Ok(ResolvedBufferPath {
path: canonical,
exists: true,
})
}
Err(error) => {
reject_existing_unresolvable_path(&absolute_path, error)?;
resolve_new_buffer_path(&absolute_path, config)
}
}
}
fn reject_existing_unresolvable_path(
absolute_path: &Path,
canonicalize_error: io::Error,
) -> Result<(), PathPolicyError> {
match fs::symlink_metadata(absolute_path) {
Ok(_metadata) => Err(PathPolicyError::Unresolvable {
path: absolute_path.to_owned(),
source: canonicalize_error,
}),
Err(metadata_error) if metadata_error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(metadata_error) => Err(PathPolicyError::Unresolvable {
path: absolute_path.to_owned(),
source: metadata_error,
}),
}
}
fn open_existing_file(
path: &Path,
config: &FilesystemConfig,
) -> Result<(File, Metadata), FileReadError> {
#[cfg(unix)]
{
open_existing_file_at_workspace_root(path, config).map_err(|source| FileReadError::Open {
path: path.to_owned(),
source,
})
}
#[cfg(not(unix))]
{
let file = File::open(path).map_err(|source| FileReadError::Open {
path: path.to_owned(),
source,
})?;
let metadata = file.metadata().map_err(|source| FileReadError::Metadata {
path: path.to_owned(),
source,
})?;
require_opened_path_identity(path, &metadata, config).map_err(|source| {
FileReadError::Metadata {
path: path.to_owned(),
source,
}
})?;
Ok((file, metadata))
}
}
#[cfg(not(unix))]
fn require_opened_path_identity(
path: &Path,
opened: &Metadata,
config: &FilesystemConfig,
) -> Result<(), io::Error> {
let canonical = path.canonicalize()?;
if !canonical.starts_with(&config.workspace_root) {
return Err(io::Error::other(
"opened file resolved outside workspace root",
));
}
let current = fs::symlink_metadata(path)?;
if same_file_metadata(opened, ¤t) {
Ok(())
} else {
Err(io::Error::other("opened file changed during policy check"))
}
}
#[cfg(not(unix))]
fn same_file_metadata(left: &Metadata, right: &Metadata) -> bool {
left.file_type() == right.file_type() && left.len() == right.len()
}
fn resolve_new_buffer_path(
absolute_path: &Path,
config: &FilesystemConfig,
) -> Result<ResolvedBufferPath, PathPolicyError> {
let parent = absolute_path
.parent()
.ok_or_else(|| PathPolicyError::MissingParent {
path: absolute_path.to_owned(),
})?;
let canonical_parent = parent
.canonicalize()
.map_err(|source| PathPolicyError::Parent {
path: absolute_path.to_owned(),
source,
})?;
require_under_root(&canonical_parent, config)?;
let file_name = absolute_path
.file_name()
.ok_or_else(|| PathPolicyError::MissingParent {
path: absolute_path.to_owned(),
})?;
require_normal_file_name(file_name, absolute_path)?;
Ok(ResolvedBufferPath {
path: canonical_parent.join(file_name),
exists: false,
})
}
fn require_normal_file_name(
file_name: &OsStr,
absolute_path: &Path,
) -> Result<(), PathPolicyError> {
let mut components = Path::new(file_name).components();
if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
Ok(())
} else {
Err(PathPolicyError::MissingParent {
path: absolute_path.to_owned(),
})
}
}
fn require_under_root(path: &Path, config: &FilesystemConfig) -> Result<(), PathPolicyError> {
if path.starts_with(&config.workspace_root) {
Ok(())
} else {
Err(PathPolicyError::OutsideWorkspace {
path: path.to_owned(),
workspace_root: config.workspace_root.clone(),
})
}
}
#[cfg(unix)]
fn open_existing_file_at_workspace_root(
path: &Path,
config: &FilesystemConfig,
) -> Result<(File, Metadata), io::Error> {
let relative_path = path
.strip_prefix(&config.workspace_root)
.map_err(|_error| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"path is outside workspace root",
)
})?;
if relative_path.as_os_str().is_empty() {
let file = open_workspace_directory(Path::new(""), config)?;
let metadata = file.metadata()?;
return Ok((file, metadata));
}
let (parent, file_name) = split_parent_and_file_name(relative_path)?;
let parent_dir = open_workspace_directory(parent, config)?;
let file_fd = openat(
&parent_dir,
file_name,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)?;
let file = File::from(file_fd);
let metadata = file.metadata()?;
Ok((file, metadata))
}
#[cfg(unix)]
fn open_workspace_directory(
relative_dir: &Path,
config: &FilesystemConfig,
) -> Result<File, io::Error> {
let mut current = File::from(openat(
rustix::fs::CWD,
&config.workspace_root,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)?);
for component in normal_components(relative_dir)? {
let next = File::from(openat(
¤t,
component,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)?);
current = next;
}
Ok(current)
}
#[cfg(unix)]
fn split_parent_and_file_name(path: &Path) -> Result<(&Path, &Path), io::Error> {
let file_name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
let parent = path.parent().unwrap_or_else(|| Path::new(""));
Ok((parent, Path::new(file_name)))
}
#[cfg(unix)]
fn normal_components(path: &Path) -> Result<Vec<&Path>, io::Error> {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::Normal(part) => components.push(Path::new(part)),
Component::CurDir => {}
Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"path contains non-normal component",
));
}
}
}
Ok(components)
}
#[cfg(unix)]
fn write_temp_and_rename(
config: &FilesystemConfig,
parent: &Path,
temp_name: &Path,
temp_path: &Path,
target_path: &Path,
bytes: &[u8],
target_exists: bool,
) -> Result<(), FileWriteError> {
let target_name =
target_path
.file_name()
.map(Path::new)
.ok_or_else(|| FileWriteError::MissingParent {
path: target_path.to_owned(),
})?;
let relative_parent = parent
.strip_prefix(&config.workspace_root)
.map_err(|_error| FileWriteError::CreateTemp {
path: temp_path.to_owned(),
source: io::Error::new(
io::ErrorKind::PermissionDenied,
"target parent is outside workspace root",
),
})?;
let parent_dir = open_workspace_directory(relative_parent, config).map_err(|source| {
FileWriteError::CreateTemp {
path: temp_path.to_owned(),
source,
}
})?;
let existing_metadata = if target_exists {
let metadata = existing_file_metadata_at(&parent_dir, target_name, target_path)?;
if !metadata.is_file() {
return Err(FileWriteError::UnsupportedFileType {
path: target_path.to_owned(),
});
}
Some(metadata)
} else {
None
};
let mut temp_file = create_temp_file_at(
&parent_dir,
temp_name,
temp_path,
existing_metadata.as_ref(),
)?;
temp_file
.write_all(bytes)
.map_err(|source| FileWriteError::WriteTemp {
path: temp_path.to_owned(),
source,
})?;
temp_file
.sync_all()
.map_err(|source| FileWriteError::SyncTemp {
path: temp_path.to_owned(),
source,
})?;
drop(temp_file);
renameat(&parent_dir, temp_name, &parent_dir, target_name).map_err(|source| {
FileWriteError::Rename {
temp_path: temp_path.to_owned(),
target_path: target_path.to_owned(),
source: io::Error::from(source),
}
})?;
fsync(&parent_dir).map_err(|source| FileWriteError::SyncTemp {
path: parent.to_owned(),
source: io::Error::from(source),
})?;
Ok(())
}
#[cfg(unix)]
fn existing_file_metadata_at(
parent_dir: &File,
target_name: &Path,
target_path: &Path,
) -> Result<Metadata, FileWriteError> {
let target_fd = openat(
parent_dir,
target_name,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|source| FileWriteError::Metadata {
path: target_path.to_owned(),
source: io::Error::from(source),
})?;
let target_file = File::from(target_fd);
target_file
.metadata()
.map_err(|source| FileWriteError::Metadata {
path: target_path.to_owned(),
source,
})
}
#[cfg(not(unix))]
fn write_temp_and_rename(
_config: &FilesystemConfig,
_parent: &Path,
_temp_name: &Path,
temp_path: &Path,
target_path: &Path,
bytes: &[u8],
target_exists: bool,
) -> Result<(), FileWriteError> {
let existing_metadata = if target_exists {
let metadata = fs::metadata(target_path).map_err(|source| FileWriteError::Metadata {
path: target_path.to_owned(),
source,
})?;
if !metadata.is_file() {
return Err(FileWriteError::UnsupportedFileType {
path: target_path.to_owned(),
});
}
Some(metadata)
} else {
None
};
let mut temp_file = create_temp_file(temp_path, existing_metadata.as_ref())?;
temp_file
.write_all(bytes)
.map_err(|source| FileWriteError::WriteTemp {
path: temp_path.to_owned(),
source,
})?;
temp_file
.sync_all()
.map_err(|source| FileWriteError::SyncTemp {
path: temp_path.to_owned(),
source,
})?;
drop(temp_file);
fs::rename(temp_path, target_path).map_err(|source| FileWriteError::Rename {
temp_path: temp_path.to_owned(),
target_path: target_path.to_owned(),
source,
})?;
if let Some(parent) = target_path.parent() {
let _synced_parent = File::open(parent).and_then(|file| file.sync_all());
}
Ok(())
}
#[cfg(unix)]
fn create_temp_file_at(
parent_dir: &File,
temp_name: &Path,
temp_path: &Path,
existing_metadata: Option<&Metadata>,
) -> Result<File, FileWriteError> {
use std::os::unix::fs::PermissionsExt;
let mode = existing_metadata.map_or(0o600, |metadata| metadata.permissions().mode() & 0o777);
let mode = u16::try_from(mode).unwrap_or(0o600);
let temp_fd = openat(
parent_dir,
temp_name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(mode),
)
.map_err(|source| FileWriteError::CreateTemp {
path: temp_path.to_owned(),
source: io::Error::from(source),
})?;
if existing_metadata.is_some() {
fchmod(&temp_fd, Mode::from_raw_mode(mode)).map_err(|source| {
FileWriteError::SetTempPermissions {
path: temp_path.to_owned(),
source: io::Error::from(source),
}
})?;
}
Ok(File::from(temp_fd))
}
#[cfg(unix)]
fn cleanup_temp_file(config: &FilesystemConfig, parent: &Path, temp_name: &Path, temp_path: &Path) {
if let Ok(relative_parent) = parent.strip_prefix(&config.workspace_root)
&& let Ok(parent_dir) = open_workspace_directory(relative_parent, config)
{
let _removed = unlinkat(&parent_dir, temp_name, rustix::fs::AtFlags::empty());
} else {
let _removed = fs::remove_file(temp_path);
}
}
#[cfg(not(unix))]
fn cleanup_temp_file(
_config: &FilesystemConfig,
_parent: &Path,
_temp_name: &Path,
temp_path: &Path,
) {
let _removed = fs::remove_file(temp_path);
}
#[cfg(not(unix))]
fn create_temp_file(
temp_path: &Path,
existing_metadata: Option<&Metadata>,
) -> Result<File, FileWriteError> {
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(temp_path)
.map_err(|source| FileWriteError::CreateTemp {
path: temp_path.to_owned(),
source,
})?;
if let Some(metadata) = existing_metadata {
file.set_permissions(metadata.permissions())
.map_err(|source| FileWriteError::SetTempPermissions {
path: temp_path.to_owned(),
source,
})?;
}
Ok(file)
}
fn unique_temp_file_name(target_path: &Path) -> OsString {
let name = target_path
.file_name()
.unwrap_or_else(|| OsStr::new("buffer"));
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
OsString::from(format!(
".{}.alma-tmp-{}-{nanos}",
name.to_string_lossy(),
std::process::id()
))
}
#[cfg(test)]
mod tests {
use super::{
FileReadError, FileWriteError, FilesystemConfig, PathPolicyError, atomic_write,
discover_workspace_root, read_text_file, resolve_buffer_path, resolve_buffer_path_from,
};
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
fn temp_dir_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"alma-fs-utils-{name}-{}-{nanos}",
std::process::id()
))
}
fn temp_dir(name: &str) -> PathBuf {
let path = temp_dir_path(name);
fs::create_dir(&path).expect("temporary directory should be created");
path
}
fn config(root: &Path) -> FilesystemConfig {
FilesystemConfig {
workspace_root: root.canonicalize().expect("root should canonicalize"),
max_file_bytes: 32,
}
}
#[test]
fn discovers_nearest_git_workspace_root() {
let root = temp_dir("git-root");
let nested = root.join("a/b");
fs::create_dir_all(&nested).expect("nested directories should be created");
fs::create_dir(root.join(".git")).expect(".git directory should be created");
assert_eq!(
discover_workspace_root(&nested).expect("workspace root should be found"),
root.canonicalize().expect("root should canonicalize")
);
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn discovers_git_file_workspace_root() {
let root = temp_dir("git-file-root");
let nested = root.join("a");
fs::create_dir_all(&nested).expect("nested directory should be created");
fs::write(root.join(".git"), "gitdir: ../repo.git").expect(".git file should be written");
assert_eq!(
discover_workspace_root(&nested).expect("workspace root should be found"),
root.canonicalize().expect("root should canonicalize")
);
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn falls_back_to_current_directory_without_git() {
let root = temp_dir("no-git-root");
assert_eq!(
discover_workspace_root(&root).expect("fallback root should be selected"),
root.canonicalize().expect("root should canonicalize")
);
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn path_policy_accepts_regular_files_and_new_files_under_root() {
let root = temp_dir("path-accept");
let cfg = config(&root);
let existing = root.join("existing.txt");
fs::write(&existing, "hello").expect("existing file should be written");
let new_file = root.join("new.txt");
assert_eq!(
resolve_buffer_path(&existing, &cfg)
.expect("existing path should resolve")
.path,
existing
.canonicalize()
.expect("existing should canonicalize")
);
assert_eq!(
resolve_buffer_path(&new_file, &cfg)
.expect("new path should resolve")
.path,
cfg.workspace_root.join("new.txt")
);
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn relative_paths_resolve_from_launch_directory_before_workspace_policy() {
let root = temp_dir("relative-root");
let nested = root.join("src");
fs::create_dir(&nested).expect("nested directory should be created");
let cfg = config(&root);
let file = nested.join("main.rs");
fs::write(&file, "fn main() {}").expect("nested file should be written");
assert_eq!(
resolve_buffer_path_from(Path::new("main.rs"), &cfg, &nested)
.expect("relative path should resolve from base directory")
.path,
file.canonicalize().expect("file should canonicalize")
);
assert!(matches!(
resolve_buffer_path_from(Path::new("../../escape.txt"), &cfg, &nested),
Err(PathPolicyError::OutsideWorkspace { .. })
));
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn path_policy_rejects_outside_workspace_and_traversal() {
let root = temp_dir("path-reject");
let outside = temp_dir("path-reject-outside");
let cfg = config(&root);
let outside_file = outside.join("outside.txt");
fs::write(&outside_file, "nope").expect("outside file should be written");
assert!(matches!(
resolve_buffer_path(&outside_file, &cfg),
Err(PathPolicyError::OutsideWorkspace { .. })
));
assert!(matches!(
resolve_buffer_path(Path::new("../escape.txt"), &cfg),
Err(PathPolicyError::OutsideWorkspace { .. })
));
let _cleanup = fs::remove_dir_all(root);
let _cleanup = fs::remove_dir_all(outside);
}
#[cfg(unix)]
#[test]
fn path_policy_rejects_dangling_symlink_as_unresolvable() {
use std::os::unix::fs::symlink;
let root = temp_dir("dangling-symlink");
let cfg = config(&root);
let link = root.join("dangling.txt");
symlink(root.join("missing-target.txt"), &link)
.expect("dangling symlink should be created");
assert!(matches!(
resolve_buffer_path(&link, &cfg),
Err(PathPolicyError::Unresolvable { .. })
));
assert!(matches!(
atomic_write(&link, b"new", &cfg),
Err(FileWriteError::Policy(PathPolicyError::Unresolvable { .. }))
));
assert!(
fs::symlink_metadata(&link)
.expect("dangling symlink should remain")
.file_type()
.is_symlink()
);
let _cleanup = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn path_policy_rejects_symlink_loop_as_unresolvable() {
use std::os::unix::fs::symlink;
let root = temp_dir("symlink-loop");
let cfg = config(&root);
let first = root.join("first");
let second = root.join("second");
symlink(&second, &first).expect("first symlink should be created");
symlink(&first, &second).expect("second symlink should be created");
assert!(matches!(
resolve_buffer_path(&first, &cfg),
Err(PathPolicyError::Unresolvable { .. })
));
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn read_rejects_directories_and_large_files() {
let root = temp_dir("read-reject");
let cfg = config(&root);
let large_file = root.join("large.txt");
fs::write(&large_file, vec![b'a'; 33]).expect("large file should be written");
assert!(matches!(
read_text_file(&root, &cfg),
Err(FileReadError::UnsupportedFileType { .. })
));
assert!(matches!(
read_text_file(&large_file, &cfg),
Err(FileReadError::TooLarge { .. })
));
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn read_accepts_missing_files_as_empty_buffers() {
let root = temp_dir("missing-open");
let cfg = config(&root);
let missing = root.join("new.txt");
let read =
read_text_file(&missing, &cfg).expect("missing file should open as empty buffer");
assert_eq!(read.path, cfg.workspace_root.join("new.txt"));
assert!(read.bytes.is_empty());
let _cleanup = fs::remove_dir_all(root);
}
#[test]
fn atomic_write_writes_and_replaces_contents() {
let root = temp_dir("atomic-write");
let cfg = config(&root);
let file = root.join("file.txt");
fs::write(&file, "old").expect("file should be written");
let written_path = atomic_write(&file, b"new", &cfg).expect("write should succeed");
assert_eq!(
written_path,
file.canonicalize().expect("file should canonicalize")
);
assert_eq!(
fs::read_to_string(&file).expect("file should be readable"),
"new"
);
assert!(
fs::read_dir(&root)
.expect("root should be readable")
.all(|entry| !entry
.expect("directory entry should be readable")
.file_name()
.to_string_lossy()
.contains("alma-tmp"))
);
let _cleanup = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_existing_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let root = temp_dir("atomic-write-mode");
let cfg = config(&root);
let file = root.join("private.txt");
fs::write(&file, "old").expect("file should be written");
fs::set_permissions(&file, fs::Permissions::from_mode(0o600))
.expect("file permissions should be restricted");
let _written_path = atomic_write(&file, b"new", &cfg).expect("write should succeed");
let mode = fs::metadata(&file)
.expect("file metadata should be readable")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
assert_eq!(
fs::read_to_string(&file).expect("file should be readable"),
"new"
);
let _cleanup = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn atomic_write_uses_resolved_symlink_target_permissions() {
use std::os::unix::{fs::PermissionsExt, fs::symlink};
let root = temp_dir("atomic-write-symlink-mode");
let cfg = config(&root);
let target = root.join("private.txt");
let link = root.join("link.txt");
fs::write(&target, "old").expect("target should be written");
fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
.expect("target permissions should be restricted");
symlink(&target, &link).expect("symlink should be created");
let written_path = atomic_write(&link, b"new", &cfg).expect("write should succeed");
assert_eq!(
written_path,
target.canonicalize().expect("target should canonicalize")
);
let mode = fs::metadata(&target)
.expect("target metadata should be readable")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
assert_eq!(
fs::read_to_string(&target).expect("target should be readable"),
"new"
);
assert!(
fs::symlink_metadata(&link)
.expect("link metadata should be readable")
.file_type()
.is_symlink()
);
let _cleanup = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn symlinks_are_allowed_only_when_target_stays_under_root() {
use std::os::unix::fs::symlink;
let root = temp_dir("symlink-root");
let outside = temp_dir("symlink-outside");
let cfg = config(&root);
let inside_file = root.join("inside.txt");
let outside_file = outside.join("outside.txt");
fs::write(&inside_file, "inside").expect("inside file should be written");
fs::write(&outside_file, "outside").expect("outside file should be written");
let inside_link = root.join("inside-link.txt");
let outside_link = root.join("outside-link.txt");
symlink(&inside_file, &inside_link).expect("inside symlink should be created");
symlink(&outside_file, &outside_link).expect("outside symlink should be created");
assert_eq!(
resolve_buffer_path(&inside_link, &cfg)
.expect("inside symlink should resolve")
.path,
inside_file
.canonicalize()
.expect("inside should canonicalize")
);
assert!(matches!(
resolve_buffer_path(&outside_link, &cfg),
Err(PathPolicyError::OutsideWorkspace { .. })
));
let _cleanup = fs::remove_dir_all(root);
let _cleanup = fs::remove_dir_all(outside);
}
}