runtimo_core/validation/
path.rs1use std::path::{Path, PathBuf};
43use unicode_normalization::UnicodeNormalization;
44
45pub struct PathContext {
51 pub allowed_prefixes: &'static [&'static str],
53 pub require_exists: bool,
55 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
69fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
74 let mut prefixes = crate::config::RuntimoConfig::get_allowed_prefixes();
75
76 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
87pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
101 if path_str.is_empty() {
103 return Err("path is empty".to_string());
104 }
105
106 if path_str.contains('\0') {
108 return Err("path contains null byte".to_string());
109 }
110
111 if !path_str.is_ascii() {
114 return Err("non-ASCII paths are not supported".to_string());
115 }
116
117 let normalized: String = path_str.nfc().collect();
119
120 if normalized.contains("..") {
122 return Err("path traversal not allowed".to_string());
123 }
124
125 let path = Path::new(&normalized);
126
127 if ctx.require_exists && !path.exists() {
129 return Err(format!("path does not exist: {}", normalized));
130 }
131
132 let resolved = if path.exists() {
136 path.canonicalize()
137 .map_err(|e| format!("canonicalize failed: {}", e))?
138 } else {
139 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 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 if ctx.require_file && resolved.exists() && !resolved.is_file() {
166 return Err(format!("not a file: {}", resolved.display()));
167 }
168
169 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
183fn 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 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 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 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 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 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 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 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}