use std::{collections::BTreeSet, fmt};
use sim_kernel::{CapabilityName, ContentId, Datum, Error, Result, Symbol};
use crate::{
ArgAtom, ProcessBudget, ProgramRef, ProjectRootRef, SandboxControl, SandboxPolicy,
SandboxRequirement, SealedBindings,
command_wire::{
budget_datum, environment_datum, i64_datum, id_datum, invocation_datum, network_datum,
node, output_datum, replay_datum, resource_datum, route_datum,
},
};
macro_rules! opaque_ref {
($name:ident, $doc:literal, $label:literal) => {
#[doc = $doc]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self> {
let value = value.into();
if value.is_empty() || value.contains('\0') {
return Err(Error::Eval(
concat!($label, " must be non-empty and NUL-free").into(),
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
};
}
opaque_ref!(
PacketRef,
"Stable implementation packet reference.",
"packet reference"
);
opaque_ref!(
BuildSourceRef,
"Stable sealed build-source reference.",
"build-source reference"
);
opaque_ref!(
CapabilityGrantRef,
"Stable least-authority grant reference.",
"capability-grant reference"
);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct CommandId(ContentId);
impl CommandId {
pub const fn content_id(&self) -> &ContentId {
&self.0
}
}
impl fmt::Display for CommandId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}:", self.0.algorithm.as_qualified_str())?;
for byte in self.0.bytes {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandReplayPolicy {
Idempotent,
ExactlyOnce,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommandInvocation {
Argv(Vec<ArgAtom>),
Interpreter {
flags: Vec<ArgAtom>,
script: Vec<u8>,
},
}
impl CommandInvocation {
pub fn argv(&self) -> Result<Vec<ArgAtom>> {
match self {
Self::Argv(argv) => Ok(argv.clone()),
Self::Interpreter { flags, script } => {
let script = std::str::from_utf8(script)
.map_err(|_| Error::Eval("trusted command script is not UTF-8".into()))?;
if script.contains('\0') {
return Err(Error::Eval("trusted command script contains NUL".into()));
}
let mut argv = flags.clone();
argv.push(ArgAtom::new(script)?);
Ok(argv)
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum ResourceAccess {
ReadOnly,
Writable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandResource {
pub source: String,
pub guest_path: String,
pub access: ResourceAccess,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputState {
Exists,
Absent,
FileContent(ContentId),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputExpectation {
pub resource: String,
pub relative_path: String,
pub state: OutputState,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputContract {
exit_codes: BTreeSet<i32>,
outputs: Vec<OutputExpectation>,
}
impl OutputContract {
pub fn new(
exit_codes: impl IntoIterator<Item = i32>,
outputs: Vec<OutputExpectation>,
) -> Result<Self> {
let exit_codes = exit_codes.into_iter().collect::<BTreeSet<_>>();
if exit_codes.is_empty() {
return Err(Error::Eval("output contract needs an exit code".into()));
}
let mut paths = BTreeSet::new();
for output in &outputs {
if output.resource.is_empty()
|| output.relative_path.is_empty()
|| output.relative_path.starts_with('/')
|| output
.relative_path
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
|| output.relative_path.contains('\0')
|| !paths.insert((&output.resource, &output.relative_path))
{
return Err(Error::Eval(
"invalid or duplicate output expectation".into(),
));
}
}
Ok(Self {
exit_codes,
outputs,
})
}
pub fn exit_codes(&self) -> &BTreeSet<i32> {
&self.exit_codes
}
pub fn outputs(&self) -> &[OutputExpectation] {
&self.outputs
}
pub fn canonical_datum(&self) -> Datum {
node(
"output-contract-v1",
vec![
(
"exit-codes",
Datum::Set(
self.exit_codes
.iter()
.map(|value| i64_datum(i64::from(*value)))
.collect(),
),
),
(
"outputs",
Datum::Vector(self.outputs.iter().map(output_datum).collect()),
),
],
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CleanupContract {
scratch_resources: BTreeSet<String>,
}
impl CleanupContract {
pub fn process_group(scratch_resources: impl IntoIterator<Item = String>) -> Result<Self> {
let scratch_resources = scratch_resources.into_iter().collect::<BTreeSet<_>>();
if scratch_resources
.iter()
.any(|value| value.is_empty() || value.contains('\0'))
{
return Err(Error::Eval("invalid cleanup resource".into()));
}
Ok(Self { scratch_resources })
}
pub fn scratch_resources(&self) -> &BTreeSet<String> {
&self.scratch_resources
}
pub fn canonical_datum(&self) -> Datum {
node(
"cleanup-contract-v1",
vec![
(
"descendant-group",
Datum::Symbol(Symbol::qualified("cleanup", "kill-reap-required")),
),
(
"scratch-resources",
Datum::Set(
self.scratch_resources
.iter()
.cloned()
.map(Datum::String)
.collect(),
),
),
],
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NetworkAccess {
Absent,
Scoped(CapabilityName),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommandRoute {
Process,
Sandbox {
launcher: String,
policy: SandboxPolicy,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandSpec {
id: CommandId,
program: ProgramRef,
root: ProjectRootRef,
invocation: CommandInvocation,
environment: SealedBindings,
resources: Vec<CommandResource>,
budget: ProcessBudget,
outputs: OutputContract,
cleanup: CleanupContract,
network: NetworkAccess,
route: CommandRoute,
replay: CommandReplayPolicy,
}
impl CommandSpec {
#[allow(clippy::too_many_arguments)]
pub fn new(
program: ProgramRef,
root: ProjectRootRef,
invocation: CommandInvocation,
environment: SealedBindings,
resources: Vec<CommandResource>,
budget: ProcessBudget,
outputs: OutputContract,
cleanup: CleanupContract,
network: NetworkAccess,
route: CommandRoute,
replay: CommandReplayPolicy,
) -> Result<Self> {
invocation.argv()?;
if budget.timeout_ms == 0 || budget.max_output_bytes == 0 {
return Err(Error::Eval("command budget must be non-zero".into()));
}
if matches!(invocation, CommandInvocation::Interpreter { .. }) && budget.stdin.is_some() {
return Err(Error::Eval(
"interpreter command reserves no second stdin script channel".into(),
));
}
let mut sources = BTreeSet::new();
let mut guests = BTreeSet::new();
for resource in &resources {
if resource.source.is_empty()
|| !resource.guest_path.starts_with('/')
|| resource.guest_path.split('/').any(|part| part == "..")
|| resource.guest_path.contains('\0')
|| !sources.insert(resource.source.as_str())
|| !guests.insert(resource.guest_path.as_str())
{
return Err(Error::Eval("invalid or duplicate command resource".into()));
}
}
let writable = resources
.iter()
.filter(|resource| resource.access == ResourceAccess::Writable)
.map(|resource| resource.source.as_str())
.collect::<BTreeSet<_>>();
if outputs
.outputs
.iter()
.any(|output| !writable.contains(output.resource.as_str()))
|| cleanup
.scratch_resources
.iter()
.any(|resource| !writable.contains(resource.as_str()))
{
return Err(Error::Eval(
"outputs and cleanup must name declared writable resources".into(),
));
}
if let CommandRoute::Sandbox { launcher, policy } = &route {
if launcher.is_empty() {
return Err(Error::Eval("sandbox launcher identity is empty".into()));
}
let mounts = policy
.mounts()
.iter()
.map(|mount| {
(
&mount.source,
&mount.guest_path,
match mount.access {
crate::MountAccess::ReadOnly => ResourceAccess::ReadOnly,
crate::MountAccess::Writable => ResourceAccess::Writable,
},
)
})
.collect::<BTreeSet<_>>();
let declared = resources
.iter()
.map(|resource| (&resource.source, &resource.guest_path, resource.access))
.collect::<BTreeSet<_>>();
if mounts != declared {
return Err(Error::Eval(
"sandbox mounts differ from command resources".into(),
));
}
if !resources
.iter()
.any(|resource| resource.source == root.as_str() && resource.guest_path == "/work")
{
return Err(Error::Eval(
"sandbox working root is not the declared /work resource".into(),
));
}
if policy.limits().wall_time_ms != budget.timeout_ms
|| policy.limits().output_bytes != budget.max_output_bytes
|| budget
.stdin
.as_ref()
.is_some_and(|stdin| stdin.len() > policy.limits().stdin_bytes)
{
return Err(Error::Eval(
"sandbox and command process budgets differ".into(),
));
}
if !matches!(network, NetworkAccess::Absent)
|| policy.requirements().get(&SandboxControl::Network)
!= Some(&SandboxRequirement::Required)
{
return Err(Error::Eval(
"current sandbox route requires proven absent networking".into(),
));
}
} else if matches!(network, NetworkAccess::Absent) {
return Err(Error::Eval(
"host process route cannot prove absent networking".into(),
));
}
let mut value = Self {
id: CommandId(ContentId::from_bytes(
Symbol::qualified("core", "sha256-datum-v1"),
[0; 32],
)),
program,
root,
invocation,
environment,
resources,
budget,
outputs,
cleanup,
network,
route,
replay,
};
value.id = CommandId(
value
.canonical_without_id()
.content_id()
.map_err(|_| Error::Eval("command specification is not canonical".into()))?,
);
Ok(value)
}
pub const fn id(&self) -> &CommandId {
&self.id
}
pub const fn program(&self) -> &ProgramRef {
&self.program
}
pub const fn root(&self) -> &ProjectRootRef {
&self.root
}
pub const fn invocation(&self) -> &CommandInvocation {
&self.invocation
}
pub const fn environment(&self) -> &SealedBindings {
&self.environment
}
pub fn resources(&self) -> &[CommandResource] {
&self.resources
}
pub const fn budget(&self) -> &ProcessBudget {
&self.budget
}
pub const fn outputs(&self) -> &OutputContract {
&self.outputs
}
pub const fn cleanup(&self) -> &CleanupContract {
&self.cleanup
}
pub const fn network(&self) -> &NetworkAccess {
&self.network
}
pub const fn route(&self) -> &CommandRoute {
&self.route
}
pub const fn replay(&self) -> CommandReplayPolicy {
self.replay
}
pub fn canonical_datum(&self) -> Datum {
self.canonical_without_id()
}
fn canonical_without_id(&self) -> Datum {
node(
"command-spec-v1",
vec![
("program", Datum::String(self.program.as_str().into())),
("root", Datum::String(self.root.as_str().into())),
("invocation", invocation_datum(&self.invocation)),
("environment", environment_datum(&self.environment)),
(
"resources",
Datum::Vector(self.resources.iter().map(resource_datum).collect()),
),
("budget", budget_datum(&self.budget)),
("outputs", self.outputs.canonical_datum()),
("cleanup", self.cleanup.canonical_datum()),
("network", network_datum(&self.network)),
("route", route_datum(&self.route)),
("replay", replay_datum(self.replay)),
],
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalCheckRequest {
packet: PacketRef,
command: CommandId,
source: BuildSourceRef,
grant: CapabilityGrantRef,
network_grant: Option<(CapabilityName, CapabilityGrantRef)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalCheckLease {
pub holder: Datum,
pub acquired_at: u64,
pub expires_at: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LocalCheckStatus {
AlreadyTrue,
Verified,
Diverged,
Uncertain,
Refused,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalCheckResult {
pub operation: Option<String>,
pub status: LocalCheckStatus,
pub evidence: Datum,
}
pub trait LocalCheckPort: Send {
fn check(
&mut self,
request: &LocalCheckRequest,
lease: &LocalCheckLease,
cancellation: &crate::ProcessCancellation,
) -> LocalCheckResult;
}
impl LocalCheckRequest {
pub fn new(
packet: PacketRef,
command: CommandId,
source: BuildSourceRef,
grant: CapabilityGrantRef,
) -> Self {
Self {
packet,
command,
source,
grant,
network_grant: None,
}
}
#[must_use]
pub fn with_network_grant(
mut self,
capability: CapabilityName,
grant: CapabilityGrantRef,
) -> Self {
self.network_grant = Some((capability, grant));
self
}
pub const fn packet(&self) -> &PacketRef {
&self.packet
}
pub const fn command(&self) -> &CommandId {
&self.command
}
pub const fn source(&self) -> &BuildSourceRef {
&self.source
}
pub const fn grant(&self) -> &CapabilityGrantRef {
&self.grant
}
pub const fn network_grant(&self) -> Option<&(CapabilityName, CapabilityGrantRef)> {
self.network_grant.as_ref()
}
pub fn canonical_datum(&self) -> Datum {
node(
"local-check-request-v1",
vec![
("packet", Datum::String(self.packet.as_str().into())),
("command", id_datum(self.command.content_id())),
("source", Datum::String(self.source.as_str().into())),
("grant", Datum::String(self.grant.as_str().into())),
(
"network-grant",
self.network_grant
.as_ref()
.map_or(Datum::Nil, |(capability, grant)| {
node(
"network-grant-v1",
vec![
("capability", Datum::String(capability.as_str().into())),
("grant", Datum::String(grant.as_str().into())),
],
)
}),
),
],
)
}
}