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