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 launch processes, perform filesystem or network I/O, resolve DNS, or
8//! select an operating-system sandbox.
9//!
10//! Start with [`BackendRequest`] and [`BackendCapabilities`]. A native backend
11//! implements [`SandboxBackend`], advertises the capabilities it can enforce,
12//! and calls [`BackendRequest::prepare_for`] before lowering the prepared
13//! request to its operating-system API. The backend owns process launch and
14//! lifecycle after this preflight boundary.
15
16#![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    /// Creates an empty capability set.
49    pub const fn new() -> Self {
50        Self {
51            capabilities: BTreeSet::new(),
52        }
53    }
54
55    /// Creates a capability set from an iterable collection.
56    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    /// Returns a copy with one capability added.
66    pub fn with(mut self, capability: BackendCapability) -> Self {
67        self.capabilities.insert(capability);
68        self
69    }
70
71    /// Returns whether this backend advertises a capability.
72    pub fn supports(&self, capability: BackendCapability) -> bool {
73        self.capabilities.contains(&capability)
74    }
75
76    /// Returns capabilities in deterministic order.
77    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    /// Creates a backend request from a command and composed sandbox.
90    pub const fn new(command: &'a CommandRequest, sandbox: &'a EffectiveSandbox) -> Self {
91        Self { command, sandbox }
92    }
93
94    /// Returns the command intent.
95    pub const fn command(&self) -> &'a CommandRequest {
96        self.command
97    }
98
99    /// Returns the effective sandbox constraint.
100    pub const fn sandbox(&self) -> &'a EffectiveSandbox {
101        self.sandbox
102    }
103
104    /// Performs common preflight using exactly the capabilities advertised by
105    /// `backend` and the backend's runtime path context.
106    ///
107    /// This is the safe handoff entry point for native integrations. The
108    /// capability check is defined by Cageforge and cannot be overridden by a
109    /// backend implementation. The context is narrowed before return, and an
110    /// effective working directory must be supplied by the runtime context and
111    /// permitted by the effective filesystem policy. The returned value is
112    /// bound to `B`, so a handoff prepared for one backend type cannot be passed
113    /// to a native lowering method for another backend type by accident.
114    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    /// Computes the capabilities required by this request.
123    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    /// Validates this request against advertised backend capabilities.
133    ///
134    /// No process, filesystem, DNS, or socket operation is performed. The
135    /// returned value only proves that the request's portable requirements are
136    /// represented by the advertised capability set; native enforcement still
137    /// belongs to the backend.
138    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(&current_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    /// Creates a new backend-instance identity.
204    #[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    /// Returns the validated executable and argv values.
263    ///
264    /// The working directory is intentionally exposed separately through
265    /// [`Self::working_directory`]. A backend must not recover or inherit the
266    /// original optional cwd from a raw [`CommandRequest`] after preflight.
267    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    /// Returns the validated effective sandbox.
273    pub fn sandbox(&self, backend: &B) -> Result<&'a EffectiveSandbox, BackendContractError> {
274        self.ensure_backend(backend)?;
275        Ok(self.request.sandbox())
276    }
277
278    /// Returns all filesystem constraint layers required for native lowering.
279    ///
280    /// The backend must enforce every layer in the returned view. This is
281    /// distinct from the combined decision helpers: a native sandbox builder
282    /// needs the concrete rules, protected paths, and glob settings, while
283    /// the view prevents it from selecting only the requested or ceiling
284    /// side.
285    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    /// Returns all network constraint layers required for native lowering.
294    ///
295    /// These rules configure enforcement only. Actual connections must still
296    /// use [`Self::authorize_connection`] with a resolved target and exact
297    /// socket address.
298    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    /// Returns the runtime path context that was narrowed and checked during
307    /// [`BackendRequest::prepare_for`].
308    pub fn path_context(&self, backend: &B) -> Result<&EffectivePathContext, BackendContractError> {
309        self.ensure_backend(backend)?;
310        Ok(&self.path_context)
311    }
312
313    /// Returns the effective working directory resolved during preflight.
314    ///
315    /// This is always present. When the command did not specify an explicit
316    /// directory, it is the runtime current directory supplied in the path
317    /// context and checked against the effective filesystem policy.
318    pub fn working_directory(&self, backend: &B) -> Result<&Path, BackendContractError> {
319        self.ensure_backend(backend)?;
320        Ok(&self.working_directory)
321    }
322
323    /// Returns the validated standard-stream routing.
324    pub fn stdio(&self, backend: &B) -> Result<StdioSpec, BackendContractError> {
325        self.ensure_backend(backend)?;
326        Ok(self.request.command().stdio())
327    }
328
329    /// Returns the validated timeout intent.
330    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    /// Applies the effective environment to a backend-selected input base.
336    ///
337    /// A backend must construct [`EnvironmentInput::core`] only after it has
338    /// selected the platform's conservative core environment.
339    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    /// Evaluates one absolute path against both effective filesystem policies.
353    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    /// Evaluates one symbolic filesystem selector against both effective
367    /// policies and the narrowed runtime context.
368    ///
369    /// The context must come from [`Self::path_context`]. A selector that has
370    /// no effective runtime paths is denied, so a backend cannot accidentally
371    /// replace a workspace-root ceiling with a broader context.
372    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    /// Evaluates a resolved hostname and all addresses captured for it.
386    ///
387    /// This is a policy query, not connection authorization. A backend must
388    /// call [`Self::authorize_connection`] immediately before connecting.
389    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    /// Authorizes the exact socket address the backend is about to connect to.
404    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    /// Evaluates one Unix socket path against both effective network policies.
419    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}