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
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;