1use 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#[derive(Debug, Error)]
27pub enum LocalCheckError {
28 #[error("local check command is not installed")]
30 CommandNotInstalled,
31 #[error("local check resource is unavailable: {0}")]
33 ResourceUnavailable(String),
34 #[error("invalid local check contract: {0}")]
36 InvalidContract(String),
37 #[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
50pub 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
74pub 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 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 #[must_use]
123 pub fn command(&self, id: &CommandId) -> Option<&CommandSpec> {
124 self.commands.get(id)
125 }
126
127 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 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}