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
514mod evidence;
515
516use evidence::{
517 node, outcome_evidence, record_process, record_sandbox, refusal_datum,
518 validate_command_resources,
519};
520
521#[cfg(test)]
522mod tests;