Skip to main content

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}