1#![doc = include_str!("../README.md")]
18#![deny(missing_docs)]
19
20mod capability;
21mod execution;
22mod requirements;
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::ffi::OsString;
26use std::fmt;
27use std::marker::PhantomData;
28use std::net::{IpAddr, SocketAddr};
29use std::path::Path;
30use std::sync::Arc;
31
32use cageforge_command::{CommandRequest, CommandSpec, StdioSpec, TimeoutPolicy};
33use cageforge_path::normalize_lexical_path;
34use cageforge_policy::{
35 ConnectionAuthorization, FilesystemDecision, NetworkDecision, PathResolutionContext,
36 PathSelector, ResolvedNetworkTarget,
37};
38use cageforge_policy_compose::{
39 EffectiveFilesystemLowering, EffectiveNetworkLowering, EffectivePathContext, EffectiveSandbox,
40 EnvironmentInput,
41};
42mod model;
43
44pub use execution::{DynSandbox, Sandbox, SandboxChild, SandboxExecutionError};
45
46pub use model::{
47 BackendCapabilities, BackendCapability, BackendContractError, BackendIdentity, BackendRequest,
48 PreparedBackendRequest, SandboxBackend,
49};
50
51impl BackendCapabilities {
52 pub const fn new() -> Self {
54 Self {
55 capabilities: BTreeSet::new(),
56 }
57 }
58
59 pub fn from_capabilities<I>(capabilities: I) -> Self
61 where
62 I: IntoIterator<Item = BackendCapability>,
63 {
64 Self {
65 capabilities: capabilities.into_iter().collect(),
66 }
67 }
68
69 pub fn with(mut self, capability: BackendCapability) -> Self {
71 self.capabilities.insert(capability);
72 self
73 }
74
75 pub fn supports(&self, capability: BackendCapability) -> bool {
77 self.capabilities.contains(&capability)
78 }
79
80 pub fn iter(&self) -> impl Iterator<Item = &BackendCapability> {
82 self.capabilities.iter()
83 }
84}
85
86impl FromIterator<BackendCapability> for BackendCapabilities {
87 fn from_iter<T: IntoIterator<Item = BackendCapability>>(iter: T) -> Self {
88 Self::from_capabilities(iter)
89 }
90}
91
92impl<'a> BackendRequest<'a> {
93 pub const fn new(command: &'a CommandRequest, sandbox: &'a EffectiveSandbox) -> Self {
95 Self { command, sandbox }
96 }
97
98 pub const fn command(&self) -> &'a CommandRequest {
100 self.command
101 }
102
103 pub const fn sandbox(&self) -> &'a EffectiveSandbox {
105 self.sandbox
106 }
107
108 pub fn prepare_for<B: SandboxBackend>(
119 self,
120 backend: &B,
121 base_context: &PathResolutionContext,
122 ) -> Result<PreparedBackendRequest<'a, B>, BackendContractError> {
123 self.validate::<B>(&backend.capabilities(), backend.identity(), base_context)
124 }
125
126 pub fn required_capabilities(&self) -> BackendCapabilities {
128 let mut required = BackendCapabilities::new().with(BackendCapability::CommandExecution);
129 requirements::add_command_capabilities(&mut required, self.command);
130 requirements::add_filesystem_capabilities(&mut required, self.sandbox);
131 requirements::add_network_capabilities(&mut required, self.sandbox);
132 requirements::add_environment_capabilities(&mut required, self.sandbox);
133 required
134 }
135
136 fn validate<B: SandboxBackend>(
143 self,
144 capabilities: &BackendCapabilities,
145 backend_identity: &BackendIdentity,
146 base_context: &PathResolutionContext,
147 ) -> Result<PreparedBackendRequest<'a, B>, BackendContractError> {
148 if !self
149 .sandbox
150 .environment()
151 .requested_matches(self.command.environment())
152 {
153 return Err(BackendContractError::CommandEnvironmentMismatch);
154 }
155 for capability in self.required_capabilities().iter().copied() {
156 if !capabilities.supports(capability) {
157 return Err(BackendContractError::UnsupportedCapability { capability });
158 }
159 }
160 let path_context = self
161 .sandbox
162 .path_context(base_context)
163 .map_err(|source| BackendContractError::InvalidRuntimeContext { source })?;
164 let working_directory = match self.command.working_directory() {
165 Some(path) if path.is_absolute() => normalize_lexical_path(path).into_owned(),
166 Some(path) => {
167 let current_directory = base_context.current_directory().ok_or_else(|| {
168 BackendContractError::WorkingDirectoryResolution {
169 path: path.to_path_buf(),
170 }
171 })?;
172 normalize_lexical_path(¤t_directory.join(path)).into_owned()
173 }
174 None => base_context
175 .current_directory()
176 .map(normalize_lexical_path)
177 .map(std::borrow::Cow::into_owned)
178 .ok_or(BackendContractError::MissingRuntimeCurrentDirectory)?,
179 };
180 match self
181 .sandbox
182 .filesystem()
183 .access_for_path(&working_directory, &path_context)
184 .map_err(|source| BackendContractError::FilesystemEvaluation { source })?
185 {
186 FilesystemDecision::Read
187 | FilesystemDecision::Write
188 | FilesystemDecision::ExternallyEnforced => {}
189 FilesystemDecision::Deny => {
190 return Err(BackendContractError::WorkingDirectoryDenied {
191 path: working_directory.clone(),
192 });
193 }
194 }
195 Ok(PreparedBackendRequest {
196 request: self,
197 path_context,
198 working_directory,
199 capabilities: capabilities.clone(),
200 backend_identity: backend_identity.clone(),
201 backend: PhantomData,
202 })
203 }
204}
205
206impl BackendIdentity {
207 #[allow(clippy::new_without_default)]
209 pub fn new() -> Self {
210 Self(Arc::new(()))
211 }
212}
213
214impl fmt::Debug for BackendIdentity {
215 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216 formatter
217 .debug_struct("BackendIdentity")
218 .finish_non_exhaustive()
219 }
220}
221
222impl PartialEq for BackendIdentity {
223 fn eq(&self, other: &Self) -> bool {
224 Arc::ptr_eq(&self.0, &other.0)
225 }
226}
227
228impl Eq for BackendIdentity {}
229
230impl<'a, B: SandboxBackend> Clone for PreparedBackendRequest<'a, B> {
231 fn clone(&self) -> Self {
232 Self {
233 request: self.request,
234 path_context: self.path_context.clone(),
235 working_directory: self.working_directory.clone(),
236 capabilities: self.capabilities.clone(),
237 backend_identity: self.backend_identity.clone(),
238 backend: PhantomData,
239 }
240 }
241}
242
243impl<'a, B: SandboxBackend> fmt::Debug for PreparedBackendRequest<'a, B> {
244 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 formatter
246 .debug_struct("PreparedBackendRequest")
247 .field("request", &self.request)
248 .field("path_context", &self.path_context)
249 .field("working_directory", &self.working_directory)
250 .field("capabilities", &self.capabilities)
251 .finish()
252 }
253}
254
255impl<'a, B: SandboxBackend> PreparedBackendRequest<'a, B> {
256 fn ensure_backend(&self, backend: &B) -> Result<(), BackendContractError> {
257 if self.backend_identity != *backend.identity() {
258 Err(BackendContractError::BackendIdentityMismatch)
259 } else if self.capabilities != backend.capabilities() {
260 Err(BackendContractError::BackendCapabilitiesMismatch)
261 } else {
262 Ok(())
263 }
264 }
265
266 pub fn command_spec(&self, backend: &B) -> Result<&'a CommandSpec, BackendContractError> {
272 self.ensure_backend(backend)?;
273 Ok(self.request.command().command())
274 }
275
276 pub fn sandbox(&self, backend: &B) -> Result<&'a EffectiveSandbox, BackendContractError> {
278 self.ensure_backend(backend)?;
279 Ok(self.request.sandbox())
280 }
281
282 pub fn filesystem_lowering(
290 &self,
291 backend: &B,
292 ) -> Result<EffectiveFilesystemLowering<'_>, BackendContractError> {
293 self.ensure_backend(backend)?;
294 Ok(self.request.sandbox().filesystem().lowering())
295 }
296
297 pub fn network_lowering(
303 &self,
304 backend: &B,
305 ) -> Result<EffectiveNetworkLowering<'_>, BackendContractError> {
306 self.ensure_backend(backend)?;
307 Ok(self.request.sandbox().network().lowering())
308 }
309
310 pub fn path_context(&self, backend: &B) -> Result<&EffectivePathContext, BackendContractError> {
313 self.ensure_backend(backend)?;
314 Ok(&self.path_context)
315 }
316
317 pub fn working_directory(&self, backend: &B) -> Result<&Path, BackendContractError> {
323 self.ensure_backend(backend)?;
324 Ok(&self.working_directory)
325 }
326
327 pub fn stdio(&self, backend: &B) -> Result<StdioSpec, BackendContractError> {
329 self.ensure_backend(backend)?;
330 Ok(self.request.command().stdio())
331 }
332
333 pub fn timeout_policy(&self, backend: &B) -> Result<TimeoutPolicy, BackendContractError> {
335 self.ensure_backend(backend)?;
336 Ok(self.request.command().timeout_policy())
337 }
338
339 pub fn apply_environment(
344 &self,
345 backend: &B,
346 input: EnvironmentInput,
347 ) -> Result<BTreeMap<OsString, OsString>, BackendContractError> {
348 self.ensure_backend(backend)?;
349 self.request
350 .sandbox()
351 .environment()
352 .apply_to(input)
353 .map_err(|source| BackendContractError::EnvironmentPreparation { source })
354 }
355
356 pub fn filesystem_access_for_path(
358 &self,
359 backend: &B,
360 path: &Path,
361 ) -> Result<FilesystemDecision, BackendContractError> {
362 self.ensure_backend(backend)?;
363 self.request
364 .sandbox()
365 .filesystem()
366 .access_for_path(path, &self.path_context)
367 .map_err(|source| BackendContractError::FilesystemEvaluation { source })
368 }
369
370 pub fn filesystem_access_for(
377 &self,
378 backend: &B,
379 selector: &PathSelector,
380 ) -> Result<FilesystemDecision, BackendContractError> {
381 self.ensure_backend(backend)?;
382 self.request
383 .sandbox()
384 .filesystem()
385 .access_for(selector, &self.path_context)
386 .map_err(|source| BackendContractError::FilesystemEvaluation { source })
387 }
388
389 pub fn network_decision_for_domain_with_resolved_ips(
394 &self,
395 backend: &B,
396 domain: &str,
397 resolved_ips: &[IpAddr],
398 ) -> Result<NetworkDecision, BackendContractError> {
399 self.ensure_backend(backend)?;
400 self.request
401 .sandbox()
402 .network()
403 .decision_for_domain_with_resolved_ips(domain, resolved_ips)
404 .map_err(|source| BackendContractError::NetworkEvaluation { source })
405 }
406
407 pub fn authorize_connection(
409 &self,
410 backend: &B,
411 target: &ResolvedNetworkTarget,
412 connected: SocketAddr,
413 ) -> Result<ConnectionAuthorization, BackendContractError> {
414 self.ensure_backend(backend)?;
415 self.request
416 .sandbox()
417 .network()
418 .authorize_connection(target, connected)
419 .map_err(|source| BackendContractError::NetworkEvaluation { source })
420 }
421
422 pub fn network_decision_for_unix_socket(
424 &self,
425 backend: &B,
426 socket: &Path,
427 ) -> Result<NetworkDecision, BackendContractError> {
428 self.ensure_backend(backend)?;
429 self.request
430 .sandbox()
431 .network()
432 .decision_for_unix_socket(socket)
433 .map_err(|source| BackendContractError::NetworkEvaluation { source })
434 }
435}