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
50/// Validates input paths relative to session root.
51pub fn validate_input_paths(
52    session_root: &Path,
53    file_paths: &[PathBuf],
54    require_exists: bool,
55) -> (Vec<String>, bool) {
56    let mut valid_rels = Vec::new();
57    let mut has_errors = false;
58
59    let root_canon = match fs::canonicalize(session_root) {
60        Ok(p) => p,
61        Err(_) => return (vec![], true),
62    };
63
64    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
65
66    for path in file_paths {
67        // 1. Resolve to logical absolute path preserving symlinks segments where possible
68        let logical_abs_path = normalize_path(&cwd.join(path));
69
70        // 2. Existence check (optional but standard)
71        if require_exists {
72            if !logical_abs_path.exists() {
73                eprintln!("Error: File not found: {}", path.display());
74                has_errors = true;
75                continue;
76            }
77            if logical_abs_path.is_dir() {
78                eprintln!("Error: Cannot add a directory: {}", path.display());
79                has_errors = true;
80                continue;
81            }
82        }
83
84        // 3. Security check: Must be inside session root physically.
85        // We canonicalize the final target to ensure symlinks aren't escaping the root.
86        let physical_target = match fs::canonicalize(&logical_abs_path) {
87            Ok(p) => p,
88            Err(_) if !require_exists => logical_abs_path.clone(), // Non-existent file target
89            Err(_) => {
90                eprintln!("Error: Could not resolve path: {}", path.display());
91                has_errors = true;
92                continue;
93            }
94        };
95
96        if !physical_target.starts_with(&root_canon) {
97            eprintln!(
98                "Error: File '{}' is outside the session root",
99                path.display()
100            );
101            has_errors = true;
102            continue;
103        }
104
105        // 4. Calculate relative path using the LOGICAL path to preserve symlink semantics in the context structure.
106        match logical_abs_path.strip_prefix(session_root) {
107            Ok(rel) => {
108                let rel_str = rel.to_string_lossy().replace('\\', "/");
109                valid_rels.push(rel_str);
110            }
111            Err(_) => {
112                // If the logical path doesn't start with root (e.g. symlink into root from outside),
113                // we use the physical relative path as the context identifier.
114                if let Ok(rel) = physical_target.strip_prefix(&root_canon) {
115                    let rel_str = rel.to_string_lossy().replace('\\', "/");
116                    valid_rels.push(rel_str);
117                } else {
118                    eprintln!(
119                        "Error: File '{}' is logically outside the session root",
120                        path.display()
121                    );
122                    has_errors = true;
123                }
124            }
125        }
126    }
127
128    (valid_rels, has_errors)
129}
130
131fn normalize_path(path: &Path) -> PathBuf {
132    path.components()
133        .fold(PathBuf::new(), |mut acc, component| {
134            match component {
135                // ".." means pop the last segment
136                Component::ParentDir => {
137                    acc.pop();
138                }
139                // "." means do nothing
140                Component::CurDir => {}
141                // Normal segments, Root, and Prefix just get pushed
142                c => acc.push(c.as_os_str()),
143            };
144            acc
145        })
146}