runtimo_core/validation/
path.rs1use std::path::{Path, PathBuf};
43use unicode_normalization::UnicodeNormalization;
44
45#[allow(clippy::exhaustive_structs)]
51pub struct PathContext {
52 pub allowed_prefixes: &'static [&'static str],
54 pub require_exists: bool,
56 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
70fn get_allowed_prefixes(ctx: &PathContext) -> Vec<String> {
75 let mut prefixes = crate::config::RuntimoConfig::get_allowed_prefixes();
76
77 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
88pub fn validate_path(path_str: &str, ctx: &PathContext) -> Result<PathBuf, String> {
107 if path_str.is_empty() {
109 return Err("path is empty".to_string());
110 }
111
112 if path_str.contains('\0') {
114 return Err("path contains null byte".to_string());
115 }
116
117 if !path_str.is_ascii() {
120 return Err("non-ASCII paths are not supported".to_string());
121 }
122
123 let normalized: String = path_str.nfc().collect();
125
126 if normalized.contains("..") {
128 return Err("path traversal not allowed".to_string());
129 }
130
131 let path = Path::new(&normalized);
132
133 if ctx.require_exists && !path.exists() {
135 return Err(format!("path does not exist: {}", normalized));
136 }
137
138 let resolved = if path.exists() {
142 path.canonicalize()
143 .map_err(|e| format!("canonicalize failed: {}", e))?
144 } else {
145 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 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 if ctx.require_file && resolved.exists() && !resolved.is_file() {
172 return Err(format!("not a file: {}", resolved.display()));
173 }
174
175 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
192fn 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 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 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 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 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 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 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 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}