Skip to main content

shape_runtime/stdlib/
runtime_policy.rs

1//! Runtime policy and filesystem provider abstraction.
2//!
3//! [`RuntimePolicy`] captures resource limits and scoped access rules.
4//! [`FileSystemProvider`] is the trait through which all stdlib filesystem
5//! operations are dispatched, allowing the host to swap in a virtual FS,
6//! a policy-enforced wrapper, or a routing layer without changing callers.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12// ============================================================================
13// Runtime Policy
14// ============================================================================
15
16/// Runtime-scoped policy governing what a Shape program may access.
17///
18/// Threaded through the VM execution context as `Option<Arc<RuntimePolicy>>`.
19/// `None` means unrestricted (default for trusted programs).
20#[derive(Debug, Clone)]
21pub struct RuntimePolicy {
22    /// Filesystem paths the program may access (glob patterns supported).
23    /// Empty means all paths are allowed unless the program lacks `FsRead`/`FsWrite`.
24    pub allowed_paths: Vec<PathBuf>,
25    /// Paths that may only be read, never written.
26    pub read_only_paths: Vec<PathBuf>,
27    /// Network hosts the program may connect to (supports `*.example.com`).
28    /// Empty means all hosts are allowed unless the program lacks `NetConnect`.
29    pub allowed_hosts: Vec<String>,
30    /// Maximum heap memory in bytes. `None` = unlimited.
31    pub memory_limit: Option<usize>,
32    /// Maximum wall-clock execution time. `None` = unlimited.
33    pub time_limit: Option<Duration>,
34    /// Maximum output bytes (stdout + sink). `None` = unlimited.
35    pub output_limit: Option<usize>,
36}
37
38impl RuntimePolicy {
39    /// Unrestricted policy (equivalent to not having a policy at all).
40    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    /// Check whether `path` is allowed for reading.
52    ///
53    /// Returns `true` when:
54    /// - `allowed_paths` is empty (no path restrictions), or
55    /// - `path` matches at least one entry in `allowed_paths` or `read_only_paths`.
56    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    /// Check whether `path` is allowed for writing.
65    ///
66    /// Returns `true` when:
67    /// - `allowed_paths` is empty **and** `read_only_paths` is empty, or
68    /// - `path` matches at least one entry in `allowed_paths` **and** does NOT
69    ///   match any entry in `read_only_paths`.
70    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 in read-only list, deny writes.
75        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    /// Check whether a network host is allowed.
82    ///
83    /// Returns `true` when `allowed_hosts` is empty or `host` matches at
84    /// least one pattern.
85    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    /// Does `path` match any entry in `patterns`?
95    ///
96    /// Matching is prefix-based: `/data` matches `/data/file.txt`.
97    fn path_matches_any(&self, path: &Path, patterns: &[PathBuf]) -> bool {
98        patterns.iter().any(|allowed| path.starts_with(allowed))
99    }
100}
101
102/// Simple wildcard host matching: `*.example.com` matches `api.example.com`.
103fn 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// ============================================================================
112// Filesystem Provider Trait
113// ============================================================================
114
115/// Metadata about a filesystem entry.
116#[derive(Debug, Clone)]
117pub struct FileMetadata {
118    /// Total size in bytes.
119    pub size: u64,
120    /// True if this entry is a directory.
121    pub is_dir: bool,
122    /// True if this entry is a regular file.
123    pub is_file: bool,
124    /// True if the file/directory is read-only.
125    pub readonly: bool,
126}
127
128/// A single entry returned by `list_dir`.
129#[derive(Debug, Clone)]
130pub struct PathEntry {
131    /// Absolute path to the entry.
132    pub path: PathBuf,
133    /// True if the entry is a directory.
134    pub is_dir: bool,
135}
136
137/// Trait for all filesystem operations used by the Shape stdlib.
138///
139/// Implementations include:
140/// - [`RealFileSystem`] — delegates to `std::fs`
141/// - [`PolicyEnforcedFs`] — wraps another provider with permission checks
142/// - `VirtualFilesystem` (in `virtual_fs.rs`) — in-memory sandbox
143pub trait FileSystemProvider: Send + Sync {
144    /// Read the entire contents of a file.
145    fn read(&self, path: &Path) -> std::io::Result<Vec<u8>>;
146    /// Write `data` to a file, creating or truncating as needed.
147    fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
148    /// Append `data` to a file.
149    fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
150    /// Check whether a path exists.
151    fn exists(&self, path: &Path) -> bool;
152    /// Remove a file.
153    fn remove(&self, path: &Path) -> std::io::Result<()>;
154    /// List entries in a directory.
155    fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>>;
156    /// Query metadata for a path.
157    fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata>;
158    /// Recursively create directories.
159    fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
160}
161
162// ============================================================================
163// RealFileSystem
164// ============================================================================
165
166/// Default filesystem provider that delegates to `std::fs`.
167#[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
223// ============================================================================
224// PolicyEnforcedFs
225// ============================================================================
226
227/// A filesystem provider that wraps another and enforces a [`RuntimePolicy`].
228///
229/// All read operations check `policy.is_path_readable()`; all write operations
230/// check `policy.is_path_writable()`. If the check fails,
231/// `io::ErrorKind::PermissionDenied` is returned.
232pub 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        // exists is a read-like check — deny if not readable
283        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
307// ============================================================================
308// RoutingFileSystem
309// ============================================================================
310
311/// Routes filesystem operations to different providers based on path prefix.
312///
313/// Entries are checked in order; the first matching prefix wins. If no prefix
314/// matches, the fallback provider is used.
315pub struct RoutingFileSystem {
316    routes: Vec<(PathBuf, Arc<dyn FileSystemProvider>)>,
317    fallback: Arc<dyn FileSystemProvider>,
318}
319
320impl RoutingFileSystem {
321    /// Create a routing FS with the given prefix-to-provider map and a fallback.
322    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// ============================================================================
374// Tests
375// ============================================================================
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    // -- RuntimePolicy path checks --
382
383    #[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        // Can read both
420        assert!(policy.is_path_readable(Path::new("/data/file.txt")));
421        assert!(policy.is_path_readable(Path::new("/data/config/app.toml")));
422        // Can write to /data but not to /data/config
423        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        // Outside both lists — denied when lists are non-empty
436        assert!(!policy.is_path_readable(Path::new("/other")));
437    }
438
439    // -- Host matching --
440
441    #[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        // The bare domain should NOT match *.example.com
460        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    // -- RealFileSystem basic smoke test --
471
472    #[test]
473    fn real_fs_exists() {
474        let fs = RealFileSystem;
475        // Cargo.toml should exist in the workspace
476        assert!(fs.exists(Path::new("/")));
477    }
478
479    // -- PolicyEnforcedFs --
480
481    #[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        // "/" exists on disk but the policy doesn't allow reading it
522        assert!(!enforced.exists(Path::new("/")));
523    }
524
525    // -- RoutingFileSystem --
526
527    /// A trivial in-memory FS for testing routing.
528    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}