Skip to main content

aico/
fs.rs

1use crate::exceptions::AicoError;
2use std::fs;
3use std::io::Write;
4use std::path::Component;
5use std::path::Path;
6use std::path::PathBuf;
7use tempfile::NamedTempFile;
8
9/// Atomically write text to a file using a temporary file + rename strategy.
10pub fn atomic_write_text<P: AsRef<Path>>(path: P, text: &str) -> Result<(), AicoError> {
11    let path = path.as_ref();
12    let dir = path.parent().unwrap_or_else(|| Path::new("."));
13    fs::create_dir_all(dir)?;
14
15    // Create temp file in the same directory to ensure atomic rename works across filesystems
16    let mut temp_file = NamedTempFile::new_in(dir)?;
17
18    temp_file.write_all(text.as_bytes())?;
19
20    // Persist replaces the destination path atomically
21    temp_file
22        .persist(path)
23        .map_err(|e| AicoError::Io(e.error))?;
24
25    Ok(())
26}
27
28pub fn atomic_write_json<T: serde::Serialize>(
29    path: &std::path::Path,
30    data: &T,
31) -> Result<(), crate::exceptions::AicoError> {
32    let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
33    std::fs::create_dir_all(dir)?;
34
35    let mut temp_file = tempfile::NamedTempFile::new_in(dir)?;
36
37    // Buffer the writer for performance
38    {
39        let mut writer = std::io::BufWriter::new(&mut temp_file);
40        serde_json::to_writer(&mut writer, data)?;
41        writer.flush()?;
42    }
43
44    temp_file
45        .persist(path)
46        .map_err(|e| crate::exceptions::AicoError::Io(e.error))?;
47    Ok(())
48}
49
50pub fn read_json<T: serde::de::DeserializeOwned>(
51    path: &std::path::Path,
52) -> Result<T, crate::exceptions::AicoError> {
53    let file = std::fs::File::open(path)?;
54    let reader = std::io::BufReader::new(file);
55    let data = serde_json::from_reader(reader)?;
56    Ok(data)
57}
58
59/// Validates input paths relative to session root.
60pub fn validate_input_paths(
61    session_root: &Path,
62    file_paths: &[PathBuf],
63    require_exists: bool,
64) -> (Vec<String>, bool) {
65    let mut valid_rels = Vec::new();
66    let mut has_errors = false;
67
68    let root_canon = match fs::canonicalize(session_root) {
69        Ok(p) => p,
70        Err(_) => return (vec![], true),
71    };
72
73    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
74
75    for path in file_paths {
76        // 1. Resolve to logical absolute path preserving symlinks segments where possible
77        let logical_abs_path = normalize_path(&cwd.join(path));
78
79        // 2. Existence check (optional but standard)
80        if require_exists {
81            if !logical_abs_path.exists() {
82                eprintln!("Error: File not found: {}", path.display());
83                has_errors = true;
84                continue;
85            }
86            if logical_abs_path.is_dir() {
87                eprintln!("Error: Cannot add a directory: {}", path.display());
88                has_errors = true;
89                continue;
90            }
91        }
92
93        // 3. Security check: Must be inside session root physically.
94        // We canonicalize the final target to ensure symlinks aren't escaping the root.
95        let physical_target = match fs::canonicalize(&logical_abs_path) {
96            Ok(p) => p,
97            Err(_) if !require_exists => logical_abs_path.clone(), // Non-existent file target
98            Err(_) => {
99                eprintln!("Error: Could not resolve path: {}", path.display());
100                has_errors = true;
101                continue;
102            }
103        };
104
105        if !physical_target.starts_with(&root_canon) {
106            eprintln!(
107                "Error: File '{}' is outside the session root",
108                path.display()
109            );
110            has_errors = true;
111            continue;
112        }
113
114        // 4. Calculate relative path using the LOGICAL path to preserve symlink semantics in the context structure.
115        match logical_abs_path.strip_prefix(session_root) {
116            Ok(rel) => {
117                let rel_str = rel.to_string_lossy().to_string();
118                valid_rels.push(rel_str);
119            }
120            Err(_) => {
121                // If the logical path doesn't start with root (e.g. symlink into root from outside),
122                // we use the physical relative path as the context identifier.
123                if let Ok(rel) = physical_target.strip_prefix(&root_canon) {
124                    let rel_str = rel.to_string_lossy().to_string();
125                    valid_rels.push(rel_str);
126                } else {
127                    eprintln!(
128                        "Error: File '{}' is logically outside the session root",
129                        path.display()
130                    );
131                    has_errors = true;
132                }
133            }
134        }
135    }
136
137    (valid_rels, has_errors)
138}
139
140fn normalize_path(path: &Path) -> PathBuf {
141    path.components()
142        .fold(PathBuf::new(), |mut acc, component| {
143            match component {
144                // ".." means pop the last segment
145                Component::ParentDir => {
146                    acc.pop();
147                }
148                // "." means do nothing
149                Component::CurDir => {}
150                // Normal segments, Root, and Prefix just get pushed
151                c => acc.push(c.as_os_str()),
152            };
153            acc
154        })
155}