Skip to main content

fude/
fs.rs

1//! Sandbox-backed file I/O commands. Registered by [`crate::App::with_fs_sandbox`].
2
3use std::fs;
4use std::io::Read;
5use std::path::Path;
6
7use base64::Engine;
8use serde::Serialize;
9use serde_json::Value;
10
11use crate::sandbox::{
12    atomic_write, is_dir_allowed, is_path_allowed, safe_lock, validate_path, SharedList,
13    DEFAULT_MAX_ALLOWED_PATHS, DEFAULT_MAX_FILE_SIZE,
14};
15
16pub struct FsState {
17    pub allowed_paths: SharedList,
18    pub allowed_dirs: SharedList,
19}
20
21fn arg_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
22    args.get(key)
23        .and_then(|v| v.as_str())
24        .ok_or_else(|| format!("missing arg: {}", key))
25}
26
27pub fn allow_path(state: &FsState, args: &Value) -> Result<Value, String> {
28    let path = arg_str(args, "path")?;
29    validate_path(path)?;
30    let canonical = fs::canonicalize(path)
31        .map_err(|_| "Invalid file path".to_string())?
32        .to_string_lossy()
33        .to_string();
34    let mut paths = safe_lock(&state.allowed_paths);
35    if paths.len() >= DEFAULT_MAX_ALLOWED_PATHS {
36        return Err("Too many allowed paths".to_string());
37    }
38    if !paths.contains(&canonical) {
39        paths.push(canonical);
40    }
41    Ok(Value::Null)
42}
43
44pub fn allow_dir(state: &FsState, args: &Value) -> Result<Value, String> {
45    let path = arg_str(args, "path")?;
46    validate_path(path)?;
47    let canonical = fs::canonicalize(path)
48        .map_err(|_| "Invalid directory path".to_string())?
49        .to_string_lossy()
50        .to_string();
51    let mut dirs = safe_lock(&state.allowed_dirs);
52    if dirs.len() >= DEFAULT_MAX_ALLOWED_PATHS {
53        return Err("Too many allowed directories".to_string());
54    }
55    if !dirs.contains(&canonical) {
56        dirs.push(canonical);
57    }
58    Ok(Value::Null)
59}
60
61#[derive(Serialize)]
62struct DirEntry {
63    name: String,
64    path: String,
65    is_dir: bool,
66}
67
68pub fn list_directory(state: &FsState, args: &Value) -> Result<Value, String> {
69    let path = arg_str(args, "path")?;
70    validate_path(path)?;
71    is_dir_allowed(path, &state.allowed_dirs)?;
72
73    let entries = fs::read_dir(path).map_err(|e| format!("Cannot read directory: {}", e))?;
74    let mut result: Vec<DirEntry> = Vec::new();
75    for entry in entries {
76        let entry = entry.map_err(|e| format!("Cannot read entry: {}", e))?;
77        let metadata = entry
78            .metadata()
79            .map_err(|e| format!("Cannot read metadata: {}", e))?;
80        let name = entry.file_name().to_string_lossy().to_string();
81        if name.starts_with('.') {
82            continue;
83        }
84        result.push(DirEntry {
85            name,
86            path: entry.path().to_string_lossy().to_string(),
87            is_dir: metadata.is_dir(),
88        });
89    }
90    serde_json::to_value(&result).map_err(|e| e.to_string())
91}
92
93pub fn read_file(state: &FsState, args: &Value) -> Result<Value, String> {
94    let path = arg_str(args, "path")?;
95    validate_path(path)?;
96    let canonical = is_path_allowed(path, &state.allowed_paths, &state.allowed_dirs)?;
97
98    let metadata = fs::metadata(&canonical).map_err(|_| "Cannot read file".to_string())?;
99    if metadata.len() > DEFAULT_MAX_FILE_SIZE {
100        return Err(format!(
101            "File too large: {} bytes (max {})",
102            metadata.len(),
103            DEFAULT_MAX_FILE_SIZE
104        ));
105    }
106    let mut buf = String::with_capacity(metadata.len() as usize);
107    fs::File::open(&canonical)
108        .map_err(|_| "Cannot read file".to_string())?
109        .take(DEFAULT_MAX_FILE_SIZE + 1)
110        .read_to_string(&mut buf)
111        .map_err(|_| "Cannot read file".to_string())?;
112    if buf.len() as u64 > DEFAULT_MAX_FILE_SIZE {
113        return Err(format!(
114            "File too large: exceeds {} bytes",
115            DEFAULT_MAX_FILE_SIZE
116        ));
117    }
118    Ok(Value::from(buf))
119}
120
121pub fn read_file_binary(state: &FsState, args: &Value) -> Result<Value, String> {
122    let path = arg_str(args, "path")?;
123    validate_path(path)?;
124    let canonical = is_path_allowed(path, &state.allowed_paths, &state.allowed_dirs)?;
125
126    let metadata = fs::metadata(&canonical).map_err(|_| "Cannot read file".to_string())?;
127    if metadata.len() > DEFAULT_MAX_FILE_SIZE {
128        return Err(format!(
129            "File too large: {} bytes (max {})",
130            metadata.len(),
131            DEFAULT_MAX_FILE_SIZE
132        ));
133    }
134    let mut bytes = Vec::with_capacity(metadata.len() as usize);
135    fs::File::open(&canonical)
136        .map_err(|_| "Cannot read file".to_string())?
137        .take(DEFAULT_MAX_FILE_SIZE + 1)
138        .read_to_end(&mut bytes)
139        .map_err(|_| "Cannot read file".to_string())?;
140    if bytes.len() as u64 > DEFAULT_MAX_FILE_SIZE {
141        return Err(format!(
142            "File too large: exceeds {} bytes",
143            DEFAULT_MAX_FILE_SIZE
144        ));
145    }
146    Ok(Value::from(
147        base64::engine::general_purpose::STANDARD.encode(&bytes),
148    ))
149}
150
151pub fn write_file(state: &FsState, args: &Value) -> Result<Value, String> {
152    let path = arg_str(args, "path")?;
153    let content = arg_str(args, "content")?;
154    validate_path(path)?;
155    let canonical = is_path_allowed(path, &state.allowed_paths, &state.allowed_dirs)?;
156    if content.len() as u64 > DEFAULT_MAX_FILE_SIZE {
157        return Err(format!(
158            "Content too large: {} bytes (max {})",
159            content.len(),
160            DEFAULT_MAX_FILE_SIZE
161        ));
162    }
163    atomic_write(Path::new(&canonical), content.as_bytes())?;
164    Ok(Value::Null)
165}
166
167pub fn write_file_binary(state: &FsState, args: &Value) -> Result<Value, String> {
168    let path = arg_str(args, "path")?;
169    let data = arg_str(args, "data")?;
170    validate_path(path)?;
171    let parent = Path::new(path)
172        .parent()
173        .ok_or("Invalid file path")?
174        .to_string_lossy()
175        .to_string();
176    let canonical_parent = is_dir_allowed(&parent, &state.allowed_dirs)?;
177    let bytes = base64::engine::general_purpose::STANDARD
178        .decode(data)
179        .map_err(|e| format!("base64 decode error: {}", e))?;
180    if bytes.len() as u64 > DEFAULT_MAX_FILE_SIZE {
181        return Err(format!(
182            "File too large: {} bytes (max {})",
183            bytes.len(),
184            DEFAULT_MAX_FILE_SIZE
185        ));
186    }
187    let filename = Path::new(path).file_name().ok_or("Invalid file name")?;
188    let canonical_path = Path::new(&canonical_parent).join(filename);
189    if let Ok(meta) = fs::symlink_metadata(&canonical_path) {
190        if meta.file_type().is_symlink() {
191            return Err("Write rejected: target is a symlink".to_string());
192        }
193    }
194    atomic_write(&canonical_path, &bytes)?;
195    let final_canonical = fs::canonicalize(&canonical_path)
196        .map_err(|e| format!("Cannot resolve written file: {}", e))?;
197    validate_path(&final_canonical.to_string_lossy())?;
198    if !final_canonical.starts_with(Path::new(&canonical_parent)) {
199        let _ = fs::remove_file(&canonical_path);
200        return Err("Write rejected: symlink escape detected".to_string());
201    }
202    Ok(Value::Null)
203}
204
205pub fn ensure_dir(state: &FsState, args: &Value) -> Result<Value, String> {
206    let path = arg_str(args, "path")?;
207    validate_path(path)?;
208    let canonical_parent = is_dir_allowed(path, &state.allowed_dirs).or_else(|_| {
209        let parent = Path::new(path)
210            .parent()
211            .ok_or("Invalid path")?
212            .to_string_lossy()
213            .to_string();
214        is_dir_allowed(&parent, &state.allowed_dirs)
215    })?;
216    let dir_name = Path::new(path)
217        .file_name()
218        .ok_or("Invalid directory name")?;
219    let target = Path::new(&canonical_parent).join(dir_name);
220    fs::create_dir_all(&target).map_err(|e| format!("Cannot create directory: {}", e))?;
221    let canonical_target = fs::canonicalize(&target)
222        .map_err(|e| format!("Cannot resolve created directory: {}", e))?;
223    validate_path(&canonical_target.to_string_lossy())?;
224    Ok(Value::Null)
225}