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