Skip to main content

rustant_tools/sandbox/
config.rs

1//! Sandbox configuration types and capability definitions.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::time::Duration;
6
7// ---------------------------------------------------------------------------
8// Capability
9// ---------------------------------------------------------------------------
10
11/// A capability that can be granted to sandboxed code.
12///
13/// Each variant restricts what the WASM module may access. Paths, hosts, and
14/// environment variable names are provided as allow-lists so only explicitly
15/// permitted resources are reachable.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Capability {
18    /// Read access to specific file-system paths.
19    FileRead(Vec<PathBuf>),
20    /// Write access to specific file-system paths.
21    FileWrite(Vec<PathBuf>),
22    /// Network access to specific hosts or URLs.
23    NetworkAccess(Vec<String>),
24    /// Read access to specific environment variables.
25    EnvironmentRead(Vec<String>),
26    /// Permission to write to stdout.
27    Stdout,
28    /// Permission to write to stderr.
29    Stderr,
30}
31
32// ---------------------------------------------------------------------------
33// ResourceLimits
34// ---------------------------------------------------------------------------
35
36/// Resource constraints applied to a sandbox execution.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ResourceLimits {
39    /// Maximum linear memory in bytes (default: 16 MiB).
40    pub max_memory_bytes: usize,
41    /// Instruction fuel budget (default: 1,000,000).
42    pub max_fuel: u64,
43    /// Wall-clock execution time limit (default: 30 s).
44    #[serde(with = "duration_serde")]
45    pub max_execution_time: Duration,
46}
47
48impl Default for ResourceLimits {
49    fn default() -> Self {
50        Self {
51            max_memory_bytes: 16 * 1024 * 1024, // 16 MiB
52            max_fuel: 1_000_000,
53            max_execution_time: Duration::from_secs(30),
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// SandboxConfig
60// ---------------------------------------------------------------------------
61
62/// Configuration for a WASM sandbox execution environment.
63///
64/// Use the builder methods to customise limits and capabilities:
65///
66/// ```rust
67/// use rustant_tools::sandbox::config::{SandboxConfig, Capability};
68/// use std::time::Duration;
69///
70/// let config = SandboxConfig::new()
71///     .with_memory_limit(32 * 1024 * 1024)
72///     .with_fuel_limit(2_000_000)
73///     .with_timeout(Duration::from_secs(60))
74///     .with_capability(Capability::Stdout)
75///     .allow_host_calls();
76/// ```
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SandboxConfig {
79    /// Resource limits governing memory, fuel, and wall-clock time.
80    pub resource_limits: ResourceLimits,
81    /// Capabilities granted to the sandboxed module.
82    pub capabilities: Vec<Capability>,
83    /// Whether the module may invoke host functions.
84    pub allow_host_calls: bool,
85}
86
87impl SandboxConfig {
88    /// Create a new `SandboxConfig` with default values.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Set the maximum linear memory in bytes.
94    pub fn with_memory_limit(mut self, bytes: usize) -> Self {
95        self.resource_limits.max_memory_bytes = bytes;
96        self
97    }
98
99    /// Set the instruction fuel budget.
100    pub fn with_fuel_limit(mut self, fuel: u64) -> Self {
101        self.resource_limits.max_fuel = fuel;
102        self
103    }
104
105    /// Set the wall-clock execution timeout.
106    pub fn with_timeout(mut self, duration: Duration) -> Self {
107        self.resource_limits.max_execution_time = duration;
108        self
109    }
110
111    /// Add a single capability.
112    pub fn with_capability(mut self, cap: Capability) -> Self {
113        self.capabilities.push(cap);
114        self
115    }
116
117    /// Add multiple capabilities at once.
118    pub fn with_capabilities(mut self, caps: impl IntoIterator<Item = Capability>) -> Self {
119        self.capabilities.extend(caps);
120        self
121    }
122
123    /// Enable host function calls from within the sandbox.
124    pub fn allow_host_calls(mut self) -> Self {
125        self.allow_host_calls = true;
126        self
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Serde helper for `Duration`
132// ---------------------------------------------------------------------------
133
134/// Custom serde module for `std::time::Duration`, serialised as a
135/// `{ secs, nanos }` pair so it round-trips through JSON cleanly.
136mod duration_serde {
137    use serde::{Deserialize, Deserializer, Serialize, Serializer};
138    use std::time::Duration;
139
140    #[derive(Serialize, Deserialize)]
141    struct DurationRepr {
142        secs: u64,
143        nanos: u32,
144    }
145
146    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
147    where
148        S: Serializer,
149    {
150        let repr = DurationRepr {
151            secs: duration.as_secs(),
152            nanos: duration.subsec_nanos(),
153        };
154        repr.serialize(serializer)
155    }
156
157    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
158    where
159        D: Deserializer<'de>,
160    {
161        let repr = DurationRepr::deserialize(deserializer)?;
162        Ok(Duration::new(repr.secs, repr.nanos))
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Tests
168// ---------------------------------------------------------------------------
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    // -- Default values ------------------------------------------------------
175
176    #[test]
177    fn test_sandbox_config_default_values() {
178        let config = SandboxConfig::default();
179
180        assert_eq!(config.resource_limits.max_memory_bytes, 16 * 1024 * 1024,);
181        assert_eq!(config.resource_limits.max_fuel, 1_000_000);
182        assert_eq!(
183            config.resource_limits.max_execution_time,
184            Duration::from_secs(30),
185        );
186        assert!(config.capabilities.is_empty());
187        assert!(!config.allow_host_calls);
188    }
189
190    #[test]
191    fn test_sandbox_config_new_equals_default() {
192        assert_eq!(SandboxConfig::new(), SandboxConfig::default());
193    }
194
195    #[test]
196    fn test_resource_limits_default() {
197        let limits = ResourceLimits::default();
198
199        assert_eq!(limits.max_memory_bytes, 16 * 1024 * 1024);
200        assert_eq!(limits.max_fuel, 1_000_000);
201        assert_eq!(limits.max_execution_time, Duration::from_secs(30));
202    }
203
204    // -- Builder pattern -----------------------------------------------------
205
206    #[test]
207    fn test_builder_chain() {
208        let config = SandboxConfig::new()
209            .with_memory_limit(32 * 1024 * 1024)
210            .with_fuel_limit(2_000_000)
211            .with_timeout(Duration::from_secs(60))
212            .with_capability(Capability::Stdout)
213            .with_capability(Capability::Stderr)
214            .allow_host_calls();
215
216        assert_eq!(config.resource_limits.max_memory_bytes, 32 * 1024 * 1024);
217        assert_eq!(config.resource_limits.max_fuel, 2_000_000);
218        assert_eq!(
219            config.resource_limits.max_execution_time,
220            Duration::from_secs(60),
221        );
222        assert_eq!(config.capabilities.len(), 2);
223        assert!(config.allow_host_calls);
224    }
225
226    #[test]
227    fn test_builder_with_capabilities_batch() {
228        let caps = vec![
229            Capability::Stdout,
230            Capability::Stderr,
231            Capability::NetworkAccess(vec!["localhost".to_string()]),
232        ];
233
234        let config = SandboxConfig::new().with_capabilities(caps);
235
236        assert_eq!(config.capabilities.len(), 3);
237    }
238
239    #[test]
240    fn test_builder_with_memory_limit() {
241        let config = SandboxConfig::new().with_memory_limit(64 * 1024 * 1024);
242        assert_eq!(config.resource_limits.max_memory_bytes, 64 * 1024 * 1024);
243        // Other limits remain at defaults.
244        assert_eq!(config.resource_limits.max_fuel, 1_000_000);
245    }
246
247    #[test]
248    fn test_builder_allow_host_calls() {
249        let config = SandboxConfig::new();
250        assert!(!config.allow_host_calls);
251
252        let config = config.allow_host_calls();
253        assert!(config.allow_host_calls);
254    }
255
256    // -- Capabilities --------------------------------------------------------
257
258    #[test]
259    fn test_capability_file_read() {
260        let cap = Capability::FileRead(vec![
261            PathBuf::from("/tmp/data"),
262            PathBuf::from("/home/user/docs"),
263        ]);
264
265        if let Capability::FileRead(paths) = &cap {
266            assert_eq!(paths.len(), 2);
267            assert_eq!(paths[0], PathBuf::from("/tmp/data"));
268        } else {
269            panic!("expected FileRead variant");
270        }
271    }
272
273    #[test]
274    fn test_config_with_various_capabilities() {
275        let config = SandboxConfig::new()
276            .with_capability(Capability::FileRead(vec![PathBuf::from("/data")]))
277            .with_capability(Capability::FileWrite(vec![PathBuf::from("/output")]))
278            .with_capability(Capability::NetworkAccess(vec![
279                "api.example.com".to_string(),
280            ]))
281            .with_capability(Capability::EnvironmentRead(vec![
282                "HOME".to_string(),
283                "PATH".to_string(),
284            ]))
285            .with_capability(Capability::Stdout)
286            .with_capability(Capability::Stderr);
287
288        assert_eq!(config.capabilities.len(), 6);
289    }
290
291    // -- Serialization round-trips -------------------------------------------
292
293    #[test]
294    fn test_sandbox_config_serde_round_trip() {
295        let config = SandboxConfig::new()
296            .with_memory_limit(8 * 1024 * 1024)
297            .with_fuel_limit(500_000)
298            .with_timeout(Duration::from_secs(10))
299            .with_capability(Capability::Stdout)
300            .with_capability(Capability::FileRead(vec![PathBuf::from("/tmp")]))
301            .allow_host_calls();
302
303        let json = serde_json::to_string(&config).unwrap();
304        let decoded: SandboxConfig = serde_json::from_str(&json).unwrap();
305
306        assert_eq!(config, decoded);
307    }
308
309    #[test]
310    fn test_resource_limits_serde_round_trip() {
311        let limits = ResourceLimits {
312            max_memory_bytes: 4 * 1024 * 1024,
313            max_fuel: 250_000,
314            max_execution_time: Duration::from_millis(1500),
315        };
316
317        let json = serde_json::to_string(&limits).unwrap();
318        let decoded: ResourceLimits = serde_json::from_str(&json).unwrap();
319
320        assert_eq!(limits, decoded);
321    }
322
323    #[test]
324    fn test_capability_serde_round_trip() {
325        let caps = vec![
326            Capability::FileRead(vec![PathBuf::from("/a"), PathBuf::from("/b")]),
327            Capability::FileWrite(vec![PathBuf::from("/c")]),
328            Capability::NetworkAccess(vec!["example.com".to_string()]),
329            Capability::EnvironmentRead(vec!["HOME".to_string()]),
330            Capability::Stdout,
331            Capability::Stderr,
332        ];
333
334        let json = serde_json::to_string(&caps).unwrap();
335        let decoded: Vec<Capability> = serde_json::from_str(&json).unwrap();
336
337        assert_eq!(caps, decoded);
338    }
339
340    #[test]
341    fn test_default_config_serde_round_trip() {
342        let config = SandboxConfig::default();
343        let json = serde_json::to_string(&config).unwrap();
344        let decoded: SandboxConfig = serde_json::from_str(&json).unwrap();
345
346        assert_eq!(config, decoded);
347    }
348
349    #[test]
350    fn test_duration_json_shape() {
351        let limits = ResourceLimits::default();
352        let value: serde_json::Value = serde_json::to_value(&limits).unwrap();
353
354        // max_execution_time should serialise as { secs, nanos }
355        let time = value.get("max_execution_time").unwrap();
356        assert_eq!(time.get("secs").unwrap(), 30);
357        assert_eq!(time.get("nanos").unwrap(), 0);
358    }
359}