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