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 if !path_context.executable_roots().is_empty()
165 && !capabilities.supports(BackendCapability::FilesystemExecutableMapping)
166 {
167 return Err(BackendContractError::UnsupportedCapability {
168 capability: BackendCapability::FilesystemExecutableMapping,
169 });
170 }
171 let working_directory = match self.command.working_directory() {
172 Some(path) if path.is_absolute() => normalize_lexical_path(path).into_owned(),
173 Some(path) => {
174 let current_directory = base_context.current_directory().ok_or_else(|| {
175 BackendContractError::WorkingDirectoryResolution {
176 path: path.to_path_buf(),
177 }
178 })?;
179 normalize_lexical_path(¤t_directory.join(path)).into_owned()
180 }
181 None => base_context
182 .current_directory()
183 .map(normalize_lexical_path)
184 .map(std::borrow::Cow::into_owned)
185 .ok_or(BackendContractError::MissingRuntimeCurrentDirectory)?,
186 };
187 match self
188 .sandbox
189 .filesystem()
190 .access_for_path(&working_directory, &path_context)
191 .map_err(|source| BackendContractError::FilesystemEvaluation { source })?
192 {
193 FilesystemDecision::Read
194 | FilesystemDecision::Write
195 | FilesystemDecision::ExternallyEnforced => {}
196 FilesystemDecision::Deny => {
197 return Err(BackendContractError::WorkingDirectoryDenied {
198 path: working_directory.clone(),
199 });
200 }
201 }
202 Ok(PreparedBackendRequest {
203 request: self,
204 path_context,
205 working_directory,
206 capabilities: capabilities.clone(),
207 backend_identity: backend_identity.clone(),
208 backend: PhantomData,
209 })
210 }
211}
212
213impl BackendIdentity {
214 #[allow(clippy::new_without_default)]
216 pub fn new() -> Self {
217 Self(Arc::new(()))
218 }
219}
220
221impl fmt::Debug for BackendIdentity {
222 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
223 formatter
224 .debug_struct("BackendIdentity")
225 .finish_non_exhaustive()
226 }
227}
228
229impl PartialEq for BackendIdentity {
230 fn eq(&self, other: &Self) -> bool {
231 Arc::ptr_eq(&self.0, &other.0)
232 }
233}
234
235impl Eq for BackendIdentity {}
236
237impl<'a, B: SandboxBackend> Clone for PreparedBackendRequest<'a, B> {
238 fn clone(&self) -> Self {
239 Self {
240 request: self.request,
241 path_context: self.path_context.clone(),
242 working_directory: self.working_directory.clone(),
243 capabilities: self.capabilities.clone(),
244 backend_identity: self.backend_identity.clone(),
245 backend: PhantomData,
246 }
247 }
248}
249
250impl<'a, B: SandboxBackend> fmt::Debug for PreparedBackendRequest<'a, B> {
251 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252 formatter
253 .debug_struct("PreparedBackendRequest")
254 .field("request", &self.request)
255 .field("path_context", &self.path_context)
256 .field("working_directory", &self.working_directory)
257 .field("capabilities", &self.capabilities)
258 .finish()
259 }
260}
261
262impl<'a, B: SandboxBackend> PreparedBackendRequest<'a, B> {
263 fn ensure_backend(&self, backend: &B) -> Result<(), BackendContractError> {
264 if self.backend_identity != *backend.identity() {
265 Err(BackendContractError::BackendIdentityMismatch)
266 } else if self.capabilities != backend.capabilities() {
267 Err(BackendContractError::BackendCapabilitiesMismatch)
268 } else {
269 Ok(())
270 }
271 }
272
273 pub fn command_spec(&self, backend: &B) -> Result<&'a CommandSpec, BackendContractError> {
279 self.ensure_backend(backend)?;
280 Ok(self.request.command().command())
281 }
282
283 pub fn sandbox(&self, backend: &B) -> Result<&'a EffectiveSandbox, BackendContractError> {
285 self.ensure_backend(backend)?;
286 Ok(self.request.sandbox())
287 }
288
289 pub fn filesystem_lowering(
297 &self,
298 backend: &B,
299 ) -> Result<EffectiveFilesystemLowering<'_>, BackendContractError> {
300 self.ensure_backend(backend)?;
301 Ok(self.request.sandbox().filesystem().lowering())
302 }
303
304 pub fn network_lowering(
310 &self,
311 backend: &B,
312 ) -> Result<EffectiveNetworkLowering<'_>, BackendContractError> {
313 self.ensure_backend(backend)?;
314 Ok(self.request.sandbox().network().lowering())
315 }
316
317 pub fn path_context(&self, backend: &B) -> Result<&EffectivePathContext, BackendContractError> {
320 self.ensure_backend(backend)?;
321 Ok(&self.path_context)
322 }
323
324 pub fn working_directory(&self, backend: &B) -> Result<&Path, BackendContractError> {
330 self.ensure_backend(backend)?;
331 Ok(&self.working_directory)
332 }
333
334 pub fn stdio(&self, backend: &B) -> Result<StdioSpec, BackendContractError> {
336 self.ensure_backend(backend)?;
337 Ok(self.request.command().stdio())
338 }
339
340 pub fn timeout_policy(&self, backend: &B) -> Result<TimeoutPolicy, BackendContractError> {
342 self.ensure_backend(backend)?;
343 Ok(self.request.command().timeout_policy())
344 }
345
346 pub fn apply_environment(
351 &self,
352 backend: &B,
353 input: EnvironmentInput,
354 ) -> Result<BTreeMap<OsString, OsString>, BackendContractError> {
355 self.ensure_backend(backend)?;
356 self.request
357 .sandbox()
358 .environment()
359 .apply_to(input)
360 .map_err(|source| BackendContractError::EnvironmentPreparation { source })
361 }
362
363 pub fn filesystem_access_for_path(
365 &self,
366 backend: &B,
367 path: &Path,
368 ) -> Result<FilesystemDecision, BackendContractError> {
369 self.ensure_backend(backend)?;
370 self.request
371 .sandbox()
372 .filesystem()
373 .access_for_path(path, &self.path_context)
374 .map_err(|source| BackendContractError::FilesystemEvaluation { source })
375 }
376
377 pub fn filesystem_access_for(
384 &self,
385 backend: &B,
386 selector: &PathSelector,
387 ) -> Result<FilesystemDecision, BackendContractError> {
388 self.ensure_backend(backend)?;
389 self.request
390 .sandbox()
391 .filesystem()
392 .access_for(selector, &self.path_context)
393 .map_err(|source| BackendContractError::FilesystemEvaluation { source })
394 }
395
396 pub fn network_decision_for_domain_with_resolved_ips(
401 &self,
402 backend: &B,
403 domain: &str,
404 resolved_ips: &[IpAddr],
405 ) -> Result<NetworkDecision, BackendContractError> {
406 self.ensure_backend(backend)?;
407 self.request
408 .sandbox()
409 .network()
410 .decision_for_domain_with_resolved_ips(domain, resolved_ips)
411 .map_err(|source| BackendContractError::NetworkEvaluation { source })
412 }
413
414 pub fn authorize_connection(
416 &self,
417 backend: &B,
418 target: &ResolvedNetworkTarget,
419 connected: SocketAddr,
420 ) -> Result<ConnectionAuthorization, BackendContractError> {
421 self.ensure_backend(backend)?;
422 self.request
423 .sandbox()
424 .network()
425 .authorize_connection(target, connected)
426 .map_err(|source| BackendContractError::NetworkEvaluation { source })
427 }
428
429 pub fn network_decision_for_unix_socket(
431 &self,
432 backend: &B,
433 socket: &Path,
434 ) -> Result<NetworkDecision, BackendContractError> {
435 self.ensure_backend(backend)?;
436 self.request
437 .sandbox()
438 .network()
439 .decision_for_unix_socket(socket)
440 .map_err(|source| BackendContractError::NetworkEvaluation { source })
441 }
442}