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