runtimo_core/validation/path.rs
1//! Path validation with canonicalization and prefix checking.
2//!
3//! Central validation for all path-based capabilities. Handles both existing
4//! paths (canonicalize directly) and new paths (canonicalize the parent).
5//! Rejects path traversal, empty paths, null bytes, control characters,
6//! and paths outside `allowed_prefixes`. Valid UTF-8 paths with non-ASCII
7//! characters (e.g. `über.txt`, `中文`) are allowed.
8//!
9//! Error messages do not leak the list of allowed directories (prevents
10//! information disclosure about filesystem layout).
11//!
12//! # Security Considerations
13//!
14//! ## Null Byte Rejection (FINDING #8)
15//! Paths containing `\0` (null byte) are rejected immediately. Null bytes
16//! can truncate C-string path arguments in syscalls, causing path truncation
17//! attacks (e.g., `/tmp/safe.txt\0/etc/shadow` becomes `/tmp/safe.txt`).
18//!
19//! ## Unicode Normalization (FINDING #7)
20//! Paths are NFC-normalized before validation to prevent Unicode-based
21//! traversal attacks. Non-ASCII paths are allowed after NFC normalization
22//! — valid UTF-8 paths with non-ASCII characters (e.g. `über.txt`, `中文`)
23//! are accepted. Only control characters (0x00-0x1F, 0x7F) and null bytes
24//! are blocked.
25//!
26//! ## Symlink TOCTOU Limitation (FINDING #9)
27//! This module canonicalizes paths via `std::fs::canonicalize()` which
28//! resolves symlinks. A TOCTOU window exists between validation and use:
29//! an attacker could replace a validated path with a symlink between the
30//! two operations. **Mitigation status**: All file-opening capabilities
31//! (`FileRead`, `FileWrite`) use `O_NOFOLLOW` flag to prevent symlink
32//! attacks at open time. Remaining risk: non-file capabilities (e.g.,
33//! `GitExec`, `ShellExec`) may not use `O_NOFOLLOW`. Full mitigation
34//! requires filesystem-level atomicity (not available in std).
35//!
36//! # Configuration
37//!
38//! Allowed prefixes are merged from three sources (lowest to highest priority):
39//! 1. Built-in defaults (`/tmp`, `/var/tmp`, `/home`)
40//! 2. `RUNTIMO_ALLOWED_PATHS` env var (colon-separated)
41//! 3. Config file `~/.config/runtimo/config.toml` (`allowed_paths` array)
42//!
43//! Example config file:
44//! ```toml
45//! allowed_paths = ["/srv", "/opt"]
46//! ```
47
48use std::path::{Path, PathBuf};
49use unicode_normalization::UnicodeNormalization;
50
51/// Context for path validation.
52///
53/// Controls which checks are applied. [`Default`] performs all checks
54/// with built-in prefixes (`/tmp`, `/var/tmp`, `/home`), extended by
55/// `RUNTIMO_ALLOWED_PATHS` env var and config file if set.
56#[allow(clippy::exhaustive_structs)]
57pub struct PathContext {
58 /// Additional allowed directory prefixes (merged with defaults + env var + config).
59 pub allowed_prefixes: &'static [&'static str],
60 /// If true, the path must already exist on disk.
61 pub require_exists: bool,
62 /// If true, the path must be a regular file (not a directory).
63 pub require_file: bool,
64}
65
66impl Default for PathContext {
67 fn default() -> Self {
68 Self {
69 allowed_prefixes: &[],
70 require_exists: true,
71 require_file: true,
72 }
73 }
74}
75
76/// Returns the full set of allowed path prefixes.
77///
78/// Combines built-in defaults, `RUNTIMO_ALLOWED_PATHS` env var,
79/// config file prefixes, and any context-specific overrides.
80fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
81 let mut prefixes = crate::config::RuntimoConfig::get_allowed_prefixes();
82
83 // Add context-specific prefixes
84 for p in ctx.allowed_prefixes {
85 let trimmed = p.trim().to_string();
86 if !prefixes.contains(&trimmed) {
87 prefixes.push(trimmed);
88 }
89 }
90
91 prefixes
92}
93
94/// Validates a path with canonicalization and prefix checking.
95///
96/// For existing paths, resolves symlinks via `canonicalize()` to prevent
97/// symlink-based escapes. For non-existent paths (writes), canonicalizes
98/// the parent directory and appends the filename.
99///
100/// # CWD Independence (R-C26-01)
101///
102/// This function is CWD-independent: no `std::env::current_dir()` fallback.
103/// Two calls with the same path but different CWD produce identical results
104/// or identical errors. Relative paths that do not exist and whose parent
105/// does not exist are rejected because they cannot be resolved without CWD.
106///
107/// # Arguments
108/// * `path_str` - Path string to validate
109/// * `ctx` - Validation context with allowed prefixes and requirements
110///
111/// # Returns
112/// * `Ok(PathBuf)` - Resolved path (canonical if possible)
113/// * `Err(String)` - Validation error message (does not leak allowed prefixes)
114///
115/// # Errors
116/// Returns an error string if the path is empty, contains null bytes,
117/// contains control characters, traverses parent directories,
118/// does not exist (when required), is not a regular file (when required),
119/// cannot be resolved without CWD, or is outside allowed directories.
120pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
121 // Reject empty paths
122 if path_str.is_empty() {
123 return Err("path is empty".to_string());
124 }
125
126 // Reject null bytes — prevents C-string truncation attacks (FINDING #8)
127 if path_str.contains('\0') {
128 return Err("path contains null byte".to_string());
129 }
130
131 // Reject control characters (ASCII 0-31, 127) — can cause terminal
132 // injection, log injection, or shell metacharacter issues
133 if path_str.chars().any(|c| c.is_control()) {
134 return Err("path contains control character".to_string());
135 }
136
137 // NFC-normalize the path to prevent Unicode-based traversal (FINDING #7)
138 let normalized: String = path_str.nfc().collect();
139
140 // Reject path traversal sequences before any filesystem interaction
141 if normalized.contains("..") {
142 return Err("path traversal not allowed".to_string());
143 }
144
145 let path = Path::new(&normalized);
146
147 // Check existence if required
148 if ctx.require_exists && !path.exists() {
149 return Err(format!("path does not exist: {}", normalized));
150 }
151
152 // Resolve the canonical path:
153 // - For existing paths: canonicalize directly (resolves symlinks)
154 // - For non-existent paths: canonicalize parent + append filename
155 let resolved = if path.exists() {
156 path.canonicalize()
157 .map_err(|e| format!("canonicalize failed: {}", e))?
158 } else {
159 // For new files: canonicalize the parent to catch symlink tricks,
160 // then join the filename. If parent doesn't exist either, use
161 // the path as-is (parent directories will be created at execution time).
162 let parent = path.parent().unwrap_or_else(|| Path::new("/"));
163 if parent.exists() {
164 let canonical_parent = parent
165 .canonicalize()
166 .map_err(|e| format!("canonicalize parent failed: {}", e))?;
167 let filename = path
168 .file_name()
169 .ok_or_else(|| "invalid filename".to_string())?;
170 canonical_parent.join(filename)
171 } else {
172 // Parent doesn't exist yet — for absolute paths, use as-is.
173 // Relative paths rejected to enforce CWD-independent resolution
174 // (R-C26-01: same path + different CWD → identical result or error).
175 if path.is_absolute() {
176 path.to_path_buf()
177 } else {
178 return Err(format!(
179 "cannot resolve relative path without CWD: {}",
180 normalized
181 ));
182 }
183 }
184 };
185
186 // Verify it's a file if required (only meaningful for existing paths)
187 if ctx.require_file && resolved.exists() && !resolved.is_file() {
188 return Err(format!("not a file: {}", resolved.display()));
189 }
190
191 // Check allowed prefixes against the resolved path
192 let resolved_str = resolved.to_string_lossy();
193 let allowed = get_allowed_prefixes(ctx);
194 if !allowed
195 .iter()
196 .any(|prefix| path_in_prefix(&resolved_str, prefix))
197 {
198 return Err(format!(
199 "path outside allowed directories: {}",
200 resolved.display()
201 ));
202 }
203
204 Ok(resolved)
205}
206
207/// Checks if `path` is within `prefix` directory.
208///
209/// Requires either an exact match or the path starts with `prefix/`.
210/// Prevents bypass attacks like `/tmpfoo` matching `/tmp`.
211fn path_in_prefix(path: &str, prefix: &str) -> bool {
212 path == prefix || path.starts_with(&format!("{}/", prefix))
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::sync::Mutex;
219
220 /// Mutex to serialize tests that set `RUNTIMO_ALLOWED_PATHS` env var.
221 static PATH_ENV_MUTEX: Mutex<()> = Mutex::new(());
222
223 #[test]
224 fn rejects_empty_path() {
225 let ctx = PathContext::default();
226 assert!(validate_path("", &ctx).is_err());
227 }
228
229 #[test]
230 fn rejects_traversal() {
231 let ctx = PathContext::default();
232 assert!(validate_path("/tmp/../etc/passwd", &ctx).is_err());
233 }
234
235 #[test]
236 fn accepts_existing_tmp_file() {
237 let p = std::env::temp_dir().join("runtimo_val_test.txt");
238 std::fs::write(&p, "test").ok();
239 let ctx = PathContext::default();
240 let result = validate_path(p.to_str().unwrap(), &ctx);
241 assert!(result.is_ok(), "expected Ok, got {:?}", result);
242 std::fs::remove_file(&p).ok();
243 }
244
245 #[test]
246 fn accepts_nonexistent_tmp_file_for_writes() {
247 let ctx = PathContext {
248 require_exists: false,
249 require_file: false,
250 ..Default::default()
251 };
252 let result = validate_path("/tmp/runtimo_new_file_test.txt", &ctx);
253 assert!(result.is_ok(), "expected Ok, got {:?}", result);
254 }
255
256 #[test]
257 fn rejects_write_outside_allowed() {
258 let ctx = PathContext {
259 require_exists: false,
260 require_file: false,
261 ..Default::default()
262 };
263 let result = validate_path("/etc/shadow", &ctx);
264 assert!(result.is_err());
265 assert!(result.unwrap_err().contains("outside allowed"));
266 }
267
268 #[test]
269 fn rejects_symlink_escape() {
270 // Create a symlink from /tmp/link -> /etc/hostname
271 let link_path = std::env::temp_dir().join("runtimo_symlink_test");
272 let _ = std::fs::remove_file(&link_path);
273 #[cfg(unix)]
274 {
275 use std::os::unix::fs::symlink;
276 if symlink("/etc/hostname", &link_path).is_ok() {
277 let ctx = PathContext::default();
278 let result = validate_path(link_path.to_str().unwrap(), &ctx);
279 // Canonicalize resolves the symlink to /etc/hostname → rejected
280 assert!(result.is_err(), "symlink escape should be rejected");
281 std::fs::remove_file(&link_path).ok();
282 }
283 }
284 }
285
286 #[test]
287 fn env_var_extends_allowed_prefixes() {
288 let _guard = PATH_ENV_MUTEX.lock().unwrap();
289 // /srv is not in defaults, should be rejected
290 let ctx = PathContext {
291 require_exists: false,
292 require_file: false,
293 ..Default::default()
294 };
295 assert!(validate_path("/srv/myapp/config", &ctx).is_err());
296
297 // Set env var to allow /srv
298 std::env::set_var("RUNTIMO_ALLOWED_PATHS", "/srv:/opt");
299 assert!(validate_path("/srv/myapp/config", &ctx).is_ok());
300 assert!(validate_path("/opt/tools/bin", &ctx).is_ok());
301
302 // Cleanup
303 std::env::remove_var("RUNTIMO_ALLOWED_PATHS");
304 assert!(validate_path("/srv/myapp/config", &ctx).is_err());
305 }
306
307 #[test]
308 fn error_message_does_not_leak_allowed_prefixes() {
309 let ctx = PathContext {
310 require_exists: false,
311 require_file: false,
312 ..Default::default()
313 };
314 let err = validate_path("/etc/shadow", &ctx).unwrap_err();
315 // Error should not leak the list of allowed directories (info leak)
316 assert!(
317 !err.contains("/tmp"),
318 "error should not leak /tmp as allowed"
319 );
320 assert!(
321 !err.contains("/home"),
322 "error should not leak /home as allowed"
323 );
324 assert!(err.contains("outside allowed directories"));
325 }
326
327 #[test]
328 fn rejects_null_byte() {
329 let ctx = PathContext::default();
330 let result = validate_path("/tmp/safe.txt\0/etc/shadow", &ctx);
331 assert!(result.is_err());
332 assert!(result.unwrap_err().contains("null byte"));
333 }
334
335 #[test]
336 fn accepts_non_ascii_path() {
337 // Create a file with non-ASCII name on disk first
338 let p = std::env::temp_dir().join("café.txt");
339 std::fs::write(&p, "test").ok();
340 let ctx = PathContext::default();
341 let result = validate_path(p.to_str().unwrap(), &ctx);
342 // The file exists in a temp dir (allowed prefix), so it should pass
343 assert!(
344 result.is_ok(),
345 "non-ASCII path should be allowed, got: {:?}",
346 result
347 );
348 std::fs::remove_file(&p).ok();
349 }
350
351 #[test]
352 fn rejects_non_ascii_unicode_traversal() {
353 let ctx = PathContext::default();
354 // Unicode homoglyph attack attempt that doesn't exist — should error on "does not exist"
355 // The non-ASCII part is now allowed, but the traversal (..) should still be caught
356 let result = validate_path("/tmp/\u{00e9}../etc/passwd", &ctx);
357 assert!(result.is_err());
358 // Should fail due to traversal, not non-ASCII
359 assert!(
360 !result.unwrap_err().contains("non-ASCII"),
361 "should not reject for non-ASCII"
362 );
363 }
364
365 #[test]
366 fn nfc_normalizes_path() {
367 let ctx = PathContext {
368 require_exists: false,
369 require_file: false,
370 ..Default::default()
371 };
372 // NFC normalization should not change ASCII paths
373 let result = validate_path("/tmp/normal.txt", &ctx);
374 assert!(result.is_ok());
375 }
376
377 #[test]
378 fn rejects_prefix_bypass() {
379 let ctx = PathContext {
380 require_exists: false,
381 require_file: false,
382 ..Default::default()
383 };
384 let result = validate_path("/tmpfoo/bar.txt", &ctx);
385 assert!(result.is_err(), "/tmpfoo should not match /tmp prefix");
386 assert!(result.unwrap_err().contains("outside allowed"));
387 }
388
389 #[test]
390 fn accepts_valid_prefix_subdir() {
391 let ctx = PathContext {
392 require_exists: false,
393 require_file: false,
394 ..Default::default()
395 };
396 let result = validate_path("/tmp/subdir/file.txt", &ctx);
397 assert!(result.is_ok(), "/tmp/subdir should match /tmp prefix");
398 }
399
400 #[test]
401 fn test_path_in_prefix() {
402 assert!(path_in_prefix("/tmp", "/tmp"));
403 assert!(path_in_prefix("/tmp/foo", "/tmp"));
404 assert!(path_in_prefix("/tmp/foo/bar", "/tmp"));
405 assert!(!path_in_prefix("/tmpfoo", "/tmp"));
406 assert!(!path_in_prefix("/tmpfoo/bar", "/tmp"));
407 assert!(!path_in_prefix("/etc/shadow", "/tmp"));
408 assert!(path_in_prefix("/home/user/file", "/home"));
409 assert!(!path_in_prefix("/homeless/file", "/home"));
410 }
411}