use std::path::Path;
use std::time::{Duration, SystemTime};
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
pub fn output_protection_threshold() -> Duration {
std::env::var("LUFF_OUTPUT_PROTECTION_MS")
.ok()
.and_then(|s| s.parse().ok())
.map_or_else(|| Duration::from_millis(10), Duration::from_millis)
}
#[must_use]
pub fn is_within_root(path: &Path, root: &Path) -> bool {
let normalized_path = super::normalization::normalize_path(path);
let normalized_root = super::normalization::normalize_path(root);
if normalized_root.as_ref() == Path::new(".") || normalized_root.as_ref().as_os_str().is_empty()
{
return !normalized_path.as_ref().is_absolute()
&& !normalized_path.as_ref().starts_with("..");
}
normalized_path.starts_with(normalized_root.as_ref())
}
#[must_use]
pub fn is_canonical_within_root(canonical_path: &Path, canonical_root: &Path) -> bool {
debug_assert!(
canonical_path.is_absolute(),
"canonical_path must be absolute, got: {}",
canonical_path.display()
);
debug_assert!(
canonical_root.is_absolute(),
"canonical_root must be absolute, got: {}",
canonical_root.display()
);
debug_assert!(
!canonical_path.components().any(|c| matches!(
c,
std::path::Component::CurDir | std::path::Component::ParentDir
)),
"canonical_path contains . or .. components: {}",
canonical_path.display()
);
debug_assert!(
!canonical_root.components().any(|c| matches!(
c,
std::path::Component::CurDir | std::path::Component::ParentDir
)),
"canonical_root contains . or .. components: {}",
canonical_root.display()
);
canonical_path.starts_with(canonical_root)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileIdentity {
#[cfg(unix)]
pub dev: u64,
#[cfg(unix)]
pub ino: u64,
#[cfg(not(unix))]
_sealed: (),
}
impl FileIdentity {
#[cfg(unix)]
#[must_use]
pub fn from_metadata(metadata: &std::fs::Metadata) -> Self {
Self {
dev: metadata.dev(),
ino: metadata.ino(),
}
}
}
#[must_use]
pub fn get_stdout_identity() -> Option<FileIdentity> {
#[cfg(unix)]
{
use std::io::IsTerminal;
use std::os::unix::io::AsFd;
if std::io::stdout().is_terminal() {
return None;
}
let stdout = std::io::stdout();
match luff_sys::get_fd_identity(stdout.as_fd()) {
Ok((dev, ino)) => {
log::debug!("Identified stdout via fstat: dev={dev}, ino={ino}");
Some(FileIdentity { dev, ino })
}
Err(e) => {
log::debug!("Failed to get stdout identity via fstat: {e}");
None
}
}
}
#[cfg(not(unix))]
{
None
}
}
#[must_use]
pub fn is_output_file(path: &Path, stdout_identity: Option<&FileIdentity>) -> bool {
#[cfg(not(unix))]
let _ = stdout_identity;
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => {
log::debug!("Failed to get metadata for {}: {e}", path.display());
return false;
}
};
#[cfg(unix)]
{
if let Some(stdout_id) = stdout_identity {
let file_id = FileIdentity::from_metadata(&metadata);
if file_id == *stdout_id {
log::debug!("Skipping output file (inode match): {}", path.display());
return true;
}
}
}
if metadata.len() != 0 {
return false;
}
if let Ok(modified) = metadata.modified() {
if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
let threshold = output_protection_threshold();
if elapsed < threshold {
log::debug!(
"Skipping potential output file (heuristic): {} (age: {:?}, threshold: {:?})",
path.display(),
elapsed,
threshold
);
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_is_within_root() {
let root = Path::new("/home/user/project");
let path = Path::new("/home/user/project/src/main.rs");
assert!(is_within_root(path, root));
let outside = Path::new("/home/user/other/file.txt");
assert!(!is_within_root(outside, root));
}
#[test]
fn test_is_within_root_dot_does_not_match_everything() {
assert!(!is_within_root(
Path::new("../../etc/passwd"),
Path::new(".")
));
assert!(!is_within_root(Path::new("/etc/passwd"), Path::new(".")));
assert!(is_within_root(Path::new("./src/main.rs"), Path::new(".")));
assert!(is_within_root(Path::new("src/main.rs"), Path::new(".")));
}
#[test]
fn test_is_within_root_empty_does_not_match_everything() {
assert!(!is_within_root(
Path::new("../../etc/passwd"),
Path::new("")
));
assert!(!is_within_root(Path::new("/etc/passwd"), Path::new("")));
assert!(is_within_root(Path::new("src/main.rs"), Path::new("")));
}
#[test]
fn test_is_within_root_dot_self() {
assert!(is_within_root(Path::new("."), Path::new(".")));
}
#[test]
fn test_is_canonical_within_root() {
let temp = TempDir::new().unwrap();
let subdir = temp.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let file = subdir.join("test.txt");
fs::write(&file, "test").unwrap();
let canonical_root = temp.path().canonicalize().unwrap();
let canonical_file = file.canonicalize().unwrap();
assert!(is_canonical_within_root(&canonical_file, &canonical_root));
}
#[cfg(unix)]
#[test]
fn test_file_identity_equality() {
let id1 = FileIdentity { dev: 1, ino: 100 };
let id2 = FileIdentity { dev: 1, ino: 100 };
let id3 = FileIdentity { dev: 1, ino: 101 };
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_is_output_file_with_empty_recent_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("recent_empty.txt");
fs::write(&file, "").unwrap();
let result = is_output_file(&file, None);
assert!(result, "Recently created empty file should be detected");
}
#[test]
fn test_is_output_file_with_non_empty_recent_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("recent_nonempty.txt");
fs::write(&file, "content").unwrap();
let result = is_output_file(&file, None);
assert!(!result, "Non-empty file should not be detected");
}
#[test]
fn test_get_stdout_identity_when_terminal() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
let identity = get_stdout_identity();
assert_eq!(
identity, None,
"Should return None when stdout is a terminal"
);
}
}
#[test]
fn test_is_output_file_fallback_to_heuristic() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let file = temp.path().join("empty_recent.txt");
fs::write(&file, "").unwrap();
#[cfg(unix)]
{
let fake_identity = FileIdentity {
dev: 999_999,
ino: 999_999,
};
let result = is_output_file(&file, Some(&fake_identity));
assert!(
result,
"Heuristic should catch empty recent file even when inode doesn't match"
);
}
#[cfg(not(unix))]
{
let result = is_output_file(&file, None);
assert!(result, "Heuristic should catch empty recent file");
}
}
}