runtimo_core/validation/
path.rs1use std::path::{Path, PathBuf};
44use unicode_normalization::UnicodeNormalization;
45
46#[allow(clippy::exhaustive_structs)]
52pub struct PathContext {
53 pub allowed_prefixes: &'static [&'static str],
55 pub require_exists: bool,
57 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
71fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
76 let mut prefixes = crate::config::RuntimoConfig::get_allowed_prefixes();
77
78 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
89pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
108 if path_str.is_empty() {
110 return Err("path is empty".to_string());
111 }
112
113 if path_str.contains('\0') {
115 return Err("path contains null byte".to_string());
116 }
117
118 if !path_str.is_ascii() {
121 return Err("non-ASCII paths are not supported".to_string());
122 }
123
124 let normalized: String = path_str.nfc().collect();
126
127 if normalized.contains("..") {
129 return Err("path traversal not allowed".to_string());
130 }
131
132 let path = Path::new(&normalized);
133
134 if ctx.require_exists && !path.exists() {
136 return Err(format!("path does not exist: {}", normalized));
137 }
138
139 let resolved = if path.exists() {
143 path.canonicalize()
144 .map_err(|e| format!("canonicalize failed: {}", e))?
145 } else {
146 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 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 if ctx.require_file && resolved.exists() && !resolved.is_file() {
173 return Err(format!("not a file: {}", resolved.display()));
174 }
175
176 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
193fn 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 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 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 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 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 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 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 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 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}