runtimo_core/validation/
path.rs1use std::path::{Path, PathBuf};
52use unicode_normalization::UnicodeNormalization;
53
54#[allow(clippy::exhaustive_structs)]
60pub struct PathContext {
61 pub allowed_prefixes: &'static [&'static str],
63 pub require_exists: bool,
65 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
79fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
84 let mut prefixes = crate::config::RuntimoConfig::get_allowed_prefixes();
85
86 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
97pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
124 if path_str.is_empty() {
126 return Err("path is empty".to_string());
127 }
128
129 if path_str.contains('\0') {
131 return Err("path contains null byte".to_string());
132 }
133
134 if path_str.chars().any(|c| c.is_control()) {
137 return Err("path contains control character".to_string());
138 }
139
140 let normalized: String = path_str.nfc().collect();
142
143 if normalized.contains("..") {
145 return Err("path traversal not allowed".to_string());
146 }
147
148 let path = Path::new(&normalized);
149
150 if ctx.require_exists && !path.exists() {
152 return Err(format!(
153 "path does not exist: {}",
154 truncate_path(&normalized)
155 ));
156 }
157
158 let resolved = if path.exists() {
162 path.canonicalize()
163 .map_err(|e| format!("canonicalize failed: {}", e))?
164 } else {
165 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 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 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 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
235fn 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 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 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 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 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 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 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 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 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 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 let result = validate_path("/tmp/\u{00e9}../etc/passwd", &ctx);
385 assert!(result.is_err());
386 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 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}