Skip to main content

cageforge_backend_api/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Backend capability negotiation and preflight for Cageforge execution.
4//!
5//! This crate is the typed boundary between [`cageforge_command`] and
6//! [`cageforge_policy_compose`] values and a native execution backend. It does
7//! not implement native filesystem or network I/O, resolve DNS, or select an
8//! operating-system sandbox. [`Sandbox`] and [`DynSandbox`] delegate execution
9//! to the selected native backend while [`SandboxChild`] exposes its lifecycle.
10//!
11//! Start with [`BackendRequest`] and [`BackendCapabilities`]. A native backend
12//! implements [`SandboxBackend`], advertises the capabilities it can enforce,
13//! and calls [`BackendRequest::prepare_for`] before lowering the prepared
14//! request to its operating-system API. The backend owns process launch and
15//! lifecycle after this preflight boundary.
16
17#![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    /// Creates an empty capability set.
53    pub const fn new() -> Self {
54        Self {
55            capabilities: BTreeSet::new(),
56        }
57    }
58
59    /// Creates a capability set from an iterable collection.
60    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    /// Returns a copy with one capability added.
70    pub fn with(mut self, capability: BackendCapability) -> Self {
71        self.capabilities.insert(capability);
72        self
73    }
74
75    /// Returns whether this backend advertises a capability.
76    pub fn supports(&self, capability: BackendCapability) -> bool {
77        self.capabilities.contains(&capability)
78    }
79
80    /// Returns capabilities in deterministic order.
81    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    /// Creates a backend request from a command and composed sandbox.
94    pub const fn new(command: &'a CommandRequest, sandbox: &'a EffectiveSandbox) -> Self {
95        Self { command, sandbox }
96    }
97
98    /// Returns the command intent.
99    pub const fn command(&self) -> &'a CommandRequest {
100        self.command
101    }
102
103    /// Returns the effective sandbox constraint.
104    pub const fn sandbox(&self) -> &'a EffectiveSandbox {
105        self.sandbox
106    }
107
108    /// Performs common preflight using exactly the capabilities advertised by
109    /// `backend` and the backend's runtime path context.
110    ///
111    /// This is the safe handoff entry point for native integrations. The
112    /// capability check is defined by Cageforge and cannot be overridden by a
113    /// backend implementation. The context is narrowed before return, and an
114    /// effective working directory must be supplied by the runtime context and
115    /// permitted by the effective filesystem policy. The returned value is
116    /// bound to `B`, so a handoff prepared for one backend type cannot be passed
117    /// to a native lowering method for another backend type by accident.
118    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    /// Computes the capabilities required by this request.
127    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    /// Validates this request against advertised backend capabilities.
137    ///
138    /// No process, filesystem, DNS, or socket operation is performed. The
139    /// returned value only proves that the request's portable requirements are
140    /// represented by the advertised capability set; native enforcement still
141    /// belongs to the backend.
142    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(&current_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    /// Creates a new backend-instance identity.
215    #[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    /// Returns the validated executable and argv values.
274    ///
275    /// The working directory is intentionally exposed separately through
276    /// [`Self::working_directory`]. A backend must not recover or inherit the
277    /// original optional cwd from a raw [`CommandRequest`] after preflight.
278    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    /// Returns the validated effective sandbox.
284    pub fn sandbox(&self, backend: &B) -> Result<&'a EffectiveSandbox, BackendContractError> {
285        self.ensure_backend(backend)?;
286        Ok(self.request.sandbox())
287    }
288
289    /// Returns all filesystem constraint layers required for native lowering.
290    ///
291    /// The backend must enforce every layer in the returned view. This is
292    /// distinct from the combined decision helpers: a native sandbox builder
293    /// needs the concrete rules, protected paths, and glob settings, while
294    /// the view prevents it from selecting only the requested or ceiling
295    /// side.
296    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    /// Returns all network constraint layers required for native lowering.
305    ///
306    /// These rules configure enforcement only. Actual connections must still
307    /// use [`Self::authorize_connection`] with a resolved target and exact
308    /// socket address.
309    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    /// Returns the runtime path context that was narrowed and checked during
318    /// [`BackendRequest::prepare_for`].
319    pub fn path_context(&self, backend: &B) -> Result<&EffectivePathContext, BackendContractError> {
320        self.ensure_backend(backend)?;
321        Ok(&self.path_context)
322    }
323
324    /// Returns the effective working directory resolved during preflight.
325    ///
326    /// This is always present. When the command did not specify an explicit
327    /// directory, it is the runtime current directory supplied in the path
328    /// context and checked against the effective filesystem policy.
329    pub fn working_directory(&self, backend: &B) -> Result<&Path, BackendContractError> {
330        self.ensure_backend(backend)?;
331        Ok(&self.working_directory)
332    }
333
334    /// Returns the validated standard-stream routing.
335    pub fn stdio(&self, backend: &B) -> Result<StdioSpec, BackendContractError> {
336        self.ensure_backend(backend)?;
337        Ok(self.request.command().stdio())
338    }
339
340    /// Returns the validated timeout intent.
341    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    /// Applies the effective environment to a backend-selected input base.
347    ///
348    /// A backend must construct [`EnvironmentInput::core`] only after it has
349    /// selected the platform's conservative core environment.
350    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    /// Evaluates one absolute path against both effective filesystem policies.
364    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    /// Evaluates one symbolic filesystem selector against both effective
378    /// policies and the narrowed runtime context.
379    ///
380    /// The context must come from [`Self::path_context`]. A selector that has
381    /// no effective runtime paths is denied, so a backend cannot accidentally
382    /// replace a workspace-root ceiling with a broader context.
383    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    /// Evaluates a resolved hostname and all addresses captured for it.
397    ///
398    /// This is a policy query, not connection authorization. A backend must
399    /// call [`Self::authorize_connection`] immediately before connecting.
400    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    /// Authorizes the exact socket address the backend is about to connect to.
415    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    /// Evaluates one Unix socket path against both effective network policies.
430    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}