1use std::{collections::BTreeSet, fmt};
4
5use sim_kernel::{CapabilityName, ContentId, Datum, Error, Result, Symbol};
6
7use crate::{
8 ArgAtom, ProcessBudget, ProgramRef, ProjectRootRef, SandboxControl, SandboxPolicy,
9 SandboxRequirement, SealedBindings,
10 command_wire::{
11 budget_datum, environment_datum, i64_datum, id_datum, invocation_datum, network_datum,
12 node, output_datum, replay_datum, resource_datum, route_datum,
13 },
14};
15
16macro_rules! opaque_ref {
17 ($name:ident, $doc:literal, $label:literal) => {
18 #[doc = $doc]
19 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
20 pub struct $name(String);
21 impl $name {
22 pub fn new(value: impl Into<String>) -> Result<Self> {
24 let value = value.into();
25 if value.is_empty() || value.contains('\0') {
26 return Err(Error::Eval(
27 concat!($label, " must be non-empty and NUL-free").into(),
28 ));
29 }
30 Ok(Self(value))
31 }
32 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36 }
37 };
38}
39
40opaque_ref!(
41 PacketRef,
42 "Stable implementation packet reference.",
43 "packet reference"
44);
45opaque_ref!(
46 BuildSourceRef,
47 "Stable sealed build-source reference.",
48 "build-source reference"
49);
50opaque_ref!(
51 CapabilityGrantRef,
52 "Stable least-authority grant reference.",
53 "capability-grant reference"
54);
55
56#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
58pub struct CommandId(ContentId);
59
60impl CommandId {
61 pub const fn content_id(&self) -> &ContentId {
63 &self.0
64 }
65}
66
67impl fmt::Display for CommandId {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(formatter, "{}:", self.0.algorithm.as_qualified_str())?;
70 for byte in self.0.bytes {
71 write!(formatter, "{byte:02x}")?;
72 }
73 Ok(())
74 }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum CommandReplayPolicy {
80 Idempotent,
82 ExactlyOnce,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum CommandInvocation {
89 Argv(Vec<ArgAtom>),
91 Interpreter {
93 flags: Vec<ArgAtom>,
95 script: Vec<u8>,
97 },
98}
99
100impl CommandInvocation {
101 pub fn argv(&self) -> Result<Vec<ArgAtom>> {
103 match self {
104 Self::Argv(argv) => Ok(argv.clone()),
105 Self::Interpreter { flags, script } => {
106 let script = std::str::from_utf8(script)
107 .map_err(|_| Error::Eval("trusted command script is not UTF-8".into()))?;
108 if script.contains('\0') {
109 return Err(Error::Eval("trusted command script contains NUL".into()));
110 }
111 let mut argv = flags.clone();
112 argv.push(ArgAtom::new(script)?);
113 Ok(argv)
114 }
115 }
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
121pub enum ResourceAccess {
122 ReadOnly,
124 Writable,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct CommandResource {
131 pub source: String,
133 pub guest_path: String,
135 pub access: ResourceAccess,
137}
138
139#[derive(Clone, Debug, PartialEq, Eq)]
141pub enum OutputState {
142 Exists,
144 Absent,
146 FileContent(ContentId),
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct OutputExpectation {
153 pub resource: String,
155 pub relative_path: String,
157 pub state: OutputState,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct OutputContract {
164 exit_codes: BTreeSet<i32>,
165 outputs: Vec<OutputExpectation>,
166}
167
168impl OutputContract {
169 pub fn new(
171 exit_codes: impl IntoIterator<Item = i32>,
172 outputs: Vec<OutputExpectation>,
173 ) -> Result<Self> {
174 let exit_codes = exit_codes.into_iter().collect::<BTreeSet<_>>();
175 if exit_codes.is_empty() {
176 return Err(Error::Eval("output contract needs an exit code".into()));
177 }
178 let mut paths = BTreeSet::new();
179 for output in &outputs {
180 if output.resource.is_empty()
181 || output.relative_path.is_empty()
182 || output.relative_path.starts_with('/')
183 || output
184 .relative_path
185 .split('/')
186 .any(|part| part.is_empty() || part == "." || part == "..")
187 || output.relative_path.contains('\0')
188 || !paths.insert((&output.resource, &output.relative_path))
189 {
190 return Err(Error::Eval(
191 "invalid or duplicate output expectation".into(),
192 ));
193 }
194 }
195 Ok(Self {
196 exit_codes,
197 outputs,
198 })
199 }
200 pub fn exit_codes(&self) -> &BTreeSet<i32> {
202 &self.exit_codes
203 }
204 pub fn outputs(&self) -> &[OutputExpectation] {
206 &self.outputs
207 }
208 pub fn canonical_datum(&self) -> Datum {
210 node(
211 "output-contract-v1",
212 vec![
213 (
214 "exit-codes",
215 Datum::Set(
216 self.exit_codes
217 .iter()
218 .map(|value| i64_datum(i64::from(*value)))
219 .collect(),
220 ),
221 ),
222 (
223 "outputs",
224 Datum::Vector(self.outputs.iter().map(output_datum).collect()),
225 ),
226 ],
227 )
228 }
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct CleanupContract {
234 scratch_resources: BTreeSet<String>,
235}
236
237impl CleanupContract {
238 pub fn process_group(scratch_resources: impl IntoIterator<Item = String>) -> Result<Self> {
240 let scratch_resources = scratch_resources.into_iter().collect::<BTreeSet<_>>();
241 if scratch_resources
242 .iter()
243 .any(|value| value.is_empty() || value.contains('\0'))
244 {
245 return Err(Error::Eval("invalid cleanup resource".into()));
246 }
247 Ok(Self { scratch_resources })
248 }
249 pub fn scratch_resources(&self) -> &BTreeSet<String> {
251 &self.scratch_resources
252 }
253 pub fn canonical_datum(&self) -> Datum {
255 node(
256 "cleanup-contract-v1",
257 vec![
258 (
259 "descendant-group",
260 Datum::Symbol(Symbol::qualified("cleanup", "kill-reap-required")),
261 ),
262 (
263 "scratch-resources",
264 Datum::Set(
265 self.scratch_resources
266 .iter()
267 .cloned()
268 .map(Datum::String)
269 .collect(),
270 ),
271 ),
272 ],
273 )
274 }
275}
276
277#[derive(Clone, Debug, PartialEq, Eq)]
279pub enum NetworkAccess {
280 Absent,
282 Scoped(CapabilityName),
284}
285
286#[derive(Clone, Debug, PartialEq, Eq)]
288pub enum CommandRoute {
289 Process,
291 Sandbox {
293 launcher: String,
295 policy: SandboxPolicy,
297 },
298}
299
300#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct CommandSpec {
303 id: CommandId,
304 program: ProgramRef,
305 root: ProjectRootRef,
306 invocation: CommandInvocation,
307 environment: SealedBindings,
308 resources: Vec<CommandResource>,
309 budget: ProcessBudget,
310 outputs: OutputContract,
311 cleanup: CleanupContract,
312 network: NetworkAccess,
313 route: CommandRoute,
314 replay: CommandReplayPolicy,
315}
316
317impl CommandSpec {
318 #[allow(clippy::too_many_arguments)]
320 pub fn new(
321 program: ProgramRef,
322 root: ProjectRootRef,
323 invocation: CommandInvocation,
324 environment: SealedBindings,
325 resources: Vec<CommandResource>,
326 budget: ProcessBudget,
327 outputs: OutputContract,
328 cleanup: CleanupContract,
329 network: NetworkAccess,
330 route: CommandRoute,
331 replay: CommandReplayPolicy,
332 ) -> Result<Self> {
333 invocation.argv()?;
334 if budget.timeout_ms == 0 || budget.max_output_bytes == 0 {
335 return Err(Error::Eval("command budget must be non-zero".into()));
336 }
337 if matches!(invocation, CommandInvocation::Interpreter { .. }) && budget.stdin.is_some() {
338 return Err(Error::Eval(
339 "interpreter command reserves no second stdin script channel".into(),
340 ));
341 }
342 let mut sources = BTreeSet::new();
343 let mut guests = BTreeSet::new();
344 for resource in &resources {
345 if resource.source.is_empty()
346 || !resource.guest_path.starts_with('/')
347 || resource.guest_path.split('/').any(|part| part == "..")
348 || resource.guest_path.contains('\0')
349 || !sources.insert(resource.source.as_str())
350 || !guests.insert(resource.guest_path.as_str())
351 {
352 return Err(Error::Eval("invalid or duplicate command resource".into()));
353 }
354 }
355 let writable = resources
356 .iter()
357 .filter(|resource| resource.access == ResourceAccess::Writable)
358 .map(|resource| resource.source.as_str())
359 .collect::<BTreeSet<_>>();
360 if outputs
361 .outputs
362 .iter()
363 .any(|output| !writable.contains(output.resource.as_str()))
364 || cleanup
365 .scratch_resources
366 .iter()
367 .any(|resource| !writable.contains(resource.as_str()))
368 {
369 return Err(Error::Eval(
370 "outputs and cleanup must name declared writable resources".into(),
371 ));
372 }
373 if let CommandRoute::Sandbox { launcher, policy } = &route {
374 if launcher.is_empty() {
375 return Err(Error::Eval("sandbox launcher identity is empty".into()));
376 }
377 let mounts = policy
378 .mounts()
379 .iter()
380 .map(|mount| {
381 (
382 &mount.source,
383 &mount.guest_path,
384 match mount.access {
385 crate::MountAccess::ReadOnly => ResourceAccess::ReadOnly,
386 crate::MountAccess::Writable => ResourceAccess::Writable,
387 },
388 )
389 })
390 .collect::<BTreeSet<_>>();
391 let declared = resources
392 .iter()
393 .map(|resource| (&resource.source, &resource.guest_path, resource.access))
394 .collect::<BTreeSet<_>>();
395 if mounts != declared {
396 return Err(Error::Eval(
397 "sandbox mounts differ from command resources".into(),
398 ));
399 }
400 if !resources
401 .iter()
402 .any(|resource| resource.source == root.as_str() && resource.guest_path == "/work")
403 {
404 return Err(Error::Eval(
405 "sandbox working root is not the declared /work resource".into(),
406 ));
407 }
408 if policy.limits().wall_time_ms != budget.timeout_ms
409 || policy.limits().output_bytes != budget.max_output_bytes
410 || budget
411 .stdin
412 .as_ref()
413 .is_some_and(|stdin| stdin.len() > policy.limits().stdin_bytes)
414 {
415 return Err(Error::Eval(
416 "sandbox and command process budgets differ".into(),
417 ));
418 }
419 if !matches!(network, NetworkAccess::Absent)
420 || policy.requirements().get(&SandboxControl::Network)
421 != Some(&SandboxRequirement::Required)
422 {
423 return Err(Error::Eval(
424 "current sandbox route requires proven absent networking".into(),
425 ));
426 }
427 } else if matches!(network, NetworkAccess::Absent) {
428 return Err(Error::Eval(
429 "host process route cannot prove absent networking".into(),
430 ));
431 }
432 let mut value = Self {
433 id: CommandId(ContentId::from_bytes(
434 Symbol::qualified("core", "sha256-datum-v1"),
435 [0; 32],
436 )),
437 program,
438 root,
439 invocation,
440 environment,
441 resources,
442 budget,
443 outputs,
444 cleanup,
445 network,
446 route,
447 replay,
448 };
449 value.id = CommandId(
450 value
451 .canonical_without_id()
452 .content_id()
453 .map_err(|_| Error::Eval("command specification is not canonical".into()))?,
454 );
455 Ok(value)
456 }
457 pub const fn id(&self) -> &CommandId {
459 &self.id
460 }
461 pub const fn program(&self) -> &ProgramRef {
463 &self.program
464 }
465 pub const fn root(&self) -> &ProjectRootRef {
467 &self.root
468 }
469 pub const fn invocation(&self) -> &CommandInvocation {
471 &self.invocation
472 }
473 pub const fn environment(&self) -> &SealedBindings {
475 &self.environment
476 }
477 pub fn resources(&self) -> &[CommandResource] {
479 &self.resources
480 }
481 pub const fn budget(&self) -> &ProcessBudget {
483 &self.budget
484 }
485 pub const fn outputs(&self) -> &OutputContract {
487 &self.outputs
488 }
489 pub const fn cleanup(&self) -> &CleanupContract {
491 &self.cleanup
492 }
493 pub const fn network(&self) -> &NetworkAccess {
495 &self.network
496 }
497 pub const fn route(&self) -> &CommandRoute {
499 &self.route
500 }
501 pub const fn replay(&self) -> CommandReplayPolicy {
503 self.replay
504 }
505 pub fn canonical_datum(&self) -> Datum {
507 self.canonical_without_id()
508 }
509 fn canonical_without_id(&self) -> Datum {
510 node(
511 "command-spec-v1",
512 vec![
513 ("program", Datum::String(self.program.as_str().into())),
514 ("root", Datum::String(self.root.as_str().into())),
515 ("invocation", invocation_datum(&self.invocation)),
516 ("environment", environment_datum(&self.environment)),
517 (
518 "resources",
519 Datum::Vector(self.resources.iter().map(resource_datum).collect()),
520 ),
521 ("budget", budget_datum(&self.budget)),
522 ("outputs", self.outputs.canonical_datum()),
523 ("cleanup", self.cleanup.canonical_datum()),
524 ("network", network_datum(&self.network)),
525 ("route", route_datum(&self.route)),
526 ("replay", replay_datum(self.replay)),
527 ],
528 )
529 }
530}
531
532#[derive(Clone, Debug, PartialEq, Eq)]
534pub struct LocalCheckRequest {
535 packet: PacketRef,
536 command: CommandId,
537 source: BuildSourceRef,
538 grant: CapabilityGrantRef,
539 network_grant: Option<(CapabilityName, CapabilityGrantRef)>,
540}
541
542#[derive(Clone, Debug, PartialEq, Eq)]
544pub struct LocalCheckLease {
545 pub holder: Datum,
547 pub acquired_at: u64,
549 pub expires_at: u64,
551}
552
553#[derive(Clone, Copy, Debug, PartialEq, Eq)]
555pub enum LocalCheckStatus {
556 AlreadyTrue,
558 Verified,
560 Diverged,
562 Uncertain,
564 Refused,
566}
567
568#[derive(Clone, Debug, PartialEq, Eq)]
570pub struct LocalCheckResult {
571 pub operation: Option<String>,
573 pub status: LocalCheckStatus,
575 pub evidence: Datum,
577}
578
579pub trait LocalCheckPort: Send {
581 fn check(
583 &mut self,
584 request: &LocalCheckRequest,
585 lease: &LocalCheckLease,
586 cancellation: &crate::ProcessCancellation,
587 ) -> LocalCheckResult;
588}
589
590impl LocalCheckRequest {
591 pub fn new(
593 packet: PacketRef,
594 command: CommandId,
595 source: BuildSourceRef,
596 grant: CapabilityGrantRef,
597 ) -> Self {
598 Self {
599 packet,
600 command,
601 source,
602 grant,
603 network_grant: None,
604 }
605 }
606 #[must_use]
608 pub fn with_network_grant(
609 mut self,
610 capability: CapabilityName,
611 grant: CapabilityGrantRef,
612 ) -> Self {
613 self.network_grant = Some((capability, grant));
614 self
615 }
616 pub const fn packet(&self) -> &PacketRef {
618 &self.packet
619 }
620 pub const fn command(&self) -> &CommandId {
622 &self.command
623 }
624 pub const fn source(&self) -> &BuildSourceRef {
626 &self.source
627 }
628 pub const fn grant(&self) -> &CapabilityGrantRef {
630 &self.grant
631 }
632 pub const fn network_grant(&self) -> Option<&(CapabilityName, CapabilityGrantRef)> {
634 self.network_grant.as_ref()
635 }
636 pub fn canonical_datum(&self) -> Datum {
638 node(
639 "local-check-request-v1",
640 vec![
641 ("packet", Datum::String(self.packet.as_str().into())),
642 ("command", id_datum(self.command.content_id())),
643 ("source", Datum::String(self.source.as_str().into())),
644 ("grant", Datum::String(self.grant.as_str().into())),
645 (
646 "network-grant",
647 self.network_grant
648 .as_ref()
649 .map_or(Datum::Nil, |(capability, grant)| {
650 node(
651 "network-grant-v1",
652 vec![
653 ("capability", Datum::String(capability.as_str().into())),
654 ("grant", Datum::String(grant.as_str().into())),
655 ],
656 )
657 }),
658 ),
659 ],
660 )
661 }
662}