1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12#[derive(Debug, Clone)]
21pub struct RuntimePolicy {
22 pub allowed_paths: Vec<PathBuf>,
25 pub read_only_paths: Vec<PathBuf>,
27 pub allowed_hosts: Vec<String>,
30 pub memory_limit: Option<usize>,
32 pub time_limit: Option<Duration>,
34 pub output_limit: Option<usize>,
36}
37
38impl RuntimePolicy {
39 pub fn unrestricted() -> Self {
41 Self {
42 allowed_paths: Vec::new(),
43 read_only_paths: Vec::new(),
44 allowed_hosts: Vec::new(),
45 memory_limit: None,
46 time_limit: None,
47 output_limit: None,
48 }
49 }
50
51 pub fn is_path_readable(&self, path: &Path) -> bool {
57 if self.allowed_paths.is_empty() && self.read_only_paths.is_empty() {
58 return true;
59 }
60 self.path_matches_any(path, &self.allowed_paths)
61 || self.path_matches_any(path, &self.read_only_paths)
62 }
63
64 pub fn is_path_writable(&self, path: &Path) -> bool {
71 if self.allowed_paths.is_empty() && self.read_only_paths.is_empty() {
72 return true;
73 }
74 if self.path_matches_any(path, &self.read_only_paths) {
76 return false;
77 }
78 self.path_matches_any(path, &self.allowed_paths)
79 }
80
81 pub fn is_host_allowed(&self, host: &str) -> bool {
86 if self.allowed_hosts.is_empty() {
87 return true;
88 }
89 self.allowed_hosts
90 .iter()
91 .any(|pattern| host_matches(host, pattern))
92 }
93
94 fn path_matches_any(&self, path: &Path, patterns: &[PathBuf]) -> bool {
98 patterns.iter().any(|allowed| path.starts_with(allowed))
99 }
100}
101
102fn host_matches(host: &str, pattern: &str) -> bool {
104 if let Some(suffix) = pattern.strip_prefix("*.") {
105 host.ends_with(suffix) && host.len() > suffix.len()
106 } else {
107 host == pattern
108 }
109}
110
111#[derive(Debug, Clone)]
117pub struct FileMetadata {
118 pub size: u64,
120 pub is_dir: bool,
122 pub is_file: bool,
124 pub readonly: bool,
126}
127
128#[derive(Debug, Clone)]
130pub struct PathEntry {
131 pub path: PathBuf,
133 pub is_dir: bool,
135}
136
137pub trait FileSystemProvider: Send + Sync {
144 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>>;
146 fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
148 fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
150 fn exists(&self, path: &Path) -> bool;
152 fn remove(&self, path: &Path) -> std::io::Result<()>;
154 fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>>;
156 fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata>;
158 fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
160}
161
162#[derive(Debug, Clone, Copy)]
168pub struct RealFileSystem;
169
170impl FileSystemProvider for RealFileSystem {
171 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
172 std::fs::read(path)
173 }
174
175 fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
176 std::fs::write(path, data)
177 }
178
179 fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
180 use std::io::Write;
181 let mut f = std::fs::OpenOptions::new()
182 .append(true)
183 .create(true)
184 .open(path)?;
185 f.write_all(data)
186 }
187
188 fn exists(&self, path: &Path) -> bool {
189 path.exists()
190 }
191
192 fn remove(&self, path: &Path) -> std::io::Result<()> {
193 std::fs::remove_file(path)
194 }
195
196 fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>> {
197 let mut entries = Vec::new();
198 for entry in std::fs::read_dir(path)? {
199 let entry = entry?;
200 entries.push(PathEntry {
201 path: entry.path(),
202 is_dir: entry.file_type()?.is_dir(),
203 });
204 }
205 Ok(entries)
206 }
207
208 fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
209 let m = std::fs::metadata(path)?;
210 Ok(FileMetadata {
211 size: m.len(),
212 is_dir: m.is_dir(),
213 is_file: m.is_file(),
214 readonly: m.permissions().readonly(),
215 })
216 }
217
218 fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
219 std::fs::create_dir_all(path)
220 }
221}
222
223pub struct PolicyEnforcedFs {
233 inner: Arc<dyn FileSystemProvider>,
234 policy: Arc<RuntimePolicy>,
235}
236
237impl PolicyEnforcedFs {
238 pub fn new(inner: Arc<dyn FileSystemProvider>, policy: Arc<RuntimePolicy>) -> Self {
239 Self { inner, policy }
240 }
241
242 fn check_readable(&self, path: &Path) -> std::io::Result<()> {
243 if self.policy.is_path_readable(path) {
244 Ok(())
245 } else {
246 Err(std::io::Error::new(
247 std::io::ErrorKind::PermissionDenied,
248 format!("policy denies read access to {}", path.display()),
249 ))
250 }
251 }
252
253 fn check_writable(&self, path: &Path) -> std::io::Result<()> {
254 if self.policy.is_path_writable(path) {
255 Ok(())
256 } else {
257 Err(std::io::Error::new(
258 std::io::ErrorKind::PermissionDenied,
259 format!("policy denies write access to {}", path.display()),
260 ))
261 }
262 }
263}
264
265impl FileSystemProvider for PolicyEnforcedFs {
266 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
267 self.check_readable(path)?;
268 self.inner.read(path)
269 }
270
271 fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
272 self.check_writable(path)?;
273 self.inner.write(path, data)
274 }
275
276 fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
277 self.check_writable(path)?;
278 self.inner.append(path, data)
279 }
280
281 fn exists(&self, path: &Path) -> bool {
282 self.policy.is_path_readable(path) && self.inner.exists(path)
284 }
285
286 fn remove(&self, path: &Path) -> std::io::Result<()> {
287 self.check_writable(path)?;
288 self.inner.remove(path)
289 }
290
291 fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>> {
292 self.check_readable(path)?;
293 self.inner.list_dir(path)
294 }
295
296 fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
297 self.check_readable(path)?;
298 self.inner.metadata(path)
299 }
300
301 fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
302 self.check_writable(path)?;
303 self.inner.create_dir_all(path)
304 }
305}
306
307pub struct RoutingFileSystem {
316 routes: Vec<(PathBuf, Arc<dyn FileSystemProvider>)>,
317 fallback: Arc<dyn FileSystemProvider>,
318}
319
320impl RoutingFileSystem {
321 pub fn new(
323 routes: Vec<(PathBuf, Arc<dyn FileSystemProvider>)>,
324 fallback: Arc<dyn FileSystemProvider>,
325 ) -> Self {
326 Self { routes, fallback }
327 }
328
329 fn resolve(&self, path: &Path) -> &dyn FileSystemProvider {
330 for (prefix, provider) in &self.routes {
331 if path.starts_with(prefix) {
332 return provider.as_ref();
333 }
334 }
335 self.fallback.as_ref()
336 }
337}
338
339impl FileSystemProvider for RoutingFileSystem {
340 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
341 self.resolve(path).read(path)
342 }
343
344 fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
345 self.resolve(path).write(path, data)
346 }
347
348 fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
349 self.resolve(path).append(path, data)
350 }
351
352 fn exists(&self, path: &Path) -> bool {
353 self.resolve(path).exists(path)
354 }
355
356 fn remove(&self, path: &Path) -> std::io::Result<()> {
357 self.resolve(path).remove(path)
358 }
359
360 fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>> {
361 self.resolve(path).list_dir(path)
362 }
363
364 fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
365 self.resolve(path).metadata(path)
366 }
367
368 fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
369 self.resolve(path).create_dir_all(path)
370 }
371}
372
373#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
384 fn unrestricted_allows_everything() {
385 let policy = RuntimePolicy::unrestricted();
386 assert!(policy.is_path_readable(Path::new("/any/path")));
387 assert!(policy.is_path_writable(Path::new("/any/path")));
388 assert!(policy.is_host_allowed("any.host.com"));
389 }
390
391 #[test]
392 fn allowed_paths_restrict_read() {
393 let policy = RuntimePolicy {
394 allowed_paths: vec![PathBuf::from("/data"), PathBuf::from("/tmp")],
395 ..RuntimePolicy::unrestricted()
396 };
397 assert!(policy.is_path_readable(Path::new("/data/file.txt")));
398 assert!(policy.is_path_readable(Path::new("/tmp/scratch")));
399 assert!(!policy.is_path_readable(Path::new("/etc/passwd")));
400 }
401
402 #[test]
403 fn allowed_paths_restrict_write() {
404 let policy = RuntimePolicy {
405 allowed_paths: vec![PathBuf::from("/data")],
406 ..RuntimePolicy::unrestricted()
407 };
408 assert!(policy.is_path_writable(Path::new("/data/out.txt")));
409 assert!(!policy.is_path_writable(Path::new("/etc/shadow")));
410 }
411
412 #[test]
413 fn read_only_paths_deny_writes() {
414 let policy = RuntimePolicy {
415 allowed_paths: vec![PathBuf::from("/data")],
416 read_only_paths: vec![PathBuf::from("/data/config")],
417 ..RuntimePolicy::unrestricted()
418 };
419 assert!(policy.is_path_readable(Path::new("/data/file.txt")));
421 assert!(policy.is_path_readable(Path::new("/data/config/app.toml")));
422 assert!(policy.is_path_writable(Path::new("/data/file.txt")));
424 assert!(!policy.is_path_writable(Path::new("/data/config/app.toml")));
425 }
426
427 #[test]
428 fn read_only_paths_are_readable_even_without_allowed_paths() {
429 let policy = RuntimePolicy {
430 read_only_paths: vec![PathBuf::from("/docs")],
431 ..RuntimePolicy::unrestricted()
432 };
433 assert!(policy.is_path_readable(Path::new("/docs/readme.md")));
434 assert!(!policy.is_path_writable(Path::new("/docs/readme.md")));
435 assert!(!policy.is_path_readable(Path::new("/other")));
437 }
438
439 #[test]
442 fn exact_host_match() {
443 let policy = RuntimePolicy {
444 allowed_hosts: vec!["api.example.com".into()],
445 ..RuntimePolicy::unrestricted()
446 };
447 assert!(policy.is_host_allowed("api.example.com"));
448 assert!(!policy.is_host_allowed("evil.com"));
449 }
450
451 #[test]
452 fn wildcard_host_match() {
453 let policy = RuntimePolicy {
454 allowed_hosts: vec!["*.example.com".into()],
455 ..RuntimePolicy::unrestricted()
456 };
457 assert!(policy.is_host_allowed("api.example.com"));
458 assert!(policy.is_host_allowed("sub.example.com"));
459 assert!(!policy.is_host_allowed("example.com"));
461 assert!(!policy.is_host_allowed("evil.com"));
462 }
463
464 #[test]
465 fn empty_allowed_hosts_allows_all() {
466 let policy = RuntimePolicy::unrestricted();
467 assert!(policy.is_host_allowed("anything.com"));
468 }
469
470 #[test]
473 fn real_fs_exists() {
474 let fs = RealFileSystem;
475 assert!(fs.exists(Path::new("/")));
477 }
478
479 #[test]
482 fn policy_enforced_fs_denies_unauthorized_read() {
483 let inner: Arc<dyn FileSystemProvider> = Arc::new(RealFileSystem);
484 let policy = Arc::new(RuntimePolicy {
485 allowed_paths: vec![PathBuf::from("/allowed")],
486 ..RuntimePolicy::unrestricted()
487 });
488 let enforced = PolicyEnforcedFs::new(inner, policy);
489 let result = enforced.read(Path::new("/forbidden/file.txt"));
490 assert!(result.is_err());
491 assert_eq!(
492 result.unwrap_err().kind(),
493 std::io::ErrorKind::PermissionDenied
494 );
495 }
496
497 #[test]
498 fn policy_enforced_fs_denies_unauthorized_write() {
499 let inner: Arc<dyn FileSystemProvider> = Arc::new(RealFileSystem);
500 let policy = Arc::new(RuntimePolicy {
501 allowed_paths: vec![PathBuf::from("/allowed")],
502 ..RuntimePolicy::unrestricted()
503 });
504 let enforced = PolicyEnforcedFs::new(inner, policy);
505 let result = enforced.write(Path::new("/forbidden/file.txt"), b"data");
506 assert!(result.is_err());
507 assert_eq!(
508 result.unwrap_err().kind(),
509 std::io::ErrorKind::PermissionDenied
510 );
511 }
512
513 #[test]
514 fn policy_enforced_fs_hides_existence() {
515 let inner: Arc<dyn FileSystemProvider> = Arc::new(RealFileSystem);
516 let policy = Arc::new(RuntimePolicy {
517 allowed_paths: vec![PathBuf::from("/nonexistent_prefix")],
518 ..RuntimePolicy::unrestricted()
519 });
520 let enforced = PolicyEnforcedFs::new(inner, policy);
521 assert!(!enforced.exists(Path::new("/")));
523 }
524
525 struct ConstFs {
529 data: Vec<u8>,
530 }
531
532 impl FileSystemProvider for ConstFs {
533 fn read(&self, _path: &Path) -> std::io::Result<Vec<u8>> {
534 Ok(self.data.clone())
535 }
536 fn write(&self, _path: &Path, _data: &[u8]) -> std::io::Result<()> {
537 Ok(())
538 }
539 fn append(&self, _path: &Path, _data: &[u8]) -> std::io::Result<()> {
540 Ok(())
541 }
542 fn exists(&self, _path: &Path) -> bool {
543 true
544 }
545 fn remove(&self, _path: &Path) -> std::io::Result<()> {
546 Ok(())
547 }
548 fn list_dir(&self, _path: &Path) -> std::io::Result<Vec<PathEntry>> {
549 Ok(Vec::new())
550 }
551 fn metadata(&self, _path: &Path) -> std::io::Result<FileMetadata> {
552 Ok(FileMetadata {
553 size: self.data.len() as u64,
554 is_dir: false,
555 is_file: true,
556 readonly: false,
557 })
558 }
559 fn create_dir_all(&self, _path: &Path) -> std::io::Result<()> {
560 Ok(())
561 }
562 }
563
564 #[test]
565 fn routing_fs_dispatches_by_prefix() {
566 let a: Arc<dyn FileSystemProvider> = Arc::new(ConstFs {
567 data: vec![1, 2, 3],
568 });
569 let b: Arc<dyn FileSystemProvider> = Arc::new(ConstFs {
570 data: vec![4, 5, 6],
571 });
572 let fallback: Arc<dyn FileSystemProvider> = Arc::new(ConstFs { data: vec![0] });
573
574 let router = RoutingFileSystem::new(
575 vec![(PathBuf::from("/a"), a), (PathBuf::from("/b"), b)],
576 fallback,
577 );
578
579 assert_eq!(router.read(Path::new("/a/file")).unwrap(), vec![1, 2, 3]);
580 assert_eq!(router.read(Path::new("/b/file")).unwrap(), vec![4, 5, 6]);
581 assert_eq!(router.read(Path::new("/c/file")).unwrap(), vec![0]);
582 }
583
584 #[test]
585 fn routing_fs_first_match_wins() {
586 let first: Arc<dyn FileSystemProvider> = Arc::new(ConstFs { data: vec![1] });
587 let second: Arc<dyn FileSystemProvider> = Arc::new(ConstFs { data: vec![2] });
588 let fallback: Arc<dyn FileSystemProvider> = Arc::new(ConstFs { data: vec![0] });
589
590 let router = RoutingFileSystem::new(
591 vec![
592 (PathBuf::from("/data"), first),
593 (PathBuf::from("/data"), second),
594 ],
595 fallback,
596 );
597
598 assert_eq!(router.read(Path::new("/data/x")).unwrap(), vec![1]);
599 }
600}