use crate::diagnostics::{CoreError, FileError, LintResult};
use std::fs;
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_MAX_FILE_SIZE: u64 = 1_048_576;
pub fn safe_read_file(path: &Path) -> LintResult<String> {
safe_read_file_with_limit(path, DEFAULT_MAX_FILE_SIZE)
}
pub fn safe_write_file(path: &Path, content: &str) -> LintResult<()> {
let metadata = fs::symlink_metadata(path).map_err(|e| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})?;
if metadata.file_type().is_symlink() {
return Err(CoreError::File(FileError::Symlink {
path: path.to_path_buf(),
}));
}
if !metadata.is_file() {
return Err(CoreError::File(FileError::NotRegular {
path: path.to_path_buf(),
}));
}
let permissions = metadata.permissions();
let parent = path.parent().ok_or_else(|| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: io::Error::other("Missing parent directory"),
})
})?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file");
let (temp_path, mut temp_file) = {
let mut attempt = 0u32;
loop {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let candidate = parent.join(format!(
".{}.agnix.tmp.{}",
file_name,
unique + attempt as u128
));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => break (candidate, file),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && attempt < 10 => {
attempt += 1;
continue;
}
Err(e) => {
return Err(CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
}));
}
}
}
};
temp_file.write_all(content.as_bytes()).map_err(|e| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})?;
temp_file.sync_all().map_err(|e| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})?;
drop(temp_file);
fs::set_permissions(&temp_path, permissions).map_err(|e| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})?;
let recheck = fs::symlink_metadata(path).map_err(|e| {
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})?;
if recheck.file_type().is_symlink() {
let _ = fs::remove_file(&temp_path);
return Err(CoreError::File(FileError::Symlink {
path: path.to_path_buf(),
}));
}
if !recheck.is_file() {
let _ = fs::remove_file(&temp_path);
return Err(CoreError::File(FileError::NotRegular {
path: path.to_path_buf(),
}));
}
fs::rename(&temp_path, path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
CoreError::File(FileError::Write {
path: path.to_path_buf(),
source: e,
})
})
}
pub fn safe_read_file_with_limit(path: &Path, max_size: u64) -> LintResult<String> {
let metadata = fs::metadata(path).map_err(|e| {
CoreError::File(FileError::Read {
path: path.to_path_buf(),
source: e,
})
})?;
if !metadata.is_file() {
return Err(CoreError::File(FileError::NotRegular {
path: path.to_path_buf(),
}));
}
let size = metadata.len();
if size > max_size {
return Err(CoreError::File(FileError::TooBig {
path: path.to_path_buf(),
size,
limit: max_size,
}));
}
fs::read_to_string(path).map_err(|e| {
CoreError::File(FileError::Read {
path: path.to_path_buf(),
source: e,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_normal_file_read_succeeds() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.md");
let content = "Hello, world!";
fs::write(&file_path, content).unwrap();
let result = safe_read_file(&file_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), content);
}
#[test]
fn test_empty_file_read_succeeds() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("empty.md");
fs::write(&file_path, "").unwrap();
let result = safe_read_file(&file_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_nonexistent_file_returns_error() {
let result = safe_read_file(Path::new("/nonexistent/path/file.txt"));
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::Read { .. })
));
}
#[test]
fn test_oversized_file_rejected() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("large.txt");
let mut file = fs::File::create(&file_path).unwrap();
let content = vec![b'x'; 1024]; file.write_all(&content).unwrap();
let result = safe_read_file_with_limit(&file_path, 512);
assert!(result.is_err());
match result.unwrap_err() {
CoreError::File(FileError::TooBig { size, limit, .. }) => {
assert_eq!(size, 1024);
assert_eq!(limit, 512);
}
other => panic!("Expected FileTooBig error, got {:?}", other),
}
}
#[test]
fn test_file_at_exact_limit_succeeds() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("exact.txt");
let content = vec![b'x'; 512];
fs::write(&file_path, &content).unwrap();
let result = safe_read_file_with_limit(&file_path, 512);
assert!(result.is_ok());
}
#[test]
fn test_file_one_byte_over_limit_rejected() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("over.txt");
let content = vec![b'x'; 513];
fs::write(&file_path, &content).unwrap();
let result = safe_read_file_with_limit(&file_path, 512);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::TooBig { .. })
));
}
#[test]
fn test_default_max_file_size_is_1_mib() {
assert_eq!(DEFAULT_MAX_FILE_SIZE, 1_048_576);
assert_eq!(DEFAULT_MAX_FILE_SIZE, 1024 * 1024);
}
#[test]
fn test_file_at_1_mib_limit_accepted() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("exactly_1mib.txt");
let content = vec![b'x'; DEFAULT_MAX_FILE_SIZE as usize];
fs::write(&file_path, &content).unwrap();
let result = safe_read_file(&file_path);
assert!(result.is_ok(), "1 MiB file should be accepted");
assert_eq!(result.unwrap().len(), DEFAULT_MAX_FILE_SIZE as usize);
}
#[test]
fn test_file_over_1_mib_limit_rejected() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("over_1mib.txt");
let content = vec![b'x'; DEFAULT_MAX_FILE_SIZE as usize + 1];
fs::write(&file_path, &content).unwrap();
let result = safe_read_file(&file_path);
assert!(result.is_err(), "File over 1 MiB should be rejected");
match result.unwrap_err() {
CoreError::File(FileError::TooBig { size, limit, .. }) => {
assert_eq!(size, DEFAULT_MAX_FILE_SIZE + 1);
assert_eq!(limit, DEFAULT_MAX_FILE_SIZE);
}
other => panic!("Expected FileTooBig error, got: {:?}", other),
}
}
#[test]
fn test_directory_rejected() {
let temp = TempDir::new().unwrap();
let dir_path = temp.path().join("subdir");
fs::create_dir(&dir_path).unwrap();
let result = safe_read_file(&dir_path);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::NotRegular { .. })
));
}
#[test]
fn test_safe_write_file_updates_content() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("write.md");
fs::write(&file_path, "before").unwrap();
let result = safe_write_file(&file_path, "after");
assert!(result.is_ok());
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "after");
}
#[test]
fn test_safe_write_file_rejects_directory() {
let temp = TempDir::new().unwrap();
let dir_path = temp.path().join("write_dir");
fs::create_dir(&dir_path).unwrap();
let result = safe_write_file(&dir_path, "nope");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::NotRegular { .. })
));
}
#[test]
fn test_safe_write_file_missing_file_returns_error() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("missing.md");
let result = safe_write_file(&file_path, "content");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::Write { .. })
));
}
#[test]
fn test_safe_read_file_on_directory_returns_not_regular() {
let temp = TempDir::new().unwrap();
let result = safe_read_file(temp.path());
assert!(result.is_err());
match result.unwrap_err() {
CoreError::File(FileError::NotRegular { path }) => {
assert_eq!(path, temp.path());
}
other => panic!("Expected FileNotRegular for directory, got {:?}", other),
}
}
#[cfg(unix)]
mod unix_tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn test_symlink_followed_for_reads() {
let temp = TempDir::new().unwrap();
let target_path = temp.path().join("target.md");
let link_path = temp.path().join("link.md");
fs::write(&target_path, "Target content").unwrap();
symlink(&target_path, &link_path).unwrap();
let result = safe_read_file(&link_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Target content");
}
#[test]
fn test_symlink_to_directory_still_rejected() {
let temp = TempDir::new().unwrap();
let dir_path = temp.path().join("subdir");
let link_path = temp.path().join("link_to_dir");
fs::create_dir(&dir_path).unwrap();
symlink(&dir_path, &link_path).unwrap();
let result = safe_read_file(&link_path);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::NotRegular { .. })
));
}
#[test]
fn test_dangling_symlink_returns_read_error() {
let temp = TempDir::new().unwrap();
let link_path = temp.path().join("dangling.md");
symlink("/nonexistent/target", &link_path).unwrap();
let result = safe_read_file(&link_path);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::Read { .. })
));
}
#[test]
fn test_safe_write_file_rejects_symlink() {
let temp = TempDir::new().unwrap();
let target_path = temp.path().join("target_write.md");
let link_path = temp.path().join("link_write.md");
fs::write(&target_path, "Target content").unwrap();
symlink(&target_path, &link_path).unwrap();
let result = safe_write_file(&link_path, "new content");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CoreError::File(FileError::Symlink { .. })
));
}
#[test]
fn test_symlink_chain_followed() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("real.md");
let link_a = temp.path().join("link_a.md");
let link_b = temp.path().join("link_b.md");
fs::write(&target, "Chained content").unwrap();
symlink(&target, &link_a).unwrap();
symlink(&link_a, &link_b).unwrap();
let result = safe_read_file(&link_b);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Chained content");
}
}
#[cfg(windows)]
mod windows_tests {
use super::*;
use std::os::windows::fs::symlink_file;
#[test]
fn test_symlink_followed_for_reads_windows() {
let temp = TempDir::new().unwrap();
let target_path = temp.path().join("target.md");
let link_path = temp.path().join("link.md");
fs::write(&target_path, "Target content").unwrap();
if symlink_file(&target_path, &link_path).is_ok() {
let result = safe_read_file(&link_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Target content");
}
}
}
}