Skip to main content

sim_platform_ubuntu_pc/
local_check.rs

1//! Durable exact-command adapter for local implementation checks.
2
3use std::{
4    collections::BTreeMap,
5    fs,
6    path::{Path, PathBuf},
7    sync::{Arc, Mutex},
8};
9
10use sim_kernel::{CapabilityName, Datum, Symbol};
11use sim_lib_exec::{
12    CommandId, CommandReplayPolicy, CommandRoute, CommandSpec, LauncherRegistry, LocalCheckLease,
13    LocalCheckPort, LocalCheckRequest, LocalCheckResult, LocalCheckStatus, MountAccess,
14    OutputState, ProcessAttempt, ProcessCancellation, ProcessPort, ProcessRefusal, ProcessRequest,
15    ResourceAccess, SandboxAttempt, SandboxRequest,
16};
17use sim_lib_journal::JournalBackend;
18use sim_lib_operation_gate::{
19    FencedDispatch, FencedDispatchId, LeaseWindow, LifecyclePerformer, LifecyclePerformerResponse,
20    OperationError, OperationGrant, OperationIntent, OperationLifecycle, OperationOutcome,
21    PostconditionObserver, PostconditionRequest, PostconditionResponse, ReplayPolicy,
22};
23use thiserror::Error;
24
25/// Typed refusal from command admission, execution composition, or durable operation handling.
26#[derive(Debug, Error)]
27pub enum LocalCheckError {
28    /// The request names no exact installed command.
29    #[error("local check command is not installed")]
30    CommandNotInstalled,
31    /// A command resource has no boot-authorized native root.
32    #[error("local check resource is unavailable: {0}")]
33    ResourceUnavailable(String),
34    /// A command contract is inconsistent with its selected execution route.
35    #[error("invalid local check contract: {0}")]
36    InvalidContract(String),
37    /// The durable operation lifecycle refused or could not persist the request.
38    #[error(transparent)]
39    Operation(#[from] OperationError),
40}
41
42#[derive(Clone, Debug)]
43struct ExecutionRecord {
44    exit_code: Option<i32>,
45    raw: Datum,
46    cleanup_proven: bool,
47    bounded: bool,
48}
49
50/// Derives stable M5 intent from one request and its exact installed command.
51///
52/// # Errors
53/// Returns [`LocalCheckError::CommandNotInstalled`] for an identity mismatch or
54/// propagates canonical operation-intent construction failure.
55pub fn request_check(
56    request: &LocalCheckRequest,
57    command: &CommandSpec,
58) -> Result<OperationIntent, LocalCheckError> {
59    if request.command() != command.id() {
60        return Err(LocalCheckError::CommandNotInstalled);
61    }
62    OperationIntent::new(
63        "local-check/run",
64        request.canonical_datum(),
65        command.outputs().canonical_datum(),
66        match command.replay() {
67            CommandReplayPolicy::Idempotent => ReplayPolicy::Idempotent,
68            CommandReplayPolicy::ExactlyOnce => ReplayPolicy::ExactlyOnce,
69        },
70    )
71    .map_err(Into::into)
72}
73
74/// Registered Ubuntu composition of M5, `ProcessPort`, and `SandboxLauncher`.
75pub struct LocalCheckAdapter<B: JournalBackend> {
76    lifecycle: OperationLifecycle<B>,
77    process: Arc<dyn ProcessPort>,
78    launchers: Arc<LauncherRegistry>,
79    commands: BTreeMap<CommandId, CommandSpec>,
80    resources: Arc<BTreeMap<String, PathBuf>>,
81    executions: Arc<Mutex<BTreeMap<FencedDispatchId, ExecutionRecord>>>,
82}
83
84impl<B: JournalBackend> LocalCheckAdapter<B> {
85    /// Builds a closed adapter from boot-authorized ports, specs, and native resources.
86    ///
87    /// # Errors
88    /// Returns a typed refusal for an empty or duplicate allowlist, or when a
89    /// command names a missing or inconsistent resource.
90    pub fn new(
91        backend: B,
92        process: Arc<dyn ProcessPort>,
93        launchers: LauncherRegistry,
94        commands: impl IntoIterator<Item = CommandSpec>,
95        resources: BTreeMap<String, PathBuf>,
96    ) -> Result<Self, LocalCheckError> {
97        let mut installed = BTreeMap::new();
98        for command in commands {
99            validate_command_resources(&command, &resources)?;
100            if installed.insert(command.id().clone(), command).is_some() {
101                return Err(LocalCheckError::InvalidContract(
102                    "duplicate command identity".into(),
103                ));
104            }
105        }
106        if installed.is_empty() {
107            return Err(LocalCheckError::InvalidContract(
108                "empty command allowlist".into(),
109            ));
110        }
111        Ok(Self {
112            lifecycle: OperationLifecycle::new(backend),
113            process,
114            launchers: Arc::new(launchers),
115            commands: installed,
116            resources: Arc::new(resources),
117            executions: Arc::new(Mutex::new(BTreeMap::new())),
118        })
119    }
120
121    /// Returns the exact installed command when present.
122    #[must_use]
123    pub fn command(&self, id: &CommandId) -> Option<&CommandSpec> {
124        self.commands.get(id)
125    }
126
127    /// Executes or reconciles one local check under a bounded fenced lease.
128    ///
129    /// # Errors
130    /// Returns a typed refusal when the request is not installed, its lease is
131    /// invalid, or the durable lifecycle cannot advance.
132    pub fn run(
133        &mut self,
134        request: &LocalCheckRequest,
135        holder: Datum,
136        now: u64,
137        expires_at: u64,
138        cancellation: &ProcessCancellation,
139    ) -> Result<OperationOutcome, LocalCheckError> {
140        let command = self
141            .commands
142            .get(request.command())
143            .ok_or(LocalCheckError::CommandNotInstalled)?
144            .clone();
145        match (command.network(), request.network_grant()) {
146            (sim_lib_exec::NetworkAccess::Absent, None) => {}
147            (sim_lib_exec::NetworkAccess::Scoped(expected), Some((observed, _)))
148                if expected == observed => {}
149            _ => {
150                return Err(LocalCheckError::InvalidContract(
151                    "separate network capability grant does not match command".into(),
152                ));
153            }
154        }
155        let intent = request_check(request, &command)?;
156        let grant = OperationGrant::new(
157            intent.id().clone(),
158            CapabilityName::new("exec/local-check"),
159            Datum::String(request.grant().as_str().into()),
160        )?;
161        let mut performer = LocalPerformer {
162            process: Arc::clone(&self.process),
163            launchers: Arc::clone(&self.launchers),
164            command: command.clone(),
165            cancellation: cancellation.clone(),
166            resources: Arc::clone(&self.resources),
167            executions: Arc::clone(&self.executions),
168        };
169        let mut observer = LocalObserver {
170            command,
171            resources: Arc::clone(&self.resources),
172            executions: Arc::clone(&self.executions),
173        };
174        self.lifecycle
175            .run(
176                &intent,
177                &grant,
178                LeaseWindow::new(holder, now, expires_at)?,
179                &mut performer,
180                &mut observer,
181            )
182            .map_err(Into::into)
183    }
184
185    /// Reconstructs the durable lifecycle for a request without performing it.
186    ///
187    /// # Errors
188    /// Returns a typed refusal when the request is not installed or the journal
189    /// cannot be verified.
190    pub fn record(
191        &self,
192        request: &LocalCheckRequest,
193    ) -> Result<Option<sim_lib_operation_gate::OperationLifecycleRecord>, LocalCheckError> {
194        let command = self
195            .commands
196            .get(request.command())
197            .ok_or(LocalCheckError::CommandNotInstalled)?;
198        let intent = request_check(request, command)?;
199        self.lifecycle.record(intent.id()).map_err(Into::into)
200    }
201}
202
203impl<B: JournalBackend> LocalCheckPort for LocalCheckAdapter<B> {
204    fn check(
205        &mut self,
206        request: &LocalCheckRequest,
207        lease: &LocalCheckLease,
208        cancellation: &ProcessCancellation,
209    ) -> LocalCheckResult {
210        let operation = self
211            .commands
212            .get(request.command())
213            .and_then(|command| request_check(request, command).ok())
214            .map(|intent| intent.id().to_string());
215        match self.run(
216            request,
217            lease.holder.clone(),
218            lease.acquired_at,
219            lease.expires_at,
220            cancellation,
221        ) {
222            Ok(outcome) => LocalCheckResult {
223                operation,
224                status: match outcome {
225                    OperationOutcome::AlreadyTrue { .. } => LocalCheckStatus::AlreadyTrue,
226                    OperationOutcome::Verified { .. } => LocalCheckStatus::Verified,
227                    OperationOutcome::Diverged { .. } => LocalCheckStatus::Diverged,
228                    OperationOutcome::Uncertain { .. } => LocalCheckStatus::Uncertain,
229                },
230                evidence: outcome_evidence(outcome),
231            },
232            Err(error) => LocalCheckResult {
233                operation,
234                status: LocalCheckStatus::Refused,
235                evidence: node(
236                    "local-check-refusal-v1",
237                    vec![("detail", Datum::String(error.to_string()))],
238                ),
239            },
240        }
241    }
242}
243
244struct LocalPerformer {
245    process: Arc<dyn ProcessPort>,
246    launchers: Arc<LauncherRegistry>,
247    command: CommandSpec,
248    cancellation: ProcessCancellation,
249    resources: Arc<BTreeMap<String, PathBuf>>,
250    executions: Arc<Mutex<BTreeMap<FencedDispatchId, ExecutionRecord>>>,
251}
252
253impl LifecyclePerformer for LocalPerformer {
254    fn identity(&self) -> Datum {
255        Datum::String("platform/site/ubuntu-pc/local-check-performer".into())
256    }
257
258    fn perform(&mut self, dispatch: &FencedDispatch) -> LifecyclePerformerResponse {
259        let argv = match self.command.invocation().argv() {
260            Ok(argv) => argv,
261            Err(error) => {
262                return LifecyclePerformerResponse::Receipt(refusal_datum(
263                    "invalid invocation",
264                    &error.to_string(),
265                ));
266            }
267        };
268        let record = match self.command.route() {
269            CommandRoute::Process => {
270                let request = ProcessRequest {
271                    program: self.command.program().clone(),
272                    argv,
273                    root: self.command.root().clone(),
274                    environment: self.command.environment().clone(),
275                    private_artifacts: vec![],
276                    budget: self.command.budget().clone(),
277                };
278                record_process(self.process.run(&request, &self.cancellation))
279            }
280            CommandRoute::Sandbox { launcher, policy } => {
281                let request = match SandboxRequest::new(
282                    self.command.program().clone(),
283                    argv,
284                    self.command.environment().clone(),
285                    self.command.budget().stdin.clone().unwrap_or_default(),
286                    policy.clone(),
287                ) {
288                    Ok(request) => request,
289                    Err(error) => {
290                        return LifecyclePerformerResponse::Receipt(refusal_datum(
291                            "invalid sandbox request",
292                            &error.to_string(),
293                        ));
294                    }
295                };
296                record_sandbox(
297                    self.launchers
298                        .launch(launcher, &request, &self.cancellation),
299                    policy,
300                )
301            }
302        };
303        let scratch_clean =
304            clean_scratch(self.command.cleanup().scratch_resources(), &self.resources);
305        let mut record = record;
306        record.cleanup_proven &= scratch_clean;
307        let raw = record.raw.clone();
308        self.executions
309            .lock()
310            .expect("local execution record lock")
311            .insert(dispatch.id().clone(), record);
312        LifecyclePerformerResponse::Receipt(raw)
313    }
314}
315
316struct LocalObserver {
317    command: CommandSpec,
318    resources: Arc<BTreeMap<String, PathBuf>>,
319    executions: Arc<Mutex<BTreeMap<FencedDispatchId, ExecutionRecord>>>,
320}
321
322impl PostconditionObserver for LocalObserver {
323    fn identity(&self) -> Datum {
324        Datum::String("platform/site/ubuntu-pc/local-check-observer".into())
325    }
326
327    fn observe(&mut self, request: &PostconditionRequest) -> PostconditionResponse {
328        let outputs = match observe_outputs(&self.command, &self.resources) {
329            Ok(value) => value,
330            Err(reason) => {
331                return PostconditionResponse::Unavailable {
332                    reason: Datum::String(reason),
333                };
334            }
335        };
336        let record = request.dispatch().and_then(|dispatch| {
337            self.executions
338                .lock()
339                .expect("local execution record lock")
340                .get(dispatch)
341                .cloned()
342        });
343        let Some(record) = record else {
344            if outputs.complete && !self.command.outputs().outputs().is_empty() {
345                return PostconditionResponse::Satisfied {
346                    observed: self.command.outputs().canonical_datum(),
347                    evidence: outputs.evidence,
348                };
349            }
350            return PostconditionResponse::NotSatisfied {
351                observed: Datum::String("no independently observed completed invocation".into()),
352                evidence: outputs.evidence,
353            };
354        };
355        if !record.bounded || !record.cleanup_proven {
356            return PostconditionResponse::Unavailable {
357                reason: node(
358                    "local-check-observer-unavailable-v1",
359                    vec![
360                        ("raw", record.raw),
361                        ("bounded", Datum::Bool(record.bounded)),
362                        ("cleanup-proven", Datum::Bool(record.cleanup_proven)),
363                    ],
364                ),
365            };
366        }
367        let exit_matches = record
368            .exit_code
369            .is_some_and(|code| self.command.outputs().exit_codes().contains(&code));
370        let evidence = node(
371            "local-check-observation-v1",
372            vec![
373                ("command", Datum::String(self.command.id().to_string())),
374                (
375                    "exit",
376                    record
377                        .exit_code
378                        .map_or(Datum::Nil, |code| Datum::String(code.to_string())),
379                ),
380                ("outputs", outputs.evidence),
381                ("cleanup", Datum::Bool(record.cleanup_proven)),
382            ],
383        );
384        if exit_matches && outputs.complete {
385            PostconditionResponse::Satisfied {
386                observed: self.command.outputs().canonical_datum(),
387                evidence,
388            }
389        } else {
390            PostconditionResponse::NotSatisfied {
391                observed: node(
392                    "local-check-diverged-v1",
393                    vec![
394                        ("exit-matches", Datum::Bool(exit_matches)),
395                        ("outputs-match", Datum::Bool(outputs.complete)),
396                    ],
397                ),
398                evidence,
399            }
400        }
401    }
402}
403
404struct OutputObservation {
405    complete: bool,
406    evidence: Datum,
407}
408
409fn observe_outputs(
410    command: &CommandSpec,
411    resources: &BTreeMap<String, PathBuf>,
412) -> Result<OutputObservation, String> {
413    let mut complete = true;
414    let mut evidence = Vec::new();
415    for expected in command.outputs().outputs() {
416        let root = resources
417            .get(&expected.resource)
418            .ok_or_else(|| format!("unregistered output resource {}", expected.resource))?;
419        let root = root
420            .canonicalize()
421            .map_err(|error| format!("output root unavailable: {error}"))?;
422        let path = root.join(&expected.relative_path);
423        let state_matches = match &expected.state {
424            OutputState::Exists => confined_existing(&root, &path)?.is_some(),
425            OutputState::Absent => confined_existing(&root, &path)?.is_none(),
426            OutputState::FileContent(expected_id) => {
427                let Some(path) = confined_existing(&root, &path)? else {
428                    complete = false;
429                    continue;
430                };
431                let bytes =
432                    fs::read(path).map_err(|error| format!("output read failed: {error}"))?;
433                Datum::Bytes(bytes)
434                    .content_id()
435                    .map_err(|_| "output content is not canonical".to_owned())?
436                    == *expected_id
437            }
438        };
439        complete &= state_matches;
440        evidence.push(node(
441            "output-path-v1",
442            vec![
443                ("resource", Datum::String(expected.resource.clone())),
444                (
445                    "relative-path",
446                    Datum::String(expected.relative_path.clone()),
447                ),
448                ("matches", Datum::Bool(state_matches)),
449            ],
450        ));
451    }
452    Ok(OutputObservation {
453        complete,
454        evidence: Datum::Vector(evidence),
455    })
456}
457
458fn confined_existing(root: &Path, path: &Path) -> Result<Option<PathBuf>, String> {
459    match path.canonicalize() {
460        Ok(path) if path.starts_with(root) => Ok(Some(path)),
461        Ok(_) => Err("output path escapes its registered root".into()),
462        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
463            let mut parent = path.parent();
464            while let Some(candidate) = parent {
465                if candidate.exists() {
466                    let candidate = candidate
467                        .canonicalize()
468                        .map_err(|error| format!("output parent unavailable: {error}"))?;
469                    return if candidate.starts_with(root) {
470                        Ok(None)
471                    } else {
472                        Err("output parent escapes its registered root".into())
473                    };
474                }
475                parent = candidate.parent();
476            }
477            Err("output path has no registered ancestor".into())
478        }
479        Err(error) => Err(format!("output observation failed: {error}")),
480    }
481}
482
483fn clean_scratch(
484    names: &std::collections::BTreeSet<String>,
485    resources: &BTreeMap<String, PathBuf>,
486) -> bool {
487    names.iter().all(|name| {
488        resources
489            .get(name)
490            .is_some_and(|root| clear_owned_root(root).is_ok())
491    })
492}
493
494fn clear_owned_root(root: &Path) -> Result<(), String> {
495    let root = root
496        .canonicalize()
497        .map_err(|error| format!("scratch root unavailable: {error}"))?;
498    for entry in fs::read_dir(&root).map_err(|error| format!("scratch root unreadable: {error}"))? {
499        let path = entry
500            .map_err(|error| format!("scratch entry unreadable: {error}"))?
501            .path();
502        let metadata = fs::symlink_metadata(&path)
503            .map_err(|error| format!("scratch metadata unavailable: {error}"))?;
504        if metadata.file_type().is_dir() {
505            fs::remove_dir_all(path)
506        } else {
507            fs::remove_file(path)
508        }
509        .map_err(|error| format!("scratch cleanup failed: {error}"))?;
510    }
511    Ok(())
512}
513
514fn record_process(attempt: ProcessAttempt) -> ExecutionRecord {
515    match attempt {
516        ProcessAttempt::Completed { receipt } => ExecutionRecord {
517            exit_code: Some(receipt.result.exit_code),
518            raw: process_result_datum(
519                receipt.result.exit_code,
520                &receipt.result.stdout,
521                &receipt.result.stderr,
522                receipt.result.truncated,
523            ),
524            cleanup_proven: false,
525            bounded: !receipt.result.truncated,
526        },
527        ProcessAttempt::StoppedAfterTimeout { receipt }
528        | ProcessAttempt::StoppedAfterCancel { receipt } => ExecutionRecord {
529            exit_code: None,
530            raw: node(
531                "process-stopped-v1",
532                vec![
533                    ("provider", Datum::String(receipt.provider)),
534                    ("cleanup", Datum::String(receipt.cleanup)),
535                ],
536            ),
537            cleanup_proven: true,
538            bounded: true,
539        },
540        ProcessAttempt::NotDispatched { refusal } => ExecutionRecord {
541            exit_code: None,
542            raw: process_refusal_datum(&refusal),
543            cleanup_proven: true,
544            bounded: true,
545        },
546        ProcessAttempt::UnknownAfterDispatch { evidence } => ExecutionRecord {
547            exit_code: None,
548            raw: refusal_datum(
549                "unknown after dispatch",
550                &format!("{}: {}", evidence.stage, evidence.detail),
551            ),
552            cleanup_proven: false,
553            bounded: false,
554        },
555    }
556}
557
558fn record_sandbox(
559    attempt: SandboxAttempt,
560    policy: &sim_lib_exec::SandboxPolicy,
561) -> ExecutionRecord {
562    match attempt {
563        SandboxAttempt::Completed(result) => {
564            let bounded =
565                result.report.proves_required(policy) && result.report.limit_hits.is_empty();
566            let cleanup = !result.report.cleanup.is_empty();
567            ExecutionRecord {
568                exit_code: Some(result.exit_code),
569                raw: process_result_datum(
570                    result.exit_code,
571                    &String::from_utf8_lossy(&result.stdout),
572                    &String::from_utf8_lossy(&result.stderr),
573                    !result.report.limit_hits.is_empty(),
574                ),
575                cleanup_proven: cleanup,
576                bounded,
577            }
578        }
579        SandboxAttempt::Stopped(report) => ExecutionRecord {
580            exit_code: None,
581            raw: node(
582                "sandbox-stopped-v1",
583                vec![
584                    ("launcher", Datum::String(report.launcher)),
585                    ("cleanup", Datum::String(report.cleanup)),
586                ],
587            ),
588            cleanup_proven: true,
589            bounded: true,
590        },
591        SandboxAttempt::Refused(refusal) => ExecutionRecord {
592            exit_code: None,
593            raw: refusal_datum("sandbox refused", &refusal.reason),
594            cleanup_proven: true,
595            bounded: true,
596        },
597        SandboxAttempt::Unknown(refusal) => ExecutionRecord {
598            exit_code: None,
599            raw: refusal_datum("sandbox unknown", &refusal.reason),
600            cleanup_proven: false,
601            bounded: false,
602        },
603    }
604}
605
606fn validate_command_resources(
607    command: &CommandSpec,
608    resources: &BTreeMap<String, PathBuf>,
609) -> Result<(), LocalCheckError> {
610    for resource in command.resources() {
611        let path = resources
612            .get(&resource.source)
613            .ok_or_else(|| LocalCheckError::ResourceUnavailable(resource.source.clone()))?;
614        if !path.is_dir() {
615            return Err(LocalCheckError::ResourceUnavailable(
616                resource.source.clone(),
617            ));
618        }
619    }
620    if let CommandRoute::Sandbox { policy, .. } = command.route() {
621        if command
622            .resources()
623            .iter()
624            .all(|resource| resource.guest_path != "/work")
625        {
626            return Err(LocalCheckError::InvalidContract(
627                "sandbox command has no explicit /work checkout resource".into(),
628            ));
629        }
630        if policy
631            .mounts()
632            .iter()
633            .any(|mount| mount.access == MountAccess::Writable)
634            && command
635                .resources()
636                .iter()
637                .all(|resource| resource.access != ResourceAccess::Writable)
638        {
639            return Err(LocalCheckError::InvalidContract(
640                "writable sandbox mount lacks writable command resource".into(),
641            ));
642        }
643    }
644    Ok(())
645}
646
647fn node(tag: &str, fields: Vec<(&str, Datum)>) -> Datum {
648    Datum::Node {
649        tag: Symbol::qualified("local-check", tag),
650        fields: fields
651            .into_iter()
652            .map(|(name, value)| (Symbol::new(name), value))
653            .collect(),
654    }
655}
656fn refusal_datum(stage: &str, detail: &str) -> Datum {
657    node(
658        "refusal-v1",
659        vec![
660            ("stage", Datum::String(stage.into())),
661            ("detail", Datum::String(detail.into())),
662        ],
663    )
664}
665fn process_result_datum(exit: i32, stdout: &str, stderr: &str, truncated: bool) -> Datum {
666    node(
667        "process-result-v1",
668        vec![
669            ("exit", Datum::String(exit.to_string())),
670            ("stdout", Datum::String(stdout.into())),
671            ("stderr", Datum::String(stderr.into())),
672            ("truncated", Datum::Bool(truncated)),
673        ],
674    )
675}
676fn process_refusal_datum(refusal: &ProcessRefusal) -> Datum {
677    let (kind, detail) = match refusal {
678        ProcessRefusal::Invalid(detail) => ("invalid", detail),
679        ProcessRefusal::Refused(detail) => ("refused", detail),
680        ProcessRefusal::SpawnFailed(detail) => ("spawn-failed", detail),
681    };
682    node(
683        "process-refusal-v1",
684        vec![
685            (
686                "kind",
687                Datum::Symbol(Symbol::qualified("process-refusal", kind)),
688            ),
689            ("detail", Datum::String(detail.clone())),
690        ],
691    )
692}
693
694fn outcome_evidence(outcome: OperationOutcome) -> Datum {
695    match outcome {
696        OperationOutcome::AlreadyTrue { evidence } => node(
697            "already-true-v1",
698            vec![("evidence", Datum::String(evidence.to_string()))],
699        ),
700        OperationOutcome::Verified { evidence } => node(
701            "verified-v1",
702            vec![("evidence", Datum::String(evidence.to_string()))],
703        ),
704        OperationOutcome::Diverged { observed, expected } => node(
705            "diverged-v1",
706            vec![("observed", observed), ("expected", expected)],
707        ),
708        OperationOutcome::Uncertain { last_durable_step } => node(
709            "uncertain-v1",
710            vec![(
711                "last-durable-step",
712                Datum::Symbol(Symbol::qualified(
713                    "operation-step",
714                    match last_durable_step {
715                        sim_lib_operation_gate::OperationStep::IntentPersisted => {
716                            "intent-persisted"
717                        }
718                        sim_lib_operation_gate::OperationStep::LeaseAcquired => "lease-acquired",
719                        sim_lib_operation_gate::OperationStep::DispatchPersisted => {
720                            "dispatch-persisted"
721                        }
722                        sim_lib_operation_gate::OperationStep::ReceiptPersisted => {
723                            "receipt-persisted"
724                        }
725                        sim_lib_operation_gate::OperationStep::ObservationPersisted => {
726                            "observation-persisted"
727                        }
728                        sim_lib_operation_gate::OperationStep::OutcomePersisted => {
729                            "outcome-persisted"
730                        }
731                    },
732                )),
733            )],
734        ),
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use sim_lib_exec::{
742        ArgAtom, BuildSourceRef, CapabilityGrantRef, CleanupContract, CommandInvocation,
743        CommandReplayPolicy, CommandResource, MountAccess, NetworkAccess, OutputContract,
744        OutputExpectation, PacketRef, ProcResult, ProcessBudget, ProcessReceipt, ProgramRef,
745        ProjectRootRef, SandboxControl, SandboxEvidence, SandboxLimits, SandboxMount,
746        SandboxPolicy, SandboxReport, SandboxRequirement, SandboxResult, SealedBindings,
747    };
748    use sim_lib_journal::MemoryBackend;
749    use std::{
750        collections::BTreeSet,
751        time::{SystemTime, UNIX_EPOCH},
752    };
753
754    #[derive(Default)]
755    struct RefusingProcess;
756    impl ProcessPort for RefusingProcess {
757        fn run(&self, _: &ProcessRequest, _: &ProcessCancellation) -> ProcessAttempt {
758            ProcessAttempt::NotDispatched {
759                refusal: ProcessRefusal::Refused("sandbox required".into()),
760            }
761        }
762    }
763
764    struct FixtureLauncher {
765        work: PathBuf,
766        scratch: PathBuf,
767    }
768    impl sim_lib_exec::SandboxLauncher for FixtureLauncher {
769        fn id(&self) -> &'static str {
770            "fixture/sandbox"
771        }
772        fn launch(&self, request: &SandboxRequest, _: &ProcessCancellation) -> SandboxAttempt {
773            let script = request.argv.last().map(ArgAtom::as_str).unwrap_or_default();
774            fs::write(self.scratch.join("temporary"), b"leak").unwrap();
775            let (exit_code, stderr) = if script == "format" {
776                fs::write(self.work.join("formatted.rs"), b"fn main() {}\n").unwrap();
777                (0, Vec::new())
778            } else {
779                (1, b"deliberate test failure".to_vec())
780            };
781            SandboxAttempt::Completed(SandboxResult {
782                stdout: vec![],
783                stderr,
784                exit_code,
785                report: SandboxReport {
786                    launcher: self.id().into(),
787                    controls: all_controls()
788                        .map(|control| SandboxEvidence {
789                            control,
790                            achieved: true,
791                            detail: "fixture control proof".into(),
792                        })
793                        .collect(),
794                    limit_hits: vec![],
795                    cleanup: "fixture process group empty".into(),
796                },
797            })
798        }
799    }
800
801    fn all_controls() -> impl Iterator<Item = SandboxControl> {
802        [
803            SandboxControl::Network,
804            SandboxControl::Mounts,
805            SandboxControl::Root,
806            SandboxControl::Environment,
807            SandboxControl::Identity,
808            SandboxControl::Cpu,
809            SandboxControl::Memory,
810            SandboxControl::WallTime,
811            SandboxControl::ProcessCount,
812            SandboxControl::FileCount,
813            SandboxControl::FileBytes,
814            SandboxControl::Output,
815            SandboxControl::Stdin,
816            SandboxControl::ProcessTree,
817        ]
818        .into_iter()
819    }
820    fn policy(resources: &[CommandResource]) -> SandboxPolicy {
821        SandboxPolicy::new(
822            all_controls().map(|control| (control, SandboxRequirement::Required)),
823            resources
824                .iter()
825                .map(|resource| SandboxMount {
826                    source: resource.source.clone(),
827                    guest_path: resource.guest_path.clone(),
828                    access: match resource.access {
829                        ResourceAccess::ReadOnly => MountAccess::ReadOnly,
830                        ResourceAccess::Writable => MountAccess::Writable,
831                    },
832                })
833                .collect(),
834            SandboxLimits {
835                cpu_seconds: 10,
836                memory_bytes: 256 * 1024 * 1024,
837                wall_time_ms: 5_000,
838                process_count: 16,
839                file_count: 1_000,
840                file_bytes: 16 * 1024 * 1024,
841                output_bytes: 16 * 1024,
842                stdin_bytes: 1,
843            },
844        )
845        .unwrap()
846    }
847    fn root(label: &str) -> PathBuf {
848        let path = std::env::temp_dir().join(format!(
849            "sim-local-check-{label}-{}",
850            SystemTime::now()
851                .duration_since(UNIX_EPOCH)
852                .unwrap()
853                .as_nanos()
854        ));
855        fs::create_dir_all(&path).unwrap();
856        path
857    }
858    fn spec(
859        script: &str,
860        resources: &[CommandResource],
861        outputs: OutputContract,
862        launcher: &str,
863    ) -> CommandSpec {
864        CommandSpec::new(
865            ProgramRef::new("shell").unwrap(),
866            ProjectRootRef::new("work").unwrap(),
867            CommandInvocation::Interpreter {
868                flags: vec![ArgAtom::new("-c").unwrap()],
869                script: script.as_bytes().to_vec(),
870            },
871            SealedBindings::literals([("PATH".into(), "/usr/bin".into())]).unwrap(),
872            resources.to_vec(),
873            ProcessBudget {
874                timeout_ms: 5_000,
875                max_output_bytes: 16 * 1024,
876                stdin: None,
877            },
878            outputs,
879            CleanupContract::process_group(["scratch".into()]).unwrap(),
880            NetworkAccess::Absent,
881            CommandRoute::Sandbox {
882                launcher: launcher.into(),
883                policy: policy(resources),
884            },
885            CommandReplayPolicy::ExactlyOnce,
886        )
887        .unwrap()
888    }
889    fn request(command: &CommandSpec, label: &str) -> LocalCheckRequest {
890        LocalCheckRequest::new(
891            PacketRef::new(format!("packet/{label}")).unwrap(),
892            command.id().clone(),
893            BuildSourceRef::new(format!("source/{label}")).unwrap(),
894            CapabilityGrantRef::new(format!("grant/{label}")).unwrap(),
895        )
896    }
897
898    #[test]
899    fn exact_adapter_observes_format_change_failure_and_scratch_cleanup() {
900        let work = root("work");
901        let scratch = root("scratch");
902        fs::write(work.join("formatted.rs"), b"fn  main( ){}\n").unwrap();
903        let expected = Datum::Bytes(b"fn main() {}\n".to_vec())
904            .content_id()
905            .unwrap();
906        let resources = vec![
907            CommandResource {
908                source: "work".into(),
909                guest_path: "/work".into(),
910                access: ResourceAccess::Writable,
911            },
912            CommandResource {
913                source: "scratch".into(),
914                guest_path: "/scratch".into(),
915                access: ResourceAccess::Writable,
916            },
917        ];
918        let format = spec(
919            "format",
920            &resources,
921            OutputContract::new(
922                [0],
923                vec![OutputExpectation {
924                    resource: "work".into(),
925                    relative_path: "formatted.rs".into(),
926                    state: OutputState::FileContent(expected),
927                }],
928            )
929            .unwrap(),
930            "fixture/sandbox",
931        );
932        let fail = spec(
933            "fail",
934            &resources,
935            OutputContract::new([0], vec![]).unwrap(),
936            "fixture/sandbox",
937        );
938        let mut registry = LauncherRegistry::default();
939        registry
940            .register(Arc::new(FixtureLauncher {
941                work: work.clone(),
942                scratch: scratch.clone(),
943            }))
944            .unwrap();
945        let roots = BTreeMap::from([
946            ("work".into(), work.clone()),
947            ("scratch".into(), scratch.clone()),
948        ]);
949        let mut adapter = LocalCheckAdapter::new(
950            MemoryBackend::default(),
951            Arc::new(RefusingProcess),
952            registry,
953            [format.clone(), fail.clone()],
954            roots,
955        )
956        .unwrap();
957        assert!(matches!(
958            adapter
959                .run(
960                    &request(&format, "format"),
961                    Datum::String("operator".into()),
962                    1,
963                    10,
964                    &ProcessCancellation::default()
965                )
966                .unwrap(),
967            OperationOutcome::Verified { .. }
968        ));
969        assert_eq!(
970            fs::read(work.join("formatted.rs")).unwrap(),
971            b"fn main() {}\n"
972        );
973        assert_eq!(
974            fs::read_dir(&scratch).unwrap().count(),
975            0,
976            "owned scratch must be empty"
977        );
978        assert!(matches!(
979            adapter
980                .run(
981                    &request(&fail, "fail"),
982                    Datum::String("operator".into()),
983                    20,
984                    30,
985                    &ProcessCancellation::default()
986                )
987                .unwrap(),
988            OperationOutcome::Diverged { .. }
989        ));
990        assert_eq!(fs::read_dir(&scratch).unwrap().count(), 0);
991        fs::remove_dir_all(work).unwrap();
992        fs::remove_dir_all(scratch).unwrap();
993    }
994
995    #[test]
996    fn request_cannot_substitute_an_uninstalled_command_identity() {
997        let work = root("wrong-command-work");
998        let scratch = root("wrong-command-scratch");
999        let resources = vec![
1000            CommandResource {
1001                source: "work".into(),
1002                guest_path: "/work".into(),
1003                access: ResourceAccess::Writable,
1004            },
1005            CommandResource {
1006                source: "scratch".into(),
1007                guest_path: "/scratch".into(),
1008                access: ResourceAccess::Writable,
1009            },
1010        ];
1011        let installed = spec(
1012            "format",
1013            &resources,
1014            OutputContract::new([0], vec![]).unwrap(),
1015            "fixture/sandbox",
1016        );
1017        let other = spec(
1018            "different bytes",
1019            &resources,
1020            OutputContract::new([0], vec![]).unwrap(),
1021            "fixture/sandbox",
1022        );
1023        let mut registry = LauncherRegistry::default();
1024        registry
1025            .register(Arc::new(FixtureLauncher {
1026                work: work.clone(),
1027                scratch: scratch.clone(),
1028            }))
1029            .unwrap();
1030        let mut adapter = LocalCheckAdapter::new(
1031            MemoryBackend::default(),
1032            Arc::new(RefusingProcess),
1033            registry,
1034            [installed],
1035            BTreeMap::from([
1036                ("work".into(), work.clone()),
1037                ("scratch".into(), scratch.clone()),
1038            ]),
1039        )
1040        .unwrap();
1041        assert!(matches!(
1042            adapter.run(
1043                &request(&other, "other"),
1044                Datum::String("operator".into()),
1045                1,
1046                2,
1047                &ProcessCancellation::default()
1048            ),
1049            Err(LocalCheckError::CommandNotInstalled)
1050        ));
1051        fs::remove_dir_all(work).unwrap();
1052        fs::remove_dir_all(scratch).unwrap();
1053    }
1054
1055    #[test]
1056    fn process_receipt_is_not_mistaken_for_independent_cleanup_evidence() {
1057        struct Completing;
1058        impl ProcessPort for Completing {
1059            fn run(&self, _: &ProcessRequest, _: &ProcessCancellation) -> ProcessAttempt {
1060                ProcessAttempt::Completed {
1061                    receipt: ProcessReceipt {
1062                        provider: "fixture/process".into(),
1063                        elapsed_mono_ns: 1,
1064                        result: ProcResult {
1065                            stdout: String::new(),
1066                            stderr: String::new(),
1067                            exit_code: 0,
1068                            truncated: false,
1069                        },
1070                    },
1071                }
1072            }
1073        }
1074        let work = root("process-work");
1075        let resources = vec![CommandResource {
1076            source: "work".into(),
1077            guest_path: "/work".into(),
1078            access: ResourceAccess::Writable,
1079        }];
1080        let command = CommandSpec::new(
1081            ProgramRef::new("tool").unwrap(),
1082            ProjectRootRef::new("work").unwrap(),
1083            CommandInvocation::Argv(vec![]),
1084            SealedBindings::empty(),
1085            resources,
1086            ProcessBudget {
1087                timeout_ms: 1_000,
1088                max_output_bytes: 100,
1089                stdin: None,
1090            },
1091            OutputContract::new([0], vec![]).unwrap(),
1092            CleanupContract::process_group(BTreeSet::new()).unwrap(),
1093            NetworkAccess::Scoped(CapabilityName::new("network/test")),
1094            CommandRoute::Process,
1095            CommandReplayPolicy::ExactlyOnce,
1096        )
1097        .unwrap();
1098        let mut adapter = LocalCheckAdapter::new(
1099            MemoryBackend::default(),
1100            Arc::new(Completing),
1101            LauncherRegistry::default(),
1102            [command.clone()],
1103            BTreeMap::from([("work".into(), work.clone())]),
1104        )
1105        .unwrap();
1106        let process_request = request(&command, "process").with_network_grant(
1107            CapabilityName::new("network/test"),
1108            CapabilityGrantRef::new("grant/network-test").unwrap(),
1109        );
1110        assert!(matches!(
1111            adapter
1112                .run(
1113                    &process_request,
1114                    Datum::String("operator".into()),
1115                    1,
1116                    2,
1117                    &ProcessCancellation::default()
1118                )
1119                .unwrap(),
1120            OperationOutcome::Uncertain { .. }
1121        ));
1122        fs::remove_dir_all(work).unwrap();
1123    }
1124
1125    #[test]
1126    fn real_bwrap_path_is_networkless_bounded_and_independently_observed() {
1127        let work = root("bwrap-work");
1128        let scratch = root("bwrap-scratch");
1129        let resources = vec![
1130            CommandResource {
1131                source: "work".into(),
1132                guest_path: "/work".into(),
1133                access: ResourceAccess::Writable,
1134            },
1135            CommandResource {
1136                source: "scratch".into(),
1137                guest_path: "/scratch".into(),
1138                access: ResourceAccess::Writable,
1139            },
1140            CommandResource {
1141                source: "usr".into(),
1142                guest_path: "/usr".into(),
1143                access: ResourceAccess::ReadOnly,
1144            },
1145            CommandResource {
1146                source: "lib".into(),
1147                guest_path: "/lib".into(),
1148                access: ResourceAccess::ReadOnly,
1149            },
1150            CommandResource {
1151                source: "lib64".into(),
1152                guest_path: "/lib64".into(),
1153                access: ResourceAccess::ReadOnly,
1154            },
1155        ];
1156        let expected = Datum::Bytes(b"checked\n".to_vec()).content_id().unwrap();
1157        let command = spec(
1158            "printf 'checked\\n' > result; printf 'temporary\\n' > /scratch/ephemeral",
1159            &resources,
1160            OutputContract::new(
1161                [0],
1162                vec![OutputExpectation {
1163                    resource: "work".into(),
1164                    relative_path: "result".into(),
1165                    state: OutputState::FileContent(expected),
1166                }],
1167            )
1168            .unwrap(),
1169            "platform/sandbox/ubuntu-bwrap",
1170        );
1171        let source_roots = BTreeMap::from([
1172            ("work".into(), work.clone()),
1173            ("scratch".into(), scratch.clone()),
1174            ("usr".into(), PathBuf::from("/usr")),
1175            ("lib".into(), PathBuf::from("/lib")),
1176            ("lib64".into(), PathBuf::from("/lib64")),
1177        ]);
1178        let launcher = crate::BwrapLauncher::new(
1179            PathBuf::from("/usr/bin/bwrap"),
1180            PathBuf::from("/usr/bin/prlimit"),
1181            BTreeMap::from([(ProgramRef::new("shell").unwrap(), PathBuf::from("/bin/sh"))]),
1182            source_roots.clone(),
1183        );
1184        let mut registry = LauncherRegistry::default();
1185        registry.register(Arc::new(launcher)).unwrap();
1186        let mut adapter = LocalCheckAdapter::new(
1187            MemoryBackend::default(),
1188            Arc::new(RefusingProcess),
1189            registry,
1190            [command.clone()],
1191            source_roots,
1192        )
1193        .unwrap();
1194        let outcome = adapter
1195            .run(
1196                &request(&command, "real-bwrap"),
1197                Datum::String("operator/bootstrap".into()),
1198                1,
1199                10,
1200                &ProcessCancellation::default(),
1201            )
1202            .unwrap();
1203        let record = adapter
1204            .record(&request(&command, "real-bwrap"))
1205            .unwrap()
1206            .unwrap();
1207        assert!(
1208            matches!(outcome, OperationOutcome::Verified { .. }),
1209            "{outcome:?}; receipts: {:?}",
1210            record.receipts()
1211        );
1212        assert_eq!(fs::read(work.join("result")).unwrap(), b"checked\n");
1213        assert_eq!(fs::read_dir(&scratch).unwrap().count(), 0);
1214        assert_eq!(record.dispatches().len(), 1);
1215        assert_eq!(record.receipts().len(), 1);
1216        fs::remove_dir_all(work).unwrap();
1217        fs::remove_dir_all(scratch).unwrap();
1218    }
1219}