mod config;
mod display;
mod error;
mod path;
mod platform;
mod read;
mod write;
pub use config::{DEFAULT_MAX_FILE_BYTES, FilesystemConfig};
pub use display::EscapedDisplayText;
pub use error::{FileReadError, FileWriteError, FilesystemConfigError, PathPolicyError};
pub use read::{ReadTextFile, read_existing_file, read_existing_workspace_file, read_text_file};
pub use write::{atomic_write, atomic_write_workspace_file};
#[cfg(test)]
mod tests {
use super::{
FileReadError, FileWriteError, FilesystemConfig, PathPolicyError, atomic_write,
config::discover_workspace_root,
path::{resolve_buffer_path, resolve_buffer_path_from},
read_text_file,
};
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 filesystem_errors_escape_control_characters_in_paths() {
let error = PathPolicyError::MissingParent {
path: PathBuf::from("bad\nname\tbidi\u{202E}.txt"),
};
let text = error.to_string();
assert_eq!(
text,
"bad\\nname\\tbidi\\u{202E}.txt has no parent directory"
);
assert!(!text.contains('\n'));
assert!(!text.contains('\t'));
assert!(!text.contains('\u{202E}'));
}
#[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);
}
}