use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crossbeam_channel::{Receiver, Sender};
use rite_model::{
ActId, ActionType, ArtifactId, Ceremony, Level, MaterialId, MaterialKind, MaterialSource,
OutputId, ParamId, RoleId, Sha256Digest, Step, TranscriptHeader,
};
use rite_sdk::{Backend, BackendError, Retriability};
use thiserror::Error;
use crate::actions::ArtifactValue;
use crate::backend::BackendRegistry;
use crate::clock::{Clock, SystemClock};
use crate::entropy::DERIVATION_V1;
use crate::executor::{
ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
};
use crate::expressions;
use crate::output_config::OutputConfig;
use crate::protocol::{ExecEvent, MaterialOverview, Response, UiCommand, UiSignal};
use crate::reporter::{Reporter, ReporterError};
use crate::state::{ExecutionState, HandlerContext, StepResult};
use crate::step_info::StepInfo;
use crate::system_info::StartupSnapshot;
use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
if dry_run {
let mut m = [0u8; 32];
for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
*slot = *byte;
}
return Ok((m, "dry-run".to_string()));
}
let m =
crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
Ok((m, "os".to_string()))
}
pub const PRODUCER: &str = concat!("rite ", env!("CARGO_PKG_VERSION"));
fn new_run_id() -> Result<String, ExecutionError> {
let id: [u8; 16] =
crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
Ok(base16ct::lower::encode_string(&id))
}
#[derive(Debug, Error)]
pub enum ActionError {
#[error("ceremony aborted by operator")]
Aborted,
#[error("frontend channel disconnected")]
Disconnected,
#[error("transcript write failed: {0}")]
Transcript(std::io::Error),
#[error("backend error: {0}")]
Backend(#[from] BackendError),
#[error("{0}")]
Failed(String),
}
impl From<ReporterError> for ActionError {
fn from(value: ReporterError) -> Self {
match value {
ReporterError::Aborted => ActionError::Aborted,
ReporterError::Disconnected => ActionError::Disconnected,
ReporterError::Transcript(e) => ActionError::Transcript(e),
ReporterError::NoCurrentStep(_)
| ReporterError::DuplicateDraw { .. }
| ReporterError::Unseeded
| ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
}
}
}
impl ActionError {
#[must_use]
pub fn retriability(&self) -> Retriability {
match self {
ActionError::Backend(e) => e.retriability(),
ActionError::Aborted
| ActionError::Disconnected
| ActionError::Transcript(_)
| ActionError::Failed(_) => Retriability::Fatal,
}
}
}
pub fn parse_params<P: serde::de::DeserializeOwned>(
params: &serde_json::Value,
) -> Result<P, ActionError> {
serde_json::from_value(params.clone())
.map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
}
impl From<ReporterError> for ExecutionError {
fn from(value: ReporterError) -> Self {
match value {
ReporterError::Aborted => ExecutionError::Aborted,
ReporterError::Disconnected => {
ExecutionError::TranscriptError("frontend channel disconnected".to_string())
}
ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
ReporterError::DuplicateDraw { .. }
| ReporterError::Unseeded
| ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
}
}
}
pub trait Action: Send + Sync {
fn action_type(&self) -> ActionType;
fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
fn unsupported_params(&self, _params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
Vec::new()
}
fn execute(
&self,
step: &StepInfo,
ctx: &HandlerContext,
params: &serde_json::Value,
reporter: &mut Reporter<'_>,
backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError>;
}
#[derive(Debug, Clone)]
pub struct StepUnsupportedParam {
pub step: rite_model::StepId,
pub message: String,
}
#[derive(Default)]
pub struct ActionRegistry {
actions: HashMap<ActionType, Arc<dyn Action>>,
}
impl ActionRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, action: Arc<dyn Action>) {
let action_type = action.action_type();
self.actions.insert(action_type, action);
}
#[must_use]
pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
self.actions.get(action)
}
pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
self.actions.keys()
}
#[must_use]
pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for step in steps {
if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
result.push(step.action);
}
}
result
}
#[must_use]
pub fn unsupported_step_params(&self, steps: &[rite_model::Step]) -> Vec<StepUnsupportedParam> {
let mut found = Vec::new();
for step in steps {
let Some(handler) = self.actions.get(&step.action) else {
continue;
};
let Some(mut params) = rite_model::expression::literal_expr_value(&step.with) else {
continue;
};
let step_info = crate::executor::step_info_from(step);
handler.apply_defaults(&mut params, &step_info);
found.extend(
handler
.unsupported_params(¶ms, &step_info)
.into_iter()
.map(|message| StepUnsupportedParam {
step: step.id.clone(),
message,
}),
);
}
found
}
}
#[derive(Debug)]
pub struct ExecutionSummary {
pub ceremony_name: String,
pub steps_completed: usize,
pub transcript_fingerprint: TranscriptFingerprint,
}
pub struct Executor {
ceremony: Ceremony,
registry: ActionRegistry,
backend_registry: BackendRegistry,
output_config: OutputConfig,
dry_run: bool,
startup: StartupSnapshot,
clock: Arc<dyn Clock>,
}
impl Executor {
#[must_use]
pub fn new(
ceremony: Ceremony,
registry: ActionRegistry,
backend_registry: BackendRegistry,
output_config: OutputConfig,
dry_run: bool,
startup: StartupSnapshot,
) -> Self {
Self {
ceremony,
registry,
backend_registry,
output_config,
dry_run,
startup,
clock: Arc::new(SystemClock),
}
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
self.clock = clock;
self
}
pub fn run(
self,
cmd_rx: &Receiver<UiCommand>,
event_tx: &Sender<ExecEvent>,
mut transcript_sink: Box<dyn TranscriptSink>,
) -> Result<ExecutionSummary, ExecutionError> {
validate_parameters(&self.ceremony)?;
let header = TranscriptHeader::new(PRODUCER, &new_run_id()?, self.dry_run);
transcript_sink
.begin(&header)
.map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
let resolved_params: HashMap<ParamId, serde_json::Value> = self
.ceremony
.parameters
.iter()
.map(|(id, p)| (id.clone(), p.value.clone()))
.collect();
let roles: HashMap<RoleId, String> = self
.ceremony
.roles
.iter()
.map(|(id, r)| (id.clone(), r.name.clone()))
.collect();
let materials_map: HashMap<MaterialId, String> = self
.ceremony
.materials
.iter()
.map(|(id, m)| (id.clone(), m.display_name().to_string()))
.collect();
let ceremony_name = self.ceremony.metadata.name.clone();
let clock = Arc::clone(&self.clock);
let mut reporter = Reporter::new(
event_tx,
cmd_rx,
transcript_sink.as_mut(),
Arc::clone(&clock),
);
let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
match outcome {
Ok(counts) => {
let at = clock.now();
let completed = StepFact::CeremonyCompleted {};
transcript_sink
.record(at, completed.default_level(), &completed)
.map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
let _ = event_tx.send(ExecEvent::Fact {
at,
fact: completed,
});
let fingerprint = transcript_sink
.finalize()
.map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
let _ = event_tx.send(ExecEvent::Finalized {
fingerprint: fingerprint.as_str().to_string(),
});
Ok(ExecutionSummary {
ceremony_name,
steps_completed: counts.completed,
transcript_fingerprint: fingerprint,
})
}
Err(err) => {
let at = clock.now();
let record = err.to_error_record();
let failed = StepFact::CeremonyFailed { error: record };
let _ = transcript_sink.record(at, failed.default_level(), &failed);
let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
if let Ok(fingerprint) = transcript_sink.finalize() {
let _ = event_tx.send(ExecEvent::Finalized {
fingerprint: fingerprint.as_str().to_string(),
});
}
Err(err)
}
}
}
#[allow(clippy::too_many_lines)]
fn execute_inner(
mut self,
reporter: &mut Reporter<'_>,
resolved_params: HashMap<ParamId, serde_json::Value>,
roles: HashMap<RoleId, String>,
materials_map: HashMap<MaterialId, String>,
) -> Result<StepCounts, ExecutionError> {
let dry_run = self.dry_run;
reporter.fact(StepFact::CeremonyStarted {
name: self.ceremony.metadata.name.clone(),
template: self.ceremony.source_digest.clone(),
})?;
let (m, source) = gather_machine_entropy(dry_run)?;
reporter.seed_entropy(&m);
reporter.fact(StepFact::EntropySeeded {
m: base16ct::lower::encode_string(&m),
source,
derivation: DERIVATION_V1.to_string(),
})?;
for (id, role) in self.ceremony.roles.iter() {
reporter.fact(StepFact::RoleDeclared {
role: id.clone(),
name: role.name.clone(),
})?;
}
for (id, role) in self.ceremony.roles.iter() {
if let Some(person) = &role.person {
reporter.fact(StepFact::RoleAssigned {
role: id.clone(),
person: person.clone(),
})?;
}
}
for (id, parameter) in self.ceremony.parameters.iter() {
reporter.fact(StepFact::ParameterBound {
name: id.clone(),
value: parameter.value.clone(),
})?;
}
reporter.signal(UiSignal::CeremonyOverview {
description: self.ceremony.metadata.description.clone(),
materials: self
.ceremony
.materials
.iter()
.map(|(_, m)| MaterialOverview::from_material(m))
.collect(),
step_count: self.ceremony.execution_plan.len(),
})?;
reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
if !dry_run {
reporter.prompt(&Prompt::Continue {
hint: Some("Press Enter to start the ceremony".to_string()),
})?;
}
let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
for (id, material) in self.ceremony.materials.iter() {
let artifact = load_material_artifact(id.as_str(), material)?;
let identifier = match &material.kind {
MaterialKind::Physical { identifier, .. } => identifier.clone(),
MaterialKind::Digital {
source: Some(MaterialSource::Identifier { identifier }),
} => Some(identifier.clone()),
MaterialKind::Digital { .. } => None,
};
let digest = match &artifact {
ArtifactValue::Bytes(bytes) => Some(Sha256Digest::of(bytes)),
_ => None,
};
reporter.fact(StepFact::MaterialLoaded {
name: id.clone(),
identifier,
})?;
if let Some(digest) = digest {
reporter.fact(StepFact::MaterialDigest {
name: id.clone(),
digest,
})?;
}
state = state.with_material(ArtifactId::new(id.as_str()), artifact);
}
let mut backends = RunBackends {
registry: &mut self.backend_registry,
bound: HashSet::new(),
};
let has_acts = !self.ceremony.acts.is_empty();
let mut current_act: Option<ActId> = None;
let mut counts = StepCounts::default();
let plan = std::mem::take(&mut self.ceremony.execution_plan);
for step in &plan {
if has_acts {
let step_act = self
.ceremony
.sections
.get(&step.section)
.and_then(|s| s.act.clone());
if step_act != current_act {
if let Some(act_id) = &step_act
&& let Some(act) = self.ceremony.acts.get(act_id)
{
reporter.fact(StepFact::ActStarted {
id: act_id.clone(),
label: act
.name
.clone()
.unwrap_or_else(|| act_id.as_str().to_string()),
})?;
}
current_act = step_act;
}
}
reporter.set_current_step(Some(step.id.clone()));
reporter.set_current_backend(step.backend.clone());
reporter.fact(StepFact::StepStarted {
id: step.id.clone(),
label: step.step_label.clone(),
role: step.role.clone().unwrap_or_else(|| RoleId::new("")),
})?;
if !step.silent && !dry_run {
reporter.prompt(&Prompt::Continue {
hint: Some(format!("Press Enter to start step {}", step.step_label)),
})?;
}
let handler = self
.registry
.get(&step.action)
.ok_or(ExecutionError::UnknownAction(step.action))?
.clone();
let ctx = state.handler_context();
let step_info = step_info_from(step);
let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
handler.apply_defaults(&mut params, &step_info);
let result = execute_step_with_retry(
handler.as_ref(),
step,
&step_info,
&ctx,
¶ms,
reporter,
&mut backends,
)?;
reporter.fact(StepFact::StepCompleted {
id: step.id.clone(),
outcome: result.outcome.clone(),
})?;
if let StepOutcome::Completed { .. } = &result.outcome {
counts.completed = counts.completed.saturating_add(1);
}
state = merge_step_result(state, result);
if let Some(artifact_id) = &step.creates
&& !dry_run
{
let output_id = OutputId::new(artifact_id.as_str());
if self.ceremony.outputs.contains(&output_id) {
std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
ExecutionError::OutputWriteFailed {
name: "artifacts directory".to_string(),
reason: e.to_string(),
}
})?;
let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
ExecutionError::OutputWriteFailed {
name: artifact_id.as_str().to_string(),
reason: "artifact not produced".to_string(),
}
})?;
let (path, digest, _size, _mime_type) =
write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
let file = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| ExecutionError::OutputWriteFailed {
name: artifact_id.as_str().to_string(),
reason: format!("'{}' has no UTF-8 file name", path.display()),
})?
.to_string();
let digest = match artifact_value {
ArtifactValue::Secret(_) => None,
_ => Some(digest),
};
reporter.fact_at(
artifact_level(step.action, artifact_value),
StepFact::ArtifactWritten {
step: step.id.clone(),
name: artifact_id.as_str().to_string(),
file,
digest,
},
)?;
}
}
}
reporter.set_current_step(None);
reporter.set_current_backend(None);
drop(plan);
Ok(counts)
}
}
#[derive(Default, Debug, Clone, Copy)]
struct StepCounts {
completed: usize,
}
fn artifact_level(action: ActionType, value: &ArtifactValue) -> Level {
match value {
ArtifactValue::Certificate(_) | ArtifactValue::PublicKey(_) => Level::PUBLIC,
ArtifactValue::Bytes(_)
if matches!(
action,
ActionType::GenerateCsr | ActionType::SignData | ActionType::PivSign
) =>
{
Level::PUBLIC
}
ArtifactValue::Bytes(_)
| ArtifactValue::Text(_)
| ArtifactValue::WrappedKey(_)
| ArtifactValue::EncryptedData(_)
| ArtifactValue::BackendKey { .. }
| ArtifactValue::Shares(_) => Level::RESTRICTED,
ArtifactValue::Secret(_) => Level::CONFIDENTIAL,
}
}
struct RunBackends<'a> {
registry: &'a mut BackendRegistry,
bound: HashSet<String>,
}
impl RunBackends<'_> {
fn acquire(
&mut self,
name: &str,
reporter: &mut Reporter<'_>,
) -> Result<&mut dyn Backend, ActionError> {
let backend = self.registry.get_mut(name)?;
if self.bound.insert(name.to_string()) {
reporter.fact(StepFact::BackendBound {
name: name.to_string(),
provider: backend.provider().to_string(),
identity: backend.fingerprint(),
})?;
}
Ok(backend)
}
}
fn execute_step_with_retry(
handler: &dyn Action,
step: &Step,
step_info: &StepInfo,
ctx: &HandlerContext,
params: &serde_json::Value,
reporter: &mut Reporter<'_>,
backends: &mut RunBackends<'_>,
) -> Result<StepResult, ExecutionError> {
let mut attempt: u32 = 1;
loop {
let side_effects_before = reporter.side_effects_emitted();
let attempt_result = match &step_info.backend {
Some(name) => backends.acquire(name, reporter).and_then(|backend| {
handler.execute(step_info, ctx, params, reporter, Some(backend))
}),
None => handler.execute(step_info, ctx, params, reporter, None),
};
let action_err = match attempt_result {
Ok(result) => return Ok(result),
Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
Err(action_err) => action_err,
};
let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
let exec_err = action_error_to_execution(action_err, &step.id);
reporter.fact(StepFact::StepAttemptFailed {
step: step.id.clone(),
attempt,
error: exec_err.to_error_record(),
})?;
let policy_allows = match step.retry {
RetryPolicy::Never => false,
RetryPolicy::MaxAttempts(max) => attempt < max,
RetryPolicy::Prompt => true,
};
if retriable && policy_allows {
let answer = reporter.prompt(&Prompt::Confirm {
question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
default: Some(false),
})?;
if matches!(answer, Response::Bool(true)) {
attempt = attempt.saturating_add(1);
continue;
}
}
return Err(exec_err);
}
}
fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
match err {
ActionError::Aborted => ExecutionError::Aborted,
ActionError::Disconnected => ExecutionError::StepFailed {
step: step_id.clone(),
reason: "frontend channel disconnected".to_string(),
},
ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
ActionError::Backend(e) => ExecutionError::BackendError(e),
ActionError::Failed(reason) => ExecutionError::StepFailed {
step: step_id.clone(),
reason,
},
}
}
impl ExecutionError {
fn to_error_record(&self) -> ErrorRecord {
let (class, kind) = match self {
ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
ExecutionError::MaterialLoadFailed { .. } => {
(ErrorClass::Integrity, "material_load_failed")
}
ExecutionError::OutputWriteFailed { .. } => {
(ErrorClass::Integrity, "output_write_failed")
}
ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
ExecutionError::BackendError(e) => {
let class = if e.retriability().is_retriable() {
ErrorClass::Environmental
} else {
ErrorClass::Integrity
};
(class, "backend_error")
}
};
ErrorRecord::new(class, kind, self.to_string())
}
}
fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
let missing: Vec<_> = ceremony
.parameters
.iter()
.filter(|(_, p)| p.value.is_null())
.map(|(id, _)| id.as_str().to_string())
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(ExecutionError::ValidationFailed(format!(
"Parameters missing values: {}",
missing.join(", ")
)))
}
}
fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
let mut artifacts = state.artifacts;
for (id, value) in result.artifacts {
artifacts.insert(id, value);
}
ExecutionState {
params: state.params,
roles: state.roles,
materials: state.materials,
dry_run: state.dry_run,
artifacts,
}
}
#[cfg(test)]
mod tests {
use crossbeam_channel::unbounded;
use rite_model::ActionType;
use super::*;
use crate::transcript_sink::InMemorySink;
#[test]
fn action_error_retriability_delegates_to_backend() {
assert_eq!(
ActionError::Backend(BackendError::TokenNotPresent).retriability(),
Retriability::Transient,
);
assert_eq!(
ActionError::Backend(BackendError::PinBlocked).retriability(),
Retriability::Fatal,
);
}
#[test]
fn handler_level_errors_are_fatal() {
assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
assert_eq!(
ActionError::Disconnected.retriability(),
Retriability::Fatal
);
assert_eq!(
ActionError::Failed("check_value mismatch".into()).retriability(),
Retriability::Fatal,
);
}
struct PingAction;
impl Action for PingAction {
fn action_type(&self) -> ActionType {
ActionType::Attest
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
reporter.log(crate::protocol::Icon::Info, "ping")?;
Ok(StepResult::completed("pinged".to_string()))
}
}
fn minimal_ceremony() -> Ceremony {
let yaml = r#"
version: "0.3"
name: "Test"
roles:
participant:
person: "Alice"
sections:
main:
role: ${role.participant}
steps:
ping:
action: attest
silent: true
with:
statement: "I confirm."
"#;
rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve")
}
struct PickyAction;
impl Action for PickyAction {
fn action_type(&self) -> ActionType {
ActionType::Attest
}
fn unsupported_params(&self, params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
match params.get("statement").and_then(serde_json::Value::as_str) {
Some("puce") => vec!["this build has no puce".to_string()],
_ => Vec::new(),
}
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
_reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
Ok(StepResult::completed("done".to_string()))
}
}
fn ceremony_with_statement(statement: &str) -> Ceremony {
let yaml = format!(
r#"
version: "0.3"
name: "Test"
roles:
participant:
person: "Alice"
sections:
main:
role: ${{role.participant}}
steps:
ping:
action: attest
with:
statement: "{statement}"
"#
);
rite_resolver::resolve(&yaml, None)
.into_result()
.expect("resolve")
}
#[test]
fn reports_what_this_build_cannot_carry_out() {
let ceremony = ceremony_with_statement("puce");
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PickyAction));
let found = registry.unsupported_step_params(&ceremony.execution_plan);
let [issue] = found.as_slice() else {
panic!("expected exactly one finding, got {found:?}");
};
assert_eq!(issue.step.as_str(), "ping");
assert_eq!(issue.message, "this build has no puce");
}
#[test]
fn accepts_a_block_the_handler_is_happy_with() {
let ceremony = ceremony_with_statement("I confirm.");
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PickyAction));
assert!(
registry
.unsupported_step_params(&ceremony.execution_plan)
.is_empty()
);
}
#[test]
fn skips_an_action_this_build_has_no_handler_for() {
let ceremony = ceremony_with_statement("puce");
let registry = ActionRegistry::new();
assert!(
registry
.unsupported_step_params(&ceremony.execution_plan)
.is_empty()
);
}
fn dry_run_output_config() -> OutputConfig {
OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
}
#[test]
fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
let ceremony = minimal_ceremony();
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
);
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut facts = Vec::new();
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact { fact, .. } = ev {
facts.push(fact);
}
}
let summary = join.join().expect("executor join").expect("run ok");
assert_eq!(summary.steps_completed, 1);
let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
assert_eq!(
kinds,
vec![
"ceremony_started",
"entropy_seeded",
"role_declared",
"role_assigned",
"step_started",
"step_completed",
"ceremony_completed",
],
);
drop(cmd_tx);
}
struct OpensContent;
impl Action for OpensContent {
fn action_type(&self) -> ActionType {
ActionType::Attest
}
fn execute(
&self,
step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
_reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
let produces = step.produces.clone().expect("step creates");
Ok(StepResult::completed_with_artifact(
"opened",
produces,
crate::actions::ArtifactValue::Secret(secrecy::SecretBox::new(Box::new(
b"the recovery phrase".to_vec(),
))),
))
}
}
fn ceremony_writing_opened_content() -> Ceremony {
let yaml = r#"
version: "0.3"
name: "Test"
roles:
participant:
person: "Alice"
output:
opened:
type: document
sections:
main:
role: ${role.participant}
steps:
open:
action: attest
silent: true
with:
statement: "I confirm."
creates: opened
"#;
rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve")
}
fn run_acknowledging_prompts(
ceremony: Ceremony,
output_config: OutputConfig,
) -> (Result<ExecutionSummary, ExecutionError>, Vec<StepFact>) {
let mut registry = ActionRegistry::new();
registry.register(Arc::new(OpensContent));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
output_config,
false,
StartupSnapshot::placeholder(),
);
let frontend = std::thread::spawn({
let cmd_tx = cmd_tx.clone();
move || {
let mut facts = Vec::new();
while let Ok(event) = event_rx.recv() {
match event {
ExecEvent::Fact { fact, .. } => facts.push(fact),
ExecEvent::AwaitPrompt { prompt_id, .. } => {
let _ = cmd_tx.send(UiCommand::PromptResponse {
prompt_id,
response: crate::protocol::Response::Acknowledge,
});
}
_ => {}
}
}
facts
}
});
let result = executor.run(&cmd_rx, &event_tx, sink);
drop(event_tx);
drop(cmd_tx);
let facts = frontend.join().expect("frontend join");
(result, facts)
}
#[test]
fn opened_content_declared_as_an_output_is_written() {
let dir = tempfile::tempdir().expect("tempdir");
let config = OutputConfig::new(dir.path().to_path_buf());
let (result, facts) = run_acknowledging_prompts(ceremony_writing_opened_content(), config);
let summary = result.expect("ceremony runs");
assert_eq!(summary.steps_completed, 1);
let written = facts.iter().find_map(|f| match f {
StepFact::ArtifactWritten {
name, file, digest, ..
} => Some((name.clone(), file.clone(), digest.clone())),
_ => None,
});
let (name, file, digest) = written.expect("the artifact is written");
assert_eq!(name, "opened");
assert_eq!(digest, None);
assert_eq!(
std::fs::read(dir.path().join("artifacts").join(file)).expect("read artifact"),
b"the recovery phrase"
);
}
#[test]
fn run_stamps_every_fact_from_the_injected_clock() {
use crate::test_support::{FixedClock, fixed_test_time};
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let fixed = fixed_test_time();
let executor = Executor::new(
minimal_ceremony(),
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
)
.with_clock(Arc::new(FixedClock(fixed)));
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut times = Vec::new();
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact { at, .. } = ev {
times.push(at);
}
}
join.join().expect("executor join").expect("run ok");
assert!(!times.is_empty());
assert!(times.iter().all(|&t| t == fixed));
drop(cmd_tx);
}
#[derive(Debug, PartialEq, Eq)]
enum PacingTrace {
CeremonyStarted,
StepStarted(String),
ContinuePrompt { step: Option<String> },
StepCompleted,
CeremonyCompleted,
}
#[test]
#[allow(clippy::too_many_lines)]
fn pacing_prompts_fire_at_step_start_not_step_end() {
let yaml = r#"
version: "0.3"
name: "Pacing"
roles:
participant:
person: "Alice"
sections:
main:
role: ${role.participant}
steps:
first:
action: attest
with:
statement: "first"
second:
action: attest
with:
statement: "second"
"#;
let ceremony = rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve");
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
false, StartupSnapshot::placeholder(),
);
let frontend = std::thread::spawn({
let cmd_tx = cmd_tx.clone();
move || {
let mut trace = Vec::new();
while let Ok(event) = event_rx.recv() {
match event {
ExecEvent::Fact {
fact: StepFact::CeremonyStarted { .. },
..
} => {
trace.push(PacingTrace::CeremonyStarted);
}
ExecEvent::Fact {
fact: StepFact::StepStarted { label, .. },
..
} => {
trace.push(PacingTrace::StepStarted(label));
}
ExecEvent::Fact {
fact: StepFact::StepCompleted { .. },
..
} => {
trace.push(PacingTrace::StepCompleted);
}
ExecEvent::Fact {
fact: StepFact::CeremonyCompleted { .. },
..
} => {
trace.push(PacingTrace::CeremonyCompleted);
}
ExecEvent::AwaitPrompt {
prompt_id,
prompt: Prompt::Continue { .. },
step,
..
} => {
trace.push(PacingTrace::ContinuePrompt {
step: step.map(|s| s.as_str().to_string()),
});
let _ = cmd_tx.send(UiCommand::PromptResponse {
prompt_id,
response: crate::protocol::Response::Acknowledge,
});
}
_ => {}
}
}
trace
}
});
let summary = executor
.run(&cmd_rx, &event_tx, sink)
.expect("ceremony runs");
assert_eq!(summary.steps_completed, 2);
drop(event_tx);
drop(cmd_tx);
let trace = frontend.join().expect("frontend join");
let mut iter = trace.iter();
assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt { step: None })
);
let first_step = match iter.next() {
Some(PacingTrace::StepStarted(label)) => label.clone(),
other => panic!("expected first StepStarted, got {other:?}"),
};
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt {
step: Some("first".to_string()),
}),
"first step's Continue prompt must fire after StepStarted and before the body",
);
assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
let second_step = match iter.next() {
Some(PacingTrace::StepStarted(label)) => label.clone(),
other => panic!("expected second StepStarted, got {other:?}"),
};
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt {
step: Some("second".to_string()),
}),
);
assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
assert_eq!(iter.next(), None, "no further events expected");
assert_eq!(first_step, "1");
assert_eq!(second_step, "2");
}
#[test]
fn aborts_when_action_returns_aborted() {
struct AbortAction;
impl Action for AbortAction {
fn action_type(&self) -> ActionType {
ActionType::Attest
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
_reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
Err(ActionError::Aborted)
}
}
let ceremony = minimal_ceremony();
let mut registry = ActionRegistry::new();
registry.register(Arc::new(AbortAction));
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
);
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut got_failed = false;
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact {
fact: StepFact::CeremonyFailed { error, .. },
..
} = ev
{
assert_eq!(error.kind, "aborted");
assert_eq!(error.class, ErrorClass::Abort);
assert_eq!(error.message, "Ceremony aborted by the operator");
got_failed = true;
}
}
assert!(got_failed, "expected CeremonyFailed event");
let result = join.join().expect("executor join");
assert!(matches!(result, Err(ExecutionError::Aborted)));
}
enum BeforeFail {
Nothing,
SideEffect,
Prompt,
Deviation,
}
struct FlakyAction {
fails_remaining: std::sync::Mutex<u32>,
error: fn() -> ActionError,
before_fail: BeforeFail,
}
impl FlakyAction {
fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
Arc::new(Self {
fails_remaining: std::sync::Mutex::new(fails),
error,
before_fail,
})
}
}
impl Action for FlakyAction {
fn action_type(&self) -> ActionType {
ActionType::Attest
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
let mut remaining = self.fails_remaining.lock().expect("lock");
match self.before_fail {
BeforeFail::Nothing => {}
BeforeFail::SideEffect => {
reporter.backend_operation(
"test_op",
serde_json::Value::Null,
serde_json::Value::Null,
None,
)?;
}
BeforeFail::Prompt => {
reporter.prompt(&Prompt::Secret {
label: "Enter PIN".to_string(),
validator: rite_model::ValidatorSpec::NonEmpty,
})?;
}
BeforeFail::Deviation => {
reporter.fact(StepFact::DeviationRecorded {
step: None,
text: "operator note".to_string(),
})?;
}
}
if *remaining == 0 {
return Ok(StepResult::completed("done".to_string()));
}
*remaining = remaining.saturating_sub(1);
Err((self.error)())
}
}
fn transient() -> ActionError {
ActionError::Backend(BackendError::TokenNotPresent)
}
fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
let yaml = format!(
r#"
version: "0.3"
name: "Retry"
roles:
participant:
person: "Alice"
sections:
main:
role: ${{role.participant}}
steps:
work:
action: attest
silent: true
{retry_clause}
with:
statement: "x"
"#
);
rite_resolver::resolve(&yaml, None)
.into_result()
.expect("resolve")
}
fn drive(
ceremony: Ceremony,
action: Arc<dyn Action>,
confirm_answer: bool,
) -> (
Result<ExecutionSummary, ExecutionError>,
Vec<StepFact>,
usize,
) {
drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
}
fn drive_with_backends(
ceremony: Ceremony,
action: Arc<dyn Action>,
confirm_answer: bool,
backend_registry: BackendRegistry,
) -> (
Result<ExecutionSummary, ExecutionError>,
Vec<StepFact>,
usize,
) {
let mut registry = ActionRegistry::new();
registry.register(action);
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
backend_registry,
dry_run_output_config(),
true, StartupSnapshot::placeholder(),
);
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut facts = Vec::new();
let mut confirms = 0usize;
while let Ok(ev) = event_rx.recv() {
match ev {
ExecEvent::Fact { fact, .. } => facts.push(fact),
ExecEvent::AwaitPrompt {
prompt_id,
prompt: Prompt::Confirm { .. },
..
} => {
confirms = confirms.saturating_add(1);
cmd_tx
.send(UiCommand::PromptResponse {
prompt_id,
response: Response::Bool(confirm_answer),
})
.expect("send confirm");
}
ExecEvent::AwaitPrompt {
prompt_id,
prompt: Prompt::Secret { .. },
..
} => {
cmd_tx
.send(UiCommand::PromptResponse {
prompt_id,
response: Response::Secret("123456".to_string().into()),
})
.expect("send secret");
}
_ => {}
}
}
let result = join.join().expect("executor join");
(result, facts, confirms)
}
fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
facts
.iter()
.filter_map(|f| match f {
StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
_ => None,
})
.collect()
}
#[test]
fn transient_failure_is_retried_then_succeeds() {
let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
let summary = result.expect("run ok");
assert_eq!(summary.steps_completed, 1);
assert_eq!(confirms, 1, "one retry prompt");
assert_eq!(attempt_numbers(&facts), vec![1]);
let StepFact::StepAttemptFailed { error, .. } = facts
.iter()
.find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
.expect("attempt fact")
else {
unreachable!()
};
assert_eq!(error.class, ErrorClass::Environmental);
assert!(
facts
.iter()
.any(|f| matches!(f, StepFact::StepCompleted { .. }))
);
}
#[test]
fn retry_is_blocked_once_a_side_effect_was_emitted() {
let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
assert!(result.is_err(), "run fails");
assert_eq!(confirms, 0, "gate blocks the retry prompt");
assert_eq!(attempt_numbers(&facts), vec![1]);
assert!(
facts
.iter()
.any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
);
}
#[test]
fn prompting_before_a_transient_failure_stays_retriable() {
let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
let summary = result.expect("run ok");
assert_eq!(summary.steps_completed, 1);
assert_eq!(confirms, 1, "the retry prompt fired");
assert_eq!(attempt_numbers(&facts), vec![1]);
let secret_answers = facts
.iter()
.filter(|f| {
matches!(
f,
StepFact::PromptAnswered {
prompt: Prompt::Secret { .. },
..
}
)
})
.count();
assert_eq!(secret_answers, 2, "each attempt records its own answer");
}
#[test]
fn a_deviation_note_does_not_block_retry() {
let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
let summary = result.expect("run ok");
assert_eq!(summary.steps_completed, 1);
assert_eq!(confirms, 1, "the retry prompt fired");
}
#[test]
fn backend_acquisition_failure_is_retriable() {
use std::sync::atomic::{AtomicU32, Ordering};
struct NullBackend {
name: String,
}
impl Backend for NullBackend {
fn name(&self) -> &str {
&self.name
}
fn provider(&self) -> &'static str {
"mock"
}
fn fingerprint(&self) -> String {
format!("mock={}", self.name)
}
}
let probes = Arc::new(AtomicU32::new(0));
let probes_in_factory = Arc::clone(&probes);
let mut backends =
crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
Err(BackendError::HardwareFailure("no device".to_string()))
} else {
Ok(Box::new(NullBackend {
name: "token".to_string(),
}) as Box<dyn Backend>)
}
}));
backends.declare(
"token".to_string(),
rite_sdk::BackendConfig {
provider: "mock".to_string(),
extra: serde_json::json!({}),
},
);
let yaml = r#"
version: "0.3"
name: "Retry"
backends:
token:
provider: mock
roles:
participant:
person: "Alice"
sections:
main:
role: ${role.participant}
steps:
work:
action: attest
backend: token
silent: true
with:
statement: "x"
"#;
let ceremony = rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve");
let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
let summary = result.expect("run ok after the operator retried");
assert_eq!(summary.steps_completed, 1);
assert_eq!(confirms, 1, "the retry prompt fired");
assert_eq!(attempt_numbers(&facts), vec![1]);
let StepFact::StepAttemptFailed { error, .. } = facts
.iter()
.find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
.expect("attempt fact")
else {
unreachable!()
};
assert_eq!(error.class, ErrorClass::Environmental);
assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
}
#[test]
fn retry_never_fails_immediately() {
let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
assert!(result.is_err());
assert_eq!(confirms, 0, "retry: never never prompts");
assert_eq!(attempt_numbers(&facts), vec![1]);
}
#[test]
fn retry_attempts_caps_total_tries() {
let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
let (result, facts, confirms) =
drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
assert!(result.is_err());
assert_eq!(confirms, 1);
assert_eq!(attempt_numbers(&facts), vec![1, 2]);
}
#[test]
fn declining_the_retry_prompt_terminates() {
let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
assert!(result.is_err());
assert_eq!(confirms, 1, "prompted once, operator declined");
assert_eq!(attempt_numbers(&facts), vec![1]);
}
#[test]
fn artifact_levels_follow_what_the_artifact_is() {
use secrecy::SecretBox;
let bytes = || ArtifactValue::Bytes(b"x".to_vec());
assert_eq!(
artifact_level(ActionType::SignData, &bytes()),
Level::PUBLIC
);
assert_eq!(
artifact_level(ActionType::GenerateCsr, &bytes()),
Level::PUBLIC
);
assert_eq!(
artifact_level(ActionType::DecryptData, &bytes()),
Level::RESTRICTED
);
assert_eq!(
artifact_level(
ActionType::DecryptData,
&ArtifactValue::Secret(SecretBox::new(Box::new(b"x".to_vec())))
),
Level::CONFIDENTIAL
);
}
fn fact_kind(fact: &StepFact) -> &'static str {
match fact {
StepFact::CeremonyStarted { .. } => "ceremony_started",
StepFact::ActStarted { .. } => "act_started",
StepFact::StepStarted { .. } => "step_started",
StepFact::PromptAnswered { .. } => "prompt_answered",
StepFact::BackendOperation { .. } => "backend_operation",
StepFact::AttestationRecorded { .. } => "attestation_recorded",
StepFact::ArtifactWritten { .. } => "artifact_written",
StepFact::DeviationRecorded { .. } => "deviation_recorded",
StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
StepFact::StepCompleted { .. } => "step_completed",
StepFact::CeremonyCompleted { .. } => "ceremony_completed",
StepFact::CeremonyFailed { .. } => "ceremony_failed",
StepFact::EntropySeeded { .. } => "entropy_seeded",
StepFact::EntropyContributed { .. } => "entropy_contributed",
StepFact::EntropyDrawn { .. } => "entropy_drawn",
StepFact::RoleDeclared { .. } => "role_declared",
StepFact::RoleAssigned { .. } => "role_assigned",
StepFact::ParameterBound { .. } => "parameter_bound",
StepFact::MaterialLoaded { .. } => "material_loaded",
StepFact::MaterialDigest { .. } => "material_digest",
StepFact::BackendBound { .. } => "backend_bound",
StepFact::MachineInfoRecorded { .. } => "machine_info_recorded",
_ => "unknown",
}
}
}