Skip to main content

sim_lib_exec/
exec.rs

1use sim_kernel::{CapabilityName, Cx, Error, Expr, NumberLiteral, Result, Symbol};
2use std::{
3    collections::BTreeMap,
4    sync::{
5        Arc,
6        atomic::{AtomicBool, Ordering},
7    },
8};
9
10const MAX_BINDINGS: usize = 128;
11const MAX_BINDING_BYTES: usize = 64 * 1024;
12/// Capability required before a process request reaches its port.
13pub fn exec_capability() -> CapabilityName {
14    CapabilityName::new("exec")
15}
16/// Read-constructor symbol for process results.
17pub fn proc_result_symbol() -> Symbol {
18    Symbol::new("ProcResult")
19}
20
21macro_rules! opaque_ref {
22    ($name:ident, $label:literal) => {
23        #[doc = concat!("Opaque, boot-trusted ", $label, ".")]
24        #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
25        pub struct $name(String);
26        impl $name {
27            /// Validates and creates an opaque reference.
28            pub fn new(value: impl Into<String>) -> Result<Self> {
29                let value = value.into();
30                if value.is_empty() || value.contains('\0') {
31                    return Err(Error::Eval(
32                        concat!($label, " must be non-empty and NUL-free").into(),
33                    ));
34                }
35                Ok(Self(value))
36            }
37            #[must_use]
38            /// Returns the non-native reference identifier.
39            pub fn as_str(&self) -> &str {
40                &self.0
41            }
42        }
43    };
44}
45opaque_ref!(ProgramRef, "program reference");
46opaque_ref!(ProjectRootRef, "project-root reference");
47opaque_ref!(PrivateArtifactRef, "private-artifact reference");
48
49/// One whole, NUL-free native argument; it is never shell-split.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ArgAtom(String);
52impl ArgAtom {
53    /// Validates and creates one whole argument.
54    pub fn new(value: impl Into<String>) -> Result<Self> {
55        let value = value.into();
56        if value.contains('\0') {
57            return Err(Error::Eval("argument contains NUL".into()));
58        }
59        Ok(Self(value))
60    }
61    #[must_use]
62    /// Returns the literal argument.
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
69/// A sealed literal or capsule-rendered resource reference.
70pub enum BindingValue {
71    /// Exact literal value.
72    Literal(String),
73    /// Opaque project root rendered by the capsule.
74    ProjectRoot(ProjectRootRef),
75    /// Opaque private artifact rendered by the capsule.
76    PrivateArtifact(PrivateArtifactRef),
77}
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
79/// Exact child bindings. No ambient inheritance is representable.
80pub struct SealedBindings(BTreeMap<String, BindingValue>);
81impl SealedBindings {
82    /// Creates the secure empty default.
83    #[must_use]
84    pub fn empty() -> Self {
85        Self::default()
86    }
87    /// Validates names, values, duplicates, count, and total bytes.
88    pub fn try_from_entries(
89        entries: impl IntoIterator<Item = (String, BindingValue)>,
90    ) -> Result<Self> {
91        let mut values = BTreeMap::new();
92        let mut bytes = 0usize;
93        for (name, value) in entries {
94            if name.is_empty() || name.contains(['=', '\0']) {
95                return Err(Error::Eval("sealed binding has an invalid name".into()));
96            }
97            let value_bytes = match &value {
98                BindingValue::Literal(v) => {
99                    if v.contains('\0') {
100                        return Err(Error::Eval("sealed binding literal contains NUL".into()));
101                    }
102                    v.len()
103                }
104                BindingValue::ProjectRoot(v) => v.as_str().len(),
105                BindingValue::PrivateArtifact(v) => v.as_str().len(),
106            };
107            bytes = bytes.saturating_add(name.len()).saturating_add(value_bytes);
108            if values.insert(name, value).is_some() {
109                return Err(Error::Eval("duplicate sealed binding".into()));
110            }
111            if values.len() > MAX_BINDINGS || bytes > MAX_BINDING_BYTES {
112                return Err(Error::Eval("sealed bindings exceed bounded size".into()));
113            }
114        }
115        Ok(Self(values))
116    }
117    /// Creates explicit literal bindings, for boot-supplied compatibility data.
118    pub fn literals(entries: impl IntoIterator<Item = (String, String)>) -> Result<Self> {
119        Self::try_from_entries(
120            entries
121                .into_iter()
122                .map(|(k, v)| (k, BindingValue::Literal(v))),
123        )
124    }
125    /// Iterates exact bindings for capsule rendering.
126    pub fn iter(&self) -> impl Iterator<Item = (&str, &BindingValue)> {
127        self.0.iter().map(|(k, v)| (k.as_str(), v))
128    }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
132/// Bounded input, time, and output policy.
133pub struct ProcessBudget {
134    /// Required timeout.
135    pub timeout_ms: u64,
136    /// Shared stdout/stderr byte cap.
137    pub max_output_bytes: usize,
138    /// Optional standard input.
139    pub stdin: Option<Vec<u8>>,
140}
141#[derive(Clone, Debug, PartialEq, Eq)]
142/// Portable options used to create a sealed process request.
143pub struct ExecOptions {
144    /// Boot-trusted program identity.
145    pub program: ProgramRef,
146    /// Opaque working project identity.
147    pub root: ProjectRootRef,
148    /// Resource budget.
149    pub budget: ProcessBudget,
150    /// Exact, empty-by-default child environment.
151    pub environment: SealedBindings,
152    /// Declared private artifacts available to bindings.
153    pub private_artifacts: Vec<PrivateArtifactRef>,
154}
155impl ExecOptions {
156    /// Creates options with an empty sealed environment.
157    pub fn new(
158        program: ProgramRef,
159        root: ProjectRootRef,
160        timeout_ms: u64,
161        max_output_bytes: usize,
162    ) -> Self {
163        Self {
164            program,
165            root,
166            budget: ProcessBudget {
167                timeout_ms,
168                max_output_bytes,
169                stdin: None,
170            },
171            environment: SealedBindings::empty(),
172            private_artifacts: Vec::new(),
173        }
174    }
175    #[must_use]
176    /// Supplies bounded standard input.
177    pub fn with_stdin(mut self, stdin: impl Into<Vec<u8>>) -> Self {
178        self.budget.stdin = Some(stdin.into());
179        self
180    }
181    #[must_use]
182    /// Supplies explicitly validated bindings.
183    pub fn with_bindings(mut self, bindings: SealedBindings) -> Self {
184        self.environment = bindings;
185        self
186    }
187    #[must_use]
188    /// Declares private artifacts that the capsule may render.
189    pub fn with_private_artifacts(mut self, artifacts: Vec<PrivateArtifactRef>) -> Self {
190        self.private_artifacts = artifacts;
191        self
192    }
193}
194#[derive(Clone, Debug, PartialEq, Eq)]
195/// Fully validated portable request passed to a platform capsule.
196pub struct ProcessRequest {
197    /// Boot-trusted program identity.
198    pub program: ProgramRef,
199    /// Whole literal arguments.
200    pub argv: Vec<ArgAtom>,
201    /// Opaque project root.
202    pub root: ProjectRootRef,
203    /// Exact sealed child environment.
204    pub environment: SealedBindings,
205    /// Declared private resources.
206    pub private_artifacts: Vec<PrivateArtifactRef>,
207    /// Bounded execution budget.
208    pub budget: ProcessBudget,
209}
210
211#[derive(Clone, Debug, Default)]
212/// Cooperative cancellation token shared with the platform adapter.
213pub struct ProcessCancellation(Arc<AtomicBool>);
214impl ProcessCancellation {
215    /// Requests cancellation.
216    pub fn cancel(&self) {
217        self.0.store(true, Ordering::Release)
218    }
219    #[must_use]
220    /// Reports whether cancellation was requested.
221    pub fn is_cancelled(&self) -> bool {
222        self.0.load(Ordering::Acquire)
223    }
224}
225#[derive(Clone, Debug, PartialEq, Eq)]
226/// Stable bounded process result; non-zero exit remains a result.
227pub struct ProcResult {
228    /// Captured standard output.
229    pub stdout: String,
230    /// Captured standard error.
231    pub stderr: String,
232    /// Native exit code, or -1 when unavailable.
233    pub exit_code: i32,
234    /// Whether output exceeded its shared cap.
235    pub truncated: bool,
236}
237impl ProcResult {
238    /// Converts the result to its stable read-constructor expression.
239    #[must_use]
240    pub fn to_constructor_expr(&self) -> Expr {
241        Expr::Call {
242            operator: Box::new(Expr::Symbol(proc_result_symbol())),
243            args: vec![
244                Expr::String(self.stdout.clone()),
245                Expr::String(self.stderr.clone()),
246                Expr::Number(NumberLiteral {
247                    domain: Symbol::qualified("numbers", "i64"),
248                    canonical: self.exit_code.to_string(),
249                }),
250                Expr::Bool(self.truncated),
251            ],
252        }
253    }
254}
255#[derive(Clone, Debug, PartialEq, Eq)]
256/// Privacy-safe completed-process receipt.
257pub struct ProcessReceipt {
258    /// Stable capsule identity.
259    pub provider: String,
260    /// Elapsed monotonic time.
261    pub elapsed_mono_ns: u64,
262    /// Completed result.
263    pub result: ProcResult,
264}
265#[derive(Clone, Debug, PartialEq, Eq)]
266/// Proof that a dispatched process group was killed and reaped.
267pub struct StopReceipt {
268    /// Stable capsule identity.
269    pub provider: String,
270    /// Elapsed monotonic time.
271    pub elapsed_mono_ns: u64,
272    /// Bounded cleanup evidence.
273    pub cleanup: String,
274}
275#[derive(Clone, Debug, PartialEq, Eq)]
276/// Bounded evidence for an ambiguous post-spawn outcome.
277pub struct DispatchEvidence {
278    /// Stable capsule identity.
279    pub provider: String,
280    /// Failed post-spawn stage.
281    pub stage: String,
282    /// Sanitized bounded detail.
283    pub detail: String,
284}
285#[derive(Clone, Debug, PartialEq, Eq)]
286/// Reason a process definitely did not cross the spawn boundary.
287pub enum ProcessRefusal {
288    /// Portable request validation failed.
289    Invalid(String),
290    /// Capsule policy or resource resolution refused the request.
291    Refused(String),
292    /// Native spawn failed before dispatch.
293    SpawnFailed(String),
294}
295#[derive(Clone, Debug, PartialEq, Eq)]
296/// Exact dispatch truth for one process attempt.
297pub enum ProcessAttempt {
298    /// Spawn definitely did not succeed.
299    NotDispatched {
300        /// Pre-spawn refusal evidence.
301        refusal: ProcessRefusal,
302    },
303    /// The child completed, including non-zero exit.
304    Completed {
305        /// Completion receipt.
306        receipt: ProcessReceipt,
307    },
308    /// Timeout won and cleanup was proven.
309    StoppedAfterTimeout {
310        /// Proven cleanup receipt.
311        receipt: StopReceipt,
312    },
313    /// Cancellation won and cleanup was proven.
314    StoppedAfterCancel {
315        /// Proven cleanup receipt.
316        receipt: StopReceipt,
317    },
318    /// Spawn succeeded but final state is ambiguous.
319    UnknownAfterDispatch {
320        /// Bounded ambiguity evidence.
321        evidence: DispatchEvidence,
322    },
323}
324impl ProcessAttempt {
325    /// Returns true only when automatic retry cannot duplicate dispatched work.
326    #[must_use]
327    pub fn automatically_retryable(&self) -> bool {
328        matches!(self, Self::NotDispatched { .. })
329    }
330}
331/// Runtime-owned seam implemented only by model and physical capsules.
332pub trait ProcessPort: Send + Sync {
333    /// Resolves opaque resources and executes one sealed request.
334    fn run(&self, request: &ProcessRequest, cancellation: &ProcessCancellation) -> ProcessAttempt;
335}
336
337/// Checks capability and portable policy before invoking the port.
338pub fn exec(
339    cx: &mut Cx,
340    port: &dyn ProcessPort,
341    argv: &[String],
342    options: &ExecOptions,
343    cancellation: &ProcessCancellation,
344) -> Result<ProcResult> {
345    cx.require(&exec_capability())?;
346    let request = checked_request(argv, options)?;
347    match port.run(&request, cancellation) {
348        ProcessAttempt::Completed { receipt } => Ok(receipt.result),
349        attempt => Err(Error::HostError(format!("exec attempt: {attempt:?}"))),
350    }
351}
352fn checked_request(argv: &[String], options: &ExecOptions) -> Result<ProcessRequest> {
353    if options.budget.timeout_ms == 0 {
354        return Err(Error::Eval("exec requires a non-zero timeout_ms".into()));
355    }
356    if options.budget.max_output_bytes == 0 {
357        return Err(Error::Eval("exec requires a non-zero output budget".into()));
358    }
359    let argv = argv
360        .iter()
361        .cloned()
362        .map(ArgAtom::new)
363        .collect::<Result<Vec<_>>>()?;
364    Ok(ProcessRequest {
365        program: options.program.clone(),
366        argv,
367        root: options.root.clone(),
368        environment: options.environment.clone(),
369        private_artifacts: options.private_artifacts.clone(),
370        budget: options.budget.clone(),
371    })
372}