Skip to main content

sim_lib_exec/
command.rs

1//! Exact, content-identified local checker command contracts.
2
3use std::{collections::BTreeSet, fmt};
4
5use sim_kernel::{CapabilityName, ContentId, Datum, Error, Result, Symbol};
6
7use crate::{
8    ArgAtom, ProcessBudget, ProgramRef, ProjectRootRef, SandboxControl, SandboxPolicy,
9    SandboxRequirement, SealedBindings,
10    command_wire::{
11        budget_datum, environment_datum, i64_datum, id_datum, invocation_datum, network_datum,
12        node, output_datum, replay_datum, resource_datum, route_datum,
13    },
14};
15
16macro_rules! opaque_ref {
17    ($name:ident, $doc:literal, $label:literal) => {
18        #[doc = $doc]
19        #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
20        pub struct $name(String);
21        impl $name {
22            /// Validates a stable, non-native reference.
23            pub fn new(value: impl Into<String>) -> Result<Self> {
24                let value = value.into();
25                if value.is_empty() || value.contains('\0') {
26                    return Err(Error::Eval(
27                        concat!($label, " must be non-empty and NUL-free").into(),
28                    ));
29                }
30                Ok(Self(value))
31            }
32            /// Returns the stable reference string.
33            pub fn as_str(&self) -> &str {
34                &self.0
35            }
36        }
37    };
38}
39
40opaque_ref!(
41    PacketRef,
42    "Stable implementation packet reference.",
43    "packet reference"
44);
45opaque_ref!(
46    BuildSourceRef,
47    "Stable sealed build-source reference.",
48    "build-source reference"
49);
50opaque_ref!(
51    CapabilityGrantRef,
52    "Stable least-authority grant reference.",
53    "capability-grant reference"
54);
55
56/// Semantic identity of an exact trusted command specification.
57#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
58pub struct CommandId(ContentId);
59
60impl CommandId {
61    /// Borrows the command's semantic content identity.
62    pub const fn content_id(&self) -> &ContentId {
63        &self.0
64    }
65}
66
67impl fmt::Display for CommandId {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(formatter, "{}:", self.0.algorithm.as_qualified_str())?;
70        for byte in self.0.bytes {
71            write!(formatter, "{byte:02x}")?;
72        }
73        Ok(())
74    }
75}
76
77/// Replay law bound into the local request without depending on the operation crate.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum CommandReplayPolicy {
80    /// A repeat may occur only after independent absence and lease expiry.
81    Idempotent,
82    /// A recorded dispatch is an unconditional at-most-once barrier.
83    ExactlyOnce,
84}
85
86/// Exact executable invocation; no variant performs shell parsing of argv.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum CommandInvocation {
89    /// Invoke the allowlisted program with whole literal arguments.
90    Argv(Vec<ArgAtom>),
91    /// Invoke an allowlisted interpreter with flags and unchanged trusted script bytes.
92    Interpreter {
93        /// Whole literal interpreter flags preceding the script.
94        flags: Vec<ArgAtom>,
95        /// Exact UTF-8, NUL-free script bytes included in [`CommandId`].
96        script: Vec<u8>,
97    },
98}
99
100impl CommandInvocation {
101    /// Renders whole argument atoms without interpolation or splitting.
102    pub fn argv(&self) -> Result<Vec<ArgAtom>> {
103        match self {
104            Self::Argv(argv) => Ok(argv.clone()),
105            Self::Interpreter { flags, script } => {
106                let script = std::str::from_utf8(script)
107                    .map_err(|_| Error::Eval("trusted command script is not UTF-8".into()))?;
108                if script.contains('\0') {
109                    return Err(Error::Eval("trusted command script contains NUL".into()));
110                }
111                let mut argv = flags.clone();
112                argv.push(ArgAtom::new(script)?);
113                Ok(argv)
114            }
115        }
116    }
117}
118
119/// Read or write authority for one boot-resolved command resource.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
121pub enum ResourceAccess {
122    /// Immutable command input.
123    ReadOnly,
124    /// Explicit command output, cache, or scratch root.
125    Writable,
126}
127
128/// One opaque, boot-resolved resource made available to an exact command.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct CommandResource {
131    /// Stable boot configuration identity.
132    pub source: String,
133    /// Absolute guest path when sandboxed.
134    pub guest_path: String,
135    /// Exact access authority.
136    pub access: ResourceAccess,
137}
138
139/// Expected state of one declared output path after execution.
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub enum OutputState {
142    /// The path must exist, regardless of content.
143    Exists,
144    /// The path must be absent.
145    Absent,
146    /// The file's `Datum::Bytes` semantic identity must equal this value.
147    FileContent(ContentId),
148}
149
150/// One independently observable path beneath a writable resource.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct OutputExpectation {
153    /// Writable resource identity containing the path.
154    pub resource: String,
155    /// Slash-separated relative path without parent traversal.
156    pub relative_path: String,
157    /// Exact expected state.
158    pub state: OutputState,
159}
160
161/// Semantic process result and filesystem postcondition.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct OutputContract {
164    exit_codes: BTreeSet<i32>,
165    outputs: Vec<OutputExpectation>,
166}
167
168impl OutputContract {
169    /// Validates a non-empty exit set and traversal-free unique output paths.
170    pub fn new(
171        exit_codes: impl IntoIterator<Item = i32>,
172        outputs: Vec<OutputExpectation>,
173    ) -> Result<Self> {
174        let exit_codes = exit_codes.into_iter().collect::<BTreeSet<_>>();
175        if exit_codes.is_empty() {
176            return Err(Error::Eval("output contract needs an exit code".into()));
177        }
178        let mut paths = BTreeSet::new();
179        for output in &outputs {
180            if output.resource.is_empty()
181                || output.relative_path.is_empty()
182                || output.relative_path.starts_with('/')
183                || output
184                    .relative_path
185                    .split('/')
186                    .any(|part| part.is_empty() || part == "." || part == "..")
187                || output.relative_path.contains('\0')
188                || !paths.insert((&output.resource, &output.relative_path))
189            {
190                return Err(Error::Eval(
191                    "invalid or duplicate output expectation".into(),
192                ));
193            }
194        }
195        Ok(Self {
196            exit_codes,
197            outputs,
198        })
199    }
200    /// Returns accepted native exit codes.
201    pub fn exit_codes(&self) -> &BTreeSet<i32> {
202        &self.exit_codes
203    }
204    /// Returns independently observable output expectations.
205    pub fn outputs(&self) -> &[OutputExpectation] {
206        &self.outputs
207    }
208    /// Returns the exact semantic output-contract value.
209    pub fn canonical_datum(&self) -> Datum {
210        node(
211            "output-contract-v1",
212            vec![
213                (
214                    "exit-codes",
215                    Datum::Set(
216                        self.exit_codes
217                            .iter()
218                            .map(|value| i64_datum(i64::from(*value)))
219                            .collect(),
220                    ),
221                ),
222                (
223                    "outputs",
224                    Datum::Vector(self.outputs.iter().map(output_datum).collect()),
225                ),
226            ],
227        )
228    }
229}
230
231/// Required cleanup proof for process descendants and writable scratch roots.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct CleanupContract {
234    scratch_resources: BTreeSet<String>,
235}
236
237impl CleanupContract {
238    /// Requires group kill/reap and names every disposable writable scratch root.
239    pub fn process_group(scratch_resources: impl IntoIterator<Item = String>) -> Result<Self> {
240        let scratch_resources = scratch_resources.into_iter().collect::<BTreeSet<_>>();
241        if scratch_resources
242            .iter()
243            .any(|value| value.is_empty() || value.contains('\0'))
244        {
245            return Err(Error::Eval("invalid cleanup resource".into()));
246        }
247        Ok(Self { scratch_resources })
248    }
249    /// Returns disposable writable resources that must be empty or removed at closure.
250    pub fn scratch_resources(&self) -> &BTreeSet<String> {
251        &self.scratch_resources
252    }
253    /// Returns the semantic cleanup contract.
254    pub fn canonical_datum(&self) -> Datum {
255        node(
256            "cleanup-contract-v1",
257            vec![
258                (
259                    "descendant-group",
260                    Datum::Symbol(Symbol::qualified("cleanup", "kill-reap-required")),
261                ),
262                (
263                    "scratch-resources",
264                    Datum::Set(
265                        self.scratch_resources
266                            .iter()
267                            .cloned()
268                            .map(Datum::String)
269                            .collect(),
270                    ),
271                ),
272            ],
273        )
274    }
275}
276
277/// Network authority for one command.
278#[derive(Clone, Debug, PartialEq, Eq)]
279pub enum NetworkAccess {
280    /// No network namespace or socket authority is available.
281    Absent,
282    /// A separately supplied capability authorizes this exact network use.
283    Scoped(CapabilityName),
284}
285
286/// Selected capsule execution boundary.
287#[derive(Clone, Debug, PartialEq, Eq)]
288pub enum CommandRoute {
289    /// Exact trusted command in an owned disposable host checkout.
290    Process,
291    /// Networkless anonymous-root execution through a registered launcher.
292    Sandbox {
293        /// Stable registered launcher identity.
294        launcher: String,
295        /// Complete sandbox authority and resource bounds.
296        policy: SandboxPolicy,
297    },
298}
299
300/// Immutable allowlist entry pinning every local checker execution input.
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct CommandSpec {
303    id: CommandId,
304    program: ProgramRef,
305    root: ProjectRootRef,
306    invocation: CommandInvocation,
307    environment: SealedBindings,
308    resources: Vec<CommandResource>,
309    budget: ProcessBudget,
310    outputs: OutputContract,
311    cleanup: CleanupContract,
312    network: NetworkAccess,
313    route: CommandRoute,
314    replay: CommandReplayPolicy,
315}
316
317impl CommandSpec {
318    /// Validates and identifies an exact trusted command allowlist entry.
319    #[allow(clippy::too_many_arguments)]
320    pub fn new(
321        program: ProgramRef,
322        root: ProjectRootRef,
323        invocation: CommandInvocation,
324        environment: SealedBindings,
325        resources: Vec<CommandResource>,
326        budget: ProcessBudget,
327        outputs: OutputContract,
328        cleanup: CleanupContract,
329        network: NetworkAccess,
330        route: CommandRoute,
331        replay: CommandReplayPolicy,
332    ) -> Result<Self> {
333        invocation.argv()?;
334        if budget.timeout_ms == 0 || budget.max_output_bytes == 0 {
335            return Err(Error::Eval("command budget must be non-zero".into()));
336        }
337        if matches!(invocation, CommandInvocation::Interpreter { .. }) && budget.stdin.is_some() {
338            return Err(Error::Eval(
339                "interpreter command reserves no second stdin script channel".into(),
340            ));
341        }
342        let mut sources = BTreeSet::new();
343        let mut guests = BTreeSet::new();
344        for resource in &resources {
345            if resource.source.is_empty()
346                || !resource.guest_path.starts_with('/')
347                || resource.guest_path.split('/').any(|part| part == "..")
348                || resource.guest_path.contains('\0')
349                || !sources.insert(resource.source.as_str())
350                || !guests.insert(resource.guest_path.as_str())
351            {
352                return Err(Error::Eval("invalid or duplicate command resource".into()));
353            }
354        }
355        let writable = resources
356            .iter()
357            .filter(|resource| resource.access == ResourceAccess::Writable)
358            .map(|resource| resource.source.as_str())
359            .collect::<BTreeSet<_>>();
360        if outputs
361            .outputs
362            .iter()
363            .any(|output| !writable.contains(output.resource.as_str()))
364            || cleanup
365                .scratch_resources
366                .iter()
367                .any(|resource| !writable.contains(resource.as_str()))
368        {
369            return Err(Error::Eval(
370                "outputs and cleanup must name declared writable resources".into(),
371            ));
372        }
373        if let CommandRoute::Sandbox { launcher, policy } = &route {
374            if launcher.is_empty() {
375                return Err(Error::Eval("sandbox launcher identity is empty".into()));
376            }
377            let mounts = policy
378                .mounts()
379                .iter()
380                .map(|mount| {
381                    (
382                        &mount.source,
383                        &mount.guest_path,
384                        match mount.access {
385                            crate::MountAccess::ReadOnly => ResourceAccess::ReadOnly,
386                            crate::MountAccess::Writable => ResourceAccess::Writable,
387                        },
388                    )
389                })
390                .collect::<BTreeSet<_>>();
391            let declared = resources
392                .iter()
393                .map(|resource| (&resource.source, &resource.guest_path, resource.access))
394                .collect::<BTreeSet<_>>();
395            if mounts != declared {
396                return Err(Error::Eval(
397                    "sandbox mounts differ from command resources".into(),
398                ));
399            }
400            if !resources
401                .iter()
402                .any(|resource| resource.source == root.as_str() && resource.guest_path == "/work")
403            {
404                return Err(Error::Eval(
405                    "sandbox working root is not the declared /work resource".into(),
406                ));
407            }
408            if policy.limits().wall_time_ms != budget.timeout_ms
409                || policy.limits().output_bytes != budget.max_output_bytes
410                || budget
411                    .stdin
412                    .as_ref()
413                    .is_some_and(|stdin| stdin.len() > policy.limits().stdin_bytes)
414            {
415                return Err(Error::Eval(
416                    "sandbox and command process budgets differ".into(),
417                ));
418            }
419            if !matches!(network, NetworkAccess::Absent)
420                || policy.requirements().get(&SandboxControl::Network)
421                    != Some(&SandboxRequirement::Required)
422            {
423                return Err(Error::Eval(
424                    "current sandbox route requires proven absent networking".into(),
425                ));
426            }
427        } else if matches!(network, NetworkAccess::Absent) {
428            return Err(Error::Eval(
429                "host process route cannot prove absent networking".into(),
430            ));
431        }
432        let mut value = Self {
433            id: CommandId(ContentId::from_bytes(
434                Symbol::qualified("core", "sha256-datum-v1"),
435                [0; 32],
436            )),
437            program,
438            root,
439            invocation,
440            environment,
441            resources,
442            budget,
443            outputs,
444            cleanup,
445            network,
446            route,
447            replay,
448        };
449        value.id = CommandId(
450            value
451                .canonical_without_id()
452                .content_id()
453                .map_err(|_| Error::Eval("command specification is not canonical".into()))?,
454        );
455        Ok(value)
456    }
457    /// Returns the stable command identity.
458    pub const fn id(&self) -> &CommandId {
459        &self.id
460    }
461    /// Returns the boot-trusted executable or interpreter identity.
462    pub const fn program(&self) -> &ProgramRef {
463        &self.program
464    }
465    /// Returns the boot-trusted working-root identity.
466    pub const fn root(&self) -> &ProjectRootRef {
467        &self.root
468    }
469    /// Returns the exact invocation.
470    pub const fn invocation(&self) -> &CommandInvocation {
471        &self.invocation
472    }
473    /// Returns the sealed, empty-by-default environment.
474    pub const fn environment(&self) -> &SealedBindings {
475        &self.environment
476    }
477    /// Returns all explicit input, output, cache, and scratch resources.
478    pub fn resources(&self) -> &[CommandResource] {
479        &self.resources
480    }
481    /// Returns the mandatory time, input, and output budget.
482    pub const fn budget(&self) -> &ProcessBudget {
483        &self.budget
484    }
485    /// Returns the semantic postcondition contract.
486    pub const fn outputs(&self) -> &OutputContract {
487        &self.outputs
488    }
489    /// Returns the descendant and resource cleanup contract.
490    pub const fn cleanup(&self) -> &CleanupContract {
491        &self.cleanup
492    }
493    /// Returns the separately scoped network policy.
494    pub const fn network(&self) -> &NetworkAccess {
495        &self.network
496    }
497    /// Returns the selected process or sandbox boundary.
498    pub const fn route(&self) -> &CommandRoute {
499        &self.route
500    }
501    /// Returns the replay policy bound into operation intent.
502    pub const fn replay(&self) -> CommandReplayPolicy {
503        self.replay
504    }
505    /// Returns the canonical semantic command specification.
506    pub fn canonical_datum(&self) -> Datum {
507        self.canonical_without_id()
508    }
509    fn canonical_without_id(&self) -> Datum {
510        node(
511            "command-spec-v1",
512            vec![
513                ("program", Datum::String(self.program.as_str().into())),
514                ("root", Datum::String(self.root.as_str().into())),
515                ("invocation", invocation_datum(&self.invocation)),
516                ("environment", environment_datum(&self.environment)),
517                (
518                    "resources",
519                    Datum::Vector(self.resources.iter().map(resource_datum).collect()),
520                ),
521                ("budget", budget_datum(&self.budget)),
522                ("outputs", self.outputs.canonical_datum()),
523                ("cleanup", self.cleanup.canonical_datum()),
524                ("network", network_datum(&self.network)),
525                ("route", route_datum(&self.route)),
526                ("replay", replay_datum(self.replay)),
527            ],
528        )
529    }
530}
531
532/// Capability-scoped request naming only an installed allowlist entry.
533#[derive(Clone, Debug, PartialEq, Eq)]
534pub struct LocalCheckRequest {
535    packet: PacketRef,
536    command: CommandId,
537    source: BuildSourceRef,
538    grant: CapabilityGrantRef,
539    network_grant: Option<(CapabilityName, CapabilityGrantRef)>,
540}
541
542/// Explicit bounded lease request supplied to a local checker port.
543#[derive(Clone, Debug, PartialEq, Eq)]
544pub struct LocalCheckLease {
545    /// Stable holder identity.
546    pub holder: Datum,
547    /// Inclusive caller-supplied monotonic acquisition tick.
548    pub acquired_at: u64,
549    /// Exclusive caller-supplied monotonic expiry tick.
550    pub expires_at: u64,
551}
552
553/// Portable projection of the durable operation outcome.
554#[derive(Clone, Copy, Debug, PartialEq, Eq)]
555pub enum LocalCheckStatus {
556    /// The exact postcondition existed before dispatch.
557    AlreadyTrue,
558    /// An independent observer verified the postcondition after dispatch.
559    Verified,
560    /// An independent observer found a different postcondition.
561    Diverged,
562    /// Available facts cannot establish completion or safe replay.
563    Uncertain,
564    /// Admission or lifecycle validation refused the request.
565    Refused,
566}
567
568/// Stable checker-facing response without native process or path values.
569#[derive(Clone, Debug, PartialEq, Eq)]
570pub struct LocalCheckResult {
571    /// Semantic operation identity, when canonical admission succeeded.
572    pub operation: Option<String>,
573    /// Portable lifecycle outcome.
574    pub status: LocalCheckStatus,
575    /// Canonical outcome evidence or typed refusal detail.
576    pub evidence: Datum,
577}
578
579/// Portable checker seam; packet tooling never constructs a native command.
580pub trait LocalCheckPort: Send {
581    /// Executes or reconciles one installed exact command under a bounded lease.
582    fn check(
583        &mut self,
584        request: &LocalCheckRequest,
585        lease: &LocalCheckLease,
586        cancellation: &crate::ProcessCancellation,
587    ) -> LocalCheckResult;
588}
589
590impl LocalCheckRequest {
591    /// Creates a request that cannot alter the installed command bytes or policy.
592    pub fn new(
593        packet: PacketRef,
594        command: CommandId,
595        source: BuildSourceRef,
596        grant: CapabilityGrantRef,
597    ) -> Self {
598        Self {
599            packet,
600            command,
601            source,
602            grant,
603            network_grant: None,
604        }
605    }
606    /// Adds authority for the exact separately scoped network capability.
607    #[must_use]
608    pub fn with_network_grant(
609        mut self,
610        capability: CapabilityName,
611        grant: CapabilityGrantRef,
612    ) -> Self {
613        self.network_grant = Some((capability, grant));
614        self
615    }
616    /// Returns the implementation packet identity.
617    pub const fn packet(&self) -> &PacketRef {
618        &self.packet
619    }
620    /// Returns the installed exact command identity.
621    pub const fn command(&self) -> &CommandId {
622        &self.command
623    }
624    /// Returns the sealed build-source identity.
625    pub const fn source(&self) -> &BuildSourceRef {
626        &self.source
627    }
628    /// Returns the least-authority grant identity.
629    pub const fn grant(&self) -> &CapabilityGrantRef {
630        &self.grant
631    }
632    /// Returns the separately scoped network capability and grant, when supplied.
633    pub const fn network_grant(&self) -> Option<&(CapabilityName, CapabilityGrantRef)> {
634        self.network_grant.as_ref()
635    }
636    /// Returns the request's canonical semantic value.
637    pub fn canonical_datum(&self) -> Datum {
638        node(
639            "local-check-request-v1",
640            vec![
641                ("packet", Datum::String(self.packet.as_str().into())),
642                ("command", id_datum(self.command.content_id())),
643                ("source", Datum::String(self.source.as_str().into())),
644                ("grant", Datum::String(self.grant.as_str().into())),
645                (
646                    "network-grant",
647                    self.network_grant
648                        .as_ref()
649                        .map_or(Datum::Nil, |(capability, grant)| {
650                            node(
651                                "network-grant-v1",
652                                vec![
653                                    ("capability", Datum::String(capability.as_str().into())),
654                                    ("grant", Datum::String(grant.as_str().into())),
655                                ],
656                            )
657                        }),
658                ),
659            ],
660        )
661    }
662}