1use anyhow::{Context, Result};
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(path_str: &str, base_dir: Option<&Path>, is_write: bool) -> Result<PathBuf> {
34 if path_str.len() > MAX_PATH_LENGTH {
36 return Err(anyhow::anyhow!(
37 "Path too long: {} characters (max: {})",
38 path_str.len(),
39 MAX_PATH_LENGTH
40 ));
41 }
42
43 if path_str.contains("..") {
45 return Err(anyhow::anyhow!(
46 "Path traversal detected: '{}'. Paths cannot contain '..' for security",
47 path_str
48 ));
49 }
50
51 if path_str.trim().is_empty() {
53 return Err(anyhow::anyhow!("Path cannot be empty"));
54 }
55
56 let path = PathBuf::from(path_str);
58 let is_relative = path.is_relative(); if is_write {
62 check_critical_system_files(&path)?;
63 }
64
65 let resolved_path = if let Some(base) = base_dir {
67 if path.is_absolute() {
69 path
72 } else {
73 base.join(&path)
75 }
76 } else {
77 if path.is_absolute() {
79 path
80 } else {
81 std::env::current_dir()
83 .context("Cannot get current directory")?
84 .join(&path)
85 }
86 };
87
88 let canonical = if resolved_path.exists() {
91 resolved_path
92 .canonicalize()
93 .with_context(|| format!("Cannot resolve path: {}", resolved_path.display()))?
94 } else {
95 resolved_path.clone()
98 };
99
100 if let Some(base) = base_dir {
103 let base_canonical = if base.exists() {
104 base.canonicalize()
105 .with_context(|| format!("Cannot resolve base directory: {}", base.display()))?
106 } else {
107 base.to_path_buf()
108 };
109
110 let is_within_base = if is_relative && !path_str.contains("..") {
113 true
115 } else {
116 resolved_path.starts_with(&base_canonical) || canonical.starts_with(&base_canonical)
118 };
119
120 if !is_within_base {
121 return Err(anyhow::anyhow!(
122 "Path escapes project directory: '{}'. Resolved path '{}' appears outside '{}'",
123 path_str,
124 resolved_path.display(),
125 base_canonical.display()
126 ));
127 }
128 }
129
130 Ok(canonical)
131}
132
133fn check_critical_system_files(path: &Path) -> Result<()> {
135 const CRITICAL_FILES: &[&str] = &[
137 "/etc/passwd",
138 "/etc/shadow",
139 "/etc/sudoers",
140 "/etc/ssh/sshd_config",
141 "/etc/hosts",
142 "/etc/fstab",
143 "/boot/",
144 "/dev/sda",
145 "/dev/hda",
146 "/proc/",
147 "/sys/",
148 ];
149
150 let path_str = path.to_string_lossy();
151
152 for critical in CRITICAL_FILES {
153 if path_str.starts_with(critical) || path_str == *critical {
154 return Err(anyhow::anyhow!(
155 "Cannot write to critical system file: '{}'. This is blocked for security",
156 path.display()
157 ));
158 }
159 }
160
161 Ok(())
162}
163
164pub fn validate_content_size(content: &str) -> Result<()> {
166 if content.len() > MAX_FILE_SIZE {
167 return Err(anyhow::anyhow!(
168 "Content too large: {} bytes (max: {} bytes = {} MB). \
169 Split into smaller files or use streaming",
170 content.len(),
171 MAX_FILE_SIZE,
172 MAX_FILE_SIZE / 1_000_000
173 ));
174 }
175
176 Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use tempfile::TempDir;
183
184 #[test]
185 fn test_path_traversal_blocked() {
186 let base = TempDir::new().unwrap();
187
188 assert!(validate_path("../../../etc/passwd", Some(base.path()), false).is_err());
190 assert!(validate_path("..\\..\\..\\windows\\system32", Some(base.path()), false).is_err());
191 assert!(validate_path("/tmp/../etc/passwd", Some(base.path()), false).is_err());
192 }
193
194 #[test]
195 fn test_safe_relative_paths_allowed() {
196 let base = TempDir::new().unwrap();
197
198 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!(
206 result1.is_ok(),
207 "Relative path 'src/main.rs' should be allowed for write"
208 );
209 assert!(
210 result2.is_ok(),
211 "Relative path './build/output.txt' should be allowed for write"
212 );
213 assert!(
214 result3.is_ok(),
215 "Relative path 'config.json' should be allowed for write"
216 );
217
218 let result4 = validate_path("newfile.txt", Some(base.path()), false);
222 assert!(
224 result4.is_ok(),
225 "Safe relative path should be allowed even for read"
226 );
227 }
228
229 #[test]
230 fn test_absolute_paths_handling() {
231 let base = TempDir::new().unwrap();
232
233 let temp_file = base.path().join("test.txt");
235 std::fs::write(&temp_file, "test content").unwrap();
236
237 assert!(
239 validate_path(temp_file.to_str().unwrap(), Some(base.path()), false).is_ok(),
240 "Absolute path within base should be allowed for existing files"
241 );
242
243 assert!(
245 validate_path("/etc/passwd", None, true).is_err(),
246 "Critical system files should be blocked for writes even without base dir"
247 );
248
249 #[cfg(unix)]
251 {
252 let outside_path = "/var/outside.txt";
254 let result = validate_path(outside_path, Some(base.path()), true);
255 assert!(
256 result.is_err(),
257 "Absolute path '{}' outside base should be rejected for write",
258 outside_path
259 );
260 }
261
262 #[cfg(windows)]
263 {
264 let outside_path = "C:\\Windows\\outside.txt";
266 let result = validate_path(outside_path, Some(base.path()), true);
267 assert!(
268 result.is_err(),
269 "Absolute path '{}' outside base should be rejected for write",
270 outside_path
271 );
272 }
273 }
274
275 #[test]
276 fn test_critical_system_files_blocked() {
277 assert!(
279 validate_path("/etc/passwd", None, true).is_err(),
280 "Should block /etc/passwd for write"
281 );
282 assert!(
283 validate_path("/etc/shadow", None, true).is_err(),
284 "Should block /etc/shadow for write"
285 );
286 assert!(
287 validate_path("/etc/sudoers", None, true).is_err(),
288 "Should block /etc/sudoers for write"
289 );
290
291 assert!(
294 validate_path("/etc/passwd", None, false).is_ok(),
295 "Reading /etc/passwd should be allowed (documented risk)"
296 );
297 assert!(
298 validate_path("/etc/hosts", None, false).is_ok(),
299 "Reading /etc/hosts should be allowed"
300 );
301 }
302
303 #[test]
304 fn test_path_length_limit() {
305 let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
307 assert!(
308 validate_path(&long_path, None, false).is_err(),
309 "Path exceeding MAX_PATH_LENGTH should be rejected"
310 );
311
312 let normal_path = "src/main.rs";
314 assert!(
315 validate_path(normal_path, None, false).is_ok(),
316 "Normal length relative path should be allowed"
317 );
318
319 let abs_path = "/tmp/test.txt";
321 assert!(
322 validate_path(abs_path, None, false).is_ok(),
323 "Normal length absolute path should be allowed for read"
324 );
325 }
326
327 #[test]
328 fn test_content_size_validation() {
329 let small = "Hello, world!";
331 assert!(validate_content_size(small).is_ok());
332
333 let large = "x".repeat(MAX_FILE_SIZE + 1);
335 assert!(validate_content_size(&large).is_err());
336
337 let exact = "x".repeat(MAX_FILE_SIZE);
339 assert!(validate_content_size(&exact).is_ok());
340 }
341
342 #[test]
343 fn test_empty_path_blocked() {
344 let base = TempDir::new().unwrap();
345
346 assert!(validate_path("", Some(base.path()), false).is_err());
348 assert!(validate_path(" ", Some(base.path()), false).is_err());
349 }
350
351 #[test]
352 fn test_symlink_escape_blocked() {
353 let base = TempDir::new().unwrap();
354 let outside = TempDir::new().unwrap();
355
356 let link = base.path().join("escape_link");
358 #[cfg(unix)]
359 std::os::unix::fs::symlink(outside.path(), &link).ok();
360 #[cfg(windows)]
361 std::os::windows::fs::symlink_file(outside.path(), &link).ok();
362
363 if link.exists() {
366 let result = validate_path("escape_link", Some(base.path()), true);
367 assert!(result.is_err() || result.unwrap().starts_with(base.path()));
369 }
370 }
371}