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