1use anyhow::{Result, Context};
9use std::path::{Path, PathBuf};
10
11pub const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
13
14pub const MAX_PATH_LENGTH: usize = 1024;
16
17pub fn validate_path(
34 path_str: &str,
35 base_dir: Option<&Path>,
36 is_write: bool
37) -> Result<PathBuf> {
38 if path_str.len() > MAX_PATH_LENGTH {
40 return Err(anyhow::anyhow!(
41 "Path too long: {} characters (max: {})",
42 path_str.len(),
43 MAX_PATH_LENGTH
44 ));
45 }
46
47 if path_str.contains("..") {
49 return Err(anyhow::anyhow!(
50 "Path traversal detected: '{}'. Paths cannot contain '..' for security",
51 path_str
52 ));
53 }
54
55 if path_str.trim().is_empty() {
57 return Err(anyhow::anyhow!("Path cannot be empty"));
58 }
59
60 let path = PathBuf::from(path_str);
62 let is_relative = path.is_relative(); if is_write {
66 check_critical_system_files(&path)?;
67 }
68
69 let resolved_path = if let Some(base) = base_dir {
71 if path.is_absolute() {
73 path
76 } else {
77 base.join(&path)
79 }
80 } else {
81 if path.is_absolute() {
83 path
84 } else {
85 std::env::current_dir()
87 .context("Cannot get current directory")?
88 .join(&path)
89 }
90 };
91
92 let canonical = if resolved_path.exists() {
95 resolved_path.canonicalize()
96 .with_context(|| format!("Cannot resolve path: {}", resolved_path.display()))?
97 } else {
98 resolved_path.clone()
101 };
102
103 if let Some(base) = base_dir {
106 let base_canonical = if base.exists() {
107 base.canonicalize()
108 .with_context(|| format!("Cannot resolve base directory: {}", base.display()))?
109 } else {
110 base.to_path_buf()
111 };
112
113 let is_within_base = if is_relative && !path_str.contains("..") {
116 true
118 } else {
119 resolved_path.starts_with(&base_canonical)
121 || canonical.starts_with(&base_canonical)
122 };
123
124 if !is_within_base {
125 return Err(anyhow::anyhow!(
126 "Path escapes project directory: '{}'. Resolved path '{}' appears outside '{}'",
127 path_str,
128 resolved_path.display(),
129 base_canonical.display()
130 ));
131 }
132 }
133
134 Ok(canonical)
135}
136
137fn check_critical_system_files(path: &Path) -> Result<()> {
139 const CRITICAL_FILES: &[&str] = &[
141 "/etc/passwd",
142 "/etc/shadow",
143 "/etc/sudoers",
144 "/etc/ssh/sshd_config",
145 "/etc/hosts",
146 "/etc/fstab",
147 "/boot/",
148 "/dev/sda",
149 "/dev/hda",
150 "/proc/",
151 "/sys/",
152 ];
153
154 let path_str = path.to_string_lossy();
155
156 for critical in CRITICAL_FILES {
157 if path_str.starts_with(critical) || path_str == *critical {
158 return Err(anyhow::anyhow!(
159 "Cannot write to critical system file: '{}'. This is blocked for security",
160 path.display()
161 ));
162 }
163 }
164
165 Ok(())
166}
167
168pub fn validate_content_size(content: &str) -> Result<()> {
170 if content.len() > MAX_FILE_SIZE {
171 return Err(anyhow::anyhow!(
172 "Content too large: {} bytes (max: {} bytes = {} MB). \
173 Split into smaller files or use streaming",
174 content.len(),
175 MAX_FILE_SIZE,
176 MAX_FILE_SIZE / 1_000_000
177 ));
178 }
179
180 Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use tempfile::TempDir;
187
188 #[test]
189 fn test_path_traversal_blocked() {
190 let base = TempDir::new().unwrap();
191
192 assert!(validate_path("../../../etc/passwd", Some(base.path()), false).is_err());
194 assert!(validate_path("..\\..\\..\\windows\\system32", Some(base.path()), false).is_err());
195 assert!(validate_path("/tmp/../etc/passwd", Some(base.path()), false).is_err());
196 }
197
198 #[test]
199 fn test_safe_relative_paths_allowed() {
200 let base = TempDir::new().unwrap();
201
202 let result1 = validate_path("src/main.rs", Some(base.path()), true); let result2 = validate_path("./build/output.txt", Some(base.path()), true); let result3 = validate_path("config.json", Some(base.path()), true); assert!(result1.is_ok(), "Relative path 'src/main.rs' should be allowed for write");
210 assert!(result2.is_ok(), "Relative path './build/output.txt' should be allowed for write");
211 assert!(result3.is_ok(), "Relative path 'config.json' should be allowed for write");
212
213 let result4 = validate_path("newfile.txt", Some(base.path()), false);
217 assert!(result4.is_ok(), "Safe relative path should be allowed even for read");
219 }
220
221 #[test]
222 fn test_absolute_paths_handling() {
223 let base = TempDir::new().unwrap();
224
225 let temp_file = base.path().join("test.txt");
227 std::fs::write(&temp_file, "test content").unwrap();
228
229 assert!(validate_path(temp_file.to_str().unwrap(), Some(base.path()), false).is_ok(),
231 "Absolute path within base should be allowed for existing files");
232
233 assert!(validate_path("/etc/passwd", None, true).is_err(),
235 "Critical system files should be blocked for writes even without base dir");
236
237 #[cfg(unix)]
239 {
240 let outside_path = "/var/outside.txt";
242 let result = validate_path(outside_path, Some(base.path()), true);
243 assert!(result.is_err(),
244 "Absolute path '{}' outside base should be rejected for write", outside_path);
245 }
246
247 #[cfg(windows)]
248 {
249 let outside_path = "C:\\Windows\\outside.txt";
251 let result = validate_path(outside_path, Some(base.path()), true);
252 assert!(result.is_err(),
253 "Absolute path '{}' outside base should be rejected for write", outside_path);
254 }
255 }
256
257 #[test]
258 fn test_critical_system_files_blocked() {
259 assert!(validate_path("/etc/passwd", None, true).is_err(),
261 "Should block /etc/passwd for write");
262 assert!(validate_path("/etc/shadow", None, true).is_err(),
263 "Should block /etc/shadow for write");
264 assert!(validate_path("/etc/sudoers", None, true).is_err(),
265 "Should block /etc/sudoers for write");
266
267 assert!(validate_path("/etc/passwd", None, false).is_ok(),
270 "Reading /etc/passwd should be allowed (documented risk)");
271 assert!(validate_path("/etc/hosts", None, false).is_ok(),
272 "Reading /etc/hosts should be allowed");
273 }
274
275 #[test]
276 fn test_path_length_limit() {
277 let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
279 assert!(validate_path(&long_path, None, false).is_err(),
280 "Path exceeding MAX_PATH_LENGTH should be rejected");
281
282 let normal_path = "src/main.rs";
284 assert!(validate_path(normal_path, None, false).is_ok(),
285 "Normal length relative path should be allowed");
286
287 let abs_path = "/tmp/test.txt";
289 assert!(validate_path(abs_path, None, false).is_ok(),
290 "Normal length absolute path should be allowed for read");
291 }
292
293 #[test]
294 fn test_content_size_validation() {
295 let small = "Hello, world!";
297 assert!(validate_content_size(small).is_ok());
298
299 let large = "x".repeat(MAX_FILE_SIZE + 1);
301 assert!(validate_content_size(&large).is_err());
302
303 let exact = "x".repeat(MAX_FILE_SIZE);
305 assert!(validate_content_size(&exact).is_ok());
306 }
307
308 #[test]
309 fn test_empty_path_blocked() {
310 let base = TempDir::new().unwrap();
311
312 assert!(validate_path("", Some(base.path()), false).is_err());
314 assert!(validate_path(" ", Some(base.path()), false).is_err());
315 }
316
317 #[test]
318 fn test_symlink_escape_blocked() {
319 let base = TempDir::new().unwrap();
320 let outside = TempDir::new().unwrap();
321
322 let link = base.path().join("escape_link");
324 #[cfg(unix)]
325 std::os::unix::fs::symlink(outside.path(), &link).ok();
326 #[cfg(windows)]
327 std::os::windows::fs::symlink_file(outside.path(), &link).ok();
328
329 if link.exists() {
332 let result = validate_path("escape_link", Some(base.path()), true);
333 assert!(result.is_err() || result.unwrap().starts_with(base.path()));
335 }
336 }
337}