1use std::collections::HashMap;
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rand::TryRng;
39use rand::rngs::SysRng;
40use rite_model::{
41 ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId, Step,
42};
43use rite_sdk::{Backend, BackendError, Retriability};
44use thiserror::Error;
45
46use crate::actions::ActionMetadata;
47use crate::backend::BackendRegistry;
48use crate::clock::{Clock, SystemClock};
49use crate::entropy::DERIVATION_V1;
50use crate::executor::{
51 ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
52};
53use crate::expressions;
54use crate::output_config::OutputConfig;
55use crate::protocol::{ExecEvent, Icon, MaterialOverview, Response, UiCommand, UiSignal};
56use crate::reporter::{Reporter, ReporterError};
57use crate::state::{ExecutionState, HandlerContext, StepResult};
58use crate::step_info::StepInfo;
59use crate::system_info::StartupSnapshot;
60use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
61use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
62
63fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
71 let mut m = [0u8; 32];
72 if dry_run {
73 for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
74 *slot = *byte;
75 }
76 return Ok((m, "dry-run".to_string()));
77 }
78 SysRng
79 .try_fill_bytes(&mut m)
80 .map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
81 Ok((m, "os".to_string()))
82}
83
84#[derive(Debug, Error)]
86pub enum ActionError {
87 #[error("ceremony aborted by operator")]
90 Aborted,
91 #[error("frontend channel disconnected")]
93 Disconnected,
94 #[error("transcript write failed: {0}")]
96 Transcript(std::io::Error),
97 #[error("backend error: {0}")]
99 Backend(#[from] BackendError),
100 #[error("{0}")]
102 Failed(String),
103}
104
105impl From<ReporterError> for ActionError {
106 fn from(value: ReporterError) -> Self {
107 match value {
108 ReporterError::Aborted => ActionError::Aborted,
109 ReporterError::Disconnected => ActionError::Disconnected,
110 ReporterError::Transcript(e) => ActionError::Transcript(e),
111 ReporterError::NoCurrentStep(_)
112 | ReporterError::DuplicateDraw { .. }
113 | ReporterError::Unseeded
114 | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
115 }
116 }
117}
118
119impl ActionError {
120 #[must_use]
130 pub fn retriability(&self) -> Retriability {
131 match self {
132 ActionError::Backend(e) => e.retriability(),
133 ActionError::Aborted
134 | ActionError::Disconnected
135 | ActionError::Transcript(_)
136 | ActionError::Failed(_) => Retriability::Fatal,
137 }
138 }
139}
140
141pub fn parse_params<P: serde::de::DeserializeOwned>(
150 params: &serde_json::Value,
151) -> Result<P, ActionError> {
152 serde_json::from_value(params.clone())
153 .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
154}
155
156impl From<ReporterError> for ExecutionError {
157 fn from(value: ReporterError) -> Self {
158 match value {
159 ReporterError::Aborted => ExecutionError::Aborted,
160 ReporterError::Disconnected => {
161 ExecutionError::TranscriptError("frontend channel disconnected".to_string())
162 }
163 ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
164 ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
165 ReporterError::DuplicateDraw { .. }
166 | ReporterError::Unseeded
167 | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
168 }
169 }
170}
171
172pub trait Action: Send + Sync {
179 fn metadata(&self) -> ActionMetadata;
181
182 fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
184
185 fn execute(
192 &self,
193 step: &StepInfo,
194 ctx: &HandlerContext,
195 params: &serde_json::Value,
196 reporter: &mut Reporter<'_>,
197 backend: Option<&mut dyn Backend>,
198 ) -> Result<StepResult, ActionError>;
199}
200
201#[derive(Default)]
203pub struct ActionRegistry {
204 actions: HashMap<ActionType, Arc<dyn Action>>,
205}
206
207impl ActionRegistry {
208 #[must_use]
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 pub fn register(&mut self, action: Arc<dyn Action>) {
216 let action_type = action.metadata().action_type;
217 self.actions.insert(action_type, action);
218 }
219
220 #[must_use]
222 pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
223 self.actions.get(action)
224 }
225
226 pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
228 self.actions.keys()
229 }
230
231 #[must_use]
235 pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
236 let mut seen = std::collections::HashSet::new();
237 let mut result = Vec::new();
238 for step in steps {
239 if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
240 result.push(step.action);
241 }
242 }
243 result
244 }
245}
246
247#[derive(Debug)]
249pub struct ExecutionSummary {
250 pub ceremony_name: String,
252 pub steps_completed: usize,
254 pub transcript_fingerprint: TranscriptFingerprint,
256}
257
258pub struct Executor {
260 ceremony: Ceremony,
261 registry: ActionRegistry,
262 backend_registry: BackendRegistry,
263 output_config: OutputConfig,
264 dry_run: bool,
265 startup: StartupSnapshot,
266 clock: Arc<dyn Clock>,
267}
268
269impl Executor {
270 #[must_use]
276 pub fn new(
277 ceremony: Ceremony,
278 registry: ActionRegistry,
279 backend_registry: BackendRegistry,
280 output_config: OutputConfig,
281 dry_run: bool,
282 startup: StartupSnapshot,
283 ) -> Self {
284 Self {
285 ceremony,
286 registry,
287 backend_registry,
288 output_config,
289 dry_run,
290 startup,
291 clock: Arc::new(SystemClock),
292 }
293 }
294
295 #[must_use]
298 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
299 self.clock = clock;
300 self
301 }
302
303 pub fn run(
316 self,
317 cmd_rx: &Receiver<UiCommand>,
318 event_tx: &Sender<ExecEvent>,
319 mut transcript_sink: Box<dyn TranscriptSink>,
320 ) -> Result<ExecutionSummary, ExecutionError> {
321 validate_parameters(&self.ceremony)?;
322
323 let resolved_params: HashMap<ParamId, serde_json::Value> = self
324 .ceremony
325 .parameters
326 .iter()
327 .map(|(id, p)| (id.clone(), p.value.clone()))
328 .collect();
329
330 let roles: HashMap<RoleId, String> = self
331 .ceremony
332 .roles
333 .iter()
334 .map(|(id, r)| (id.clone(), r.name.clone()))
335 .collect();
336
337 let materials_map: HashMap<MaterialId, String> = self
338 .ceremony
339 .materials
340 .iter()
341 .map(|(id, m)| (id.clone(), m.display_name().to_string()))
342 .collect();
343
344 let ceremony_name = self.ceremony.metadata.name.clone();
345 let clock = Arc::clone(&self.clock);
348 let mut reporter = Reporter::new(
349 event_tx,
350 cmd_rx,
351 transcript_sink.as_mut(),
352 Arc::clone(&clock),
353 );
354
355 let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
356
357 match outcome {
362 Ok(counts) => {
363 let at = clock.now();
364 let completed = StepFact::CeremonyCompleted {};
365 transcript_sink
366 .record(at, &completed)
367 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
368 let _ = event_tx.send(ExecEvent::Fact {
369 at,
370 fact: completed,
371 });
372 let fingerprint = transcript_sink
373 .finalize()
374 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
375 let _ = event_tx.send(ExecEvent::Finalized {
376 fingerprint: fingerprint.as_str().to_string(),
377 });
378 Ok(ExecutionSummary {
379 ceremony_name,
380 steps_completed: counts.completed,
381 transcript_fingerprint: fingerprint,
382 })
383 }
384 Err(err) => {
385 let at = clock.now();
386 let record = err.to_error_record();
387 let failed = StepFact::CeremonyFailed { error: record };
388 let _ = transcript_sink.record(at, &failed);
389 let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
390 if let Ok(fingerprint) = transcript_sink.finalize() {
391 let _ = event_tx.send(ExecEvent::Finalized {
392 fingerprint: fingerprint.as_str().to_string(),
393 });
394 }
395 Err(err)
396 }
397 }
398 }
399
400 #[allow(clippy::too_many_lines)]
401 fn execute_inner(
402 mut self,
403 reporter: &mut Reporter<'_>,
404 resolved_params: HashMap<ParamId, serde_json::Value>,
405 roles: HashMap<RoleId, String>,
406 materials_map: HashMap<MaterialId, String>,
407 ) -> Result<StepCounts, ExecutionError> {
408 let dry_run = self.dry_run;
409
410 reporter.fact(StepFact::CeremonyStarted {
411 name: self.ceremony.metadata.name.clone(),
412 })?;
413
414 let (m, source) = gather_machine_entropy(dry_run)?;
419 reporter.seed_entropy(&m);
420 reporter.fact(StepFact::EntropySeeded {
421 m: base16ct::lower::encode_string(&m),
422 source,
423 derivation: DERIVATION_V1.to_string(),
424 })?;
425
426 reporter.signal(UiSignal::CeremonyOverview {
431 description: self.ceremony.metadata.description.clone(),
432 materials: self
433 .ceremony
434 .materials
435 .iter()
436 .map(|(_, m)| MaterialOverview::from_material(m))
437 .collect(),
438 step_count: self.ceremony.execution_plan.len(),
439 })?;
440
441 reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
446 reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
447
448 if !dry_run {
453 reporter.prompt(&Prompt::Continue {
454 hint: Some("Press Enter to start the ceremony".to_string()),
455 })?;
456 }
457
458 let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
459
460 for (id, material) in self.ceremony.materials.iter() {
462 let artifact = load_material_artifact(id.as_str(), material)?;
463 state = state.with_material(ArtifactId::new(id.as_str()), artifact);
464 let display_name = material.display_name();
465 reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
466 }
467
468 let has_acts = !self.ceremony.acts.is_empty();
469 let mut current_act: Option<ActId> = None;
470 let mut counts = StepCounts::default();
471
472 let plan = std::mem::take(&mut self.ceremony.execution_plan);
475 for step in &plan {
476 if has_acts {
478 let step_act = self
479 .ceremony
480 .sections
481 .get(&step.section)
482 .and_then(|s| s.act.clone());
483 if step_act != current_act {
484 if let Some(act_id) = &step_act
485 && let Some(act) = self.ceremony.acts.get(act_id)
486 {
487 reporter.fact(StepFact::ActStarted {
488 id: act_id.clone(),
489 label: act
490 .name
491 .clone()
492 .unwrap_or_else(|| act_id.as_str().to_string()),
493 })?;
494 }
495 current_act = step_act;
496 }
497 }
498
499 reporter.set_current_step(Some(step.id.clone()));
500 let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
501 let role_name = self
502 .ceremony
503 .roles
504 .get(&role_id)
505 .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
506 reporter.fact(StepFact::StepStarted {
507 id: step.id.clone(),
508 label: step.step_label.clone(),
509 role: role_id,
510 role_name,
511 })?;
512
513 if !step.silent && !dry_run {
520 reporter.prompt(&Prompt::Continue {
521 hint: Some(format!("Press Enter to start step {}", step.step_label)),
522 })?;
523 }
524
525 let handler = self
527 .registry
528 .get(&step.action)
529 .ok_or(ExecutionError::UnknownAction(step.action))?
530 .clone();
531
532 let ctx = state.handler_context();
533 let step_info = step_info_from(step);
534 let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
535 handler.apply_defaults(&mut params, &step_info);
536
537 let result = execute_step_with_retry(
538 handler.as_ref(),
539 step,
540 &step_info,
541 &ctx,
542 ¶ms,
543 reporter,
544 &mut self.backend_registry,
545 )?;
546
547 reporter.fact(StepFact::StepCompleted {
549 id: step.id.clone(),
550 outcome: result.outcome.clone(),
551 })?;
552
553 if let StepOutcome::Completed { .. } = &result.outcome {
554 counts.completed = counts.completed.saturating_add(1);
555 }
556
557 state = merge_step_result(state, result);
558
559 if let Some(artifact_id) = &step.creates
563 && !dry_run
564 {
565 let output_id = OutputId::new(artifact_id.as_str());
566 if self.ceremony.outputs.contains(&output_id) {
567 std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
568 ExecutionError::OutputWriteFailed {
569 name: "artifacts directory".to_string(),
570 reason: e.to_string(),
571 }
572 })?;
573
574 let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
575 ExecutionError::OutputWriteFailed {
576 name: artifact_id.as_str().to_string(),
577 reason: "artifact not produced".to_string(),
578 }
579 })?;
580
581 let (path, hash, _size, _mime_type) =
582 write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
583
584 let recorded_path = match path.strip_prefix(self.output_config.base_dir()) {
589 Ok(relative) => relative.to_path_buf(),
590 Err(_) => path.clone(),
591 };
592
593 reporter.fact(StepFact::ArtifactWritten {
594 step: step.id.clone(),
595 name: artifact_id.as_str().to_string(),
596 path: recorded_path,
597 sha256: hash,
598 })?;
599 }
600 }
601 }
602
603 reporter.set_current_step(None);
604 drop(plan);
605
606 Ok(counts)
607 }
608}
609
610#[derive(Default, Debug, Clone, Copy)]
611struct StepCounts {
612 completed: usize,
613}
614
615fn execute_step_with_retry(
625 handler: &dyn Action,
626 step: &Step,
627 step_info: &StepInfo,
628 ctx: &HandlerContext,
629 params: &serde_json::Value,
630 reporter: &mut Reporter<'_>,
631 backend_registry: &mut BackendRegistry,
632) -> Result<StepResult, ExecutionError> {
633 let mut attempt: u32 = 1;
634 loop {
635 let side_effects_before = reporter.side_effects_emitted();
636
637 let attempt_result = match &step_info.backend {
641 Some(name) => match backend_registry.get_mut(name) {
642 Ok(backend) => handler.execute(step_info, ctx, params, reporter, Some(backend)),
643 Err(e) => Err(ActionError::Backend(e)),
644 },
645 None => handler.execute(step_info, ctx, params, reporter, None),
646 };
647
648 let action_err = match attempt_result {
649 Ok(result) => return Ok(result),
650 Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
653 Err(action_err) => action_err,
654 };
655
656 let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
659 let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
660 let exec_err = action_error_to_execution(action_err, &step.id);
661 reporter.fact(StepFact::StepAttemptFailed {
662 step: step.id.clone(),
663 attempt,
664 error: exec_err.to_error_record(),
665 })?;
666
667 let policy_allows = match step.retry {
668 RetryPolicy::Never => false,
669 RetryPolicy::MaxAttempts(max) => attempt < max,
670 RetryPolicy::Prompt => true,
671 };
672
673 if retriable && policy_allows {
674 let answer = reporter.prompt(&Prompt::Confirm {
678 question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
679 default: Some(false),
680 })?;
681 if matches!(answer, Response::Bool(true)) {
682 attempt = attempt.saturating_add(1);
683 continue;
684 }
685 }
686 return Err(exec_err);
687 }
688}
689
690fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
695 match err {
696 ActionError::Aborted => ExecutionError::Aborted,
697 ActionError::Disconnected => ExecutionError::StepFailed {
698 step: step_id.clone(),
699 reason: "frontend channel disconnected".to_string(),
700 },
701 ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
702 ActionError::Backend(e) => ExecutionError::BackendError(e),
703 ActionError::Failed(reason) => ExecutionError::StepFailed {
704 step: step_id.clone(),
705 reason,
706 },
707 }
708}
709
710impl ExecutionError {
711 fn to_error_record(&self) -> ErrorRecord {
717 let (class, kind) = match self {
718 ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
719 ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
720 ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
721 ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
722 ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
723 ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
724 ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
725 ExecutionError::MaterialLoadFailed { .. } => {
726 (ErrorClass::Integrity, "material_load_failed")
727 }
728 ExecutionError::OutputWriteFailed { .. } => {
729 (ErrorClass::Integrity, "output_write_failed")
730 }
731 ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
732 ExecutionError::BackendError(e) => {
733 let class = if e.retriability().is_retriable() {
734 ErrorClass::Environmental
735 } else {
736 ErrorClass::Integrity
737 };
738 (class, "backend_error")
739 }
740 };
741 ErrorRecord::new(class, kind, self.to_string())
742 }
743}
744
745fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
746 let missing: Vec<_> = ceremony
747 .parameters
748 .iter()
749 .filter(|(_, p)| p.value.is_null())
750 .map(|(id, _)| id.as_str().to_string())
751 .collect();
752 if missing.is_empty() {
753 Ok(())
754 } else {
755 Err(ExecutionError::ValidationFailed(format!(
756 "Parameters missing values: {}",
757 missing.join(", ")
758 )))
759 }
760}
761
762fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
764 let mut artifacts = state.artifacts;
765 for (id, value) in result.artifacts {
766 artifacts.insert(id, value);
767 }
768 ExecutionState {
769 params: state.params,
770 roles: state.roles,
771 materials: state.materials,
772 dry_run: state.dry_run,
773 artifacts,
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use crossbeam_channel::unbounded;
780 use rite_model::ActionType;
781
782 use super::*;
783 use crate::actions::ActionCategory;
784 use crate::transcript_sink::InMemorySink;
785
786 #[test]
787 fn action_error_retriability_delegates_to_backend() {
788 assert_eq!(
789 ActionError::Backend(BackendError::TokenNotPresent).retriability(),
790 Retriability::Transient,
791 );
792 assert_eq!(
793 ActionError::Backend(BackendError::PinBlocked).retriability(),
794 Retriability::Fatal,
795 );
796 }
797
798 #[test]
799 fn handler_level_errors_are_fatal() {
800 assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
801 assert_eq!(
802 ActionError::Disconnected.retriability(),
803 Retriability::Fatal
804 );
805 assert_eq!(
806 ActionError::Failed("check_value mismatch".into()).retriability(),
807 Retriability::Fatal,
808 );
809 }
810
811 struct PingAction;
812
813 impl Action for PingAction {
814 fn metadata(&self) -> ActionMetadata {
815 ActionMetadata {
816 action_type: ActionType::Attest,
817 description: "test action that always succeeds",
818 category: ActionCategory::Verification,
819 }
820 }
821
822 fn execute(
823 &self,
824 _step: &StepInfo,
825 _ctx: &HandlerContext,
826 _params: &serde_json::Value,
827 reporter: &mut Reporter<'_>,
828 _backend: Option<&mut dyn Backend>,
829 ) -> Result<StepResult, ActionError> {
830 reporter.log(Icon::Info, "ping")?;
831 Ok(StepResult::completed("pinged".to_string()))
832 }
833 }
834
835 fn minimal_ceremony() -> Ceremony {
836 let yaml = r#"
837version: "0.2"
838name: "Test"
839roles:
840 participant:
841 person: "Alice"
842sections:
843 main:
844 role: ${role.participant}
845 steps:
846 ping:
847 action: attest
848 silent: true
849 with:
850 statement: "I confirm."
851"#;
852 rite_resolver::resolve(yaml, None)
853 .into_result()
854 .expect("resolve")
855 }
856
857 fn dry_run_output_config() -> OutputConfig {
858 OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
859 }
860
861 #[test]
862 fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
863 let ceremony = minimal_ceremony();
864 let mut registry = ActionRegistry::new();
865 registry.register(Arc::new(PingAction));
866
867 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
868 let (event_tx, event_rx) = unbounded::<ExecEvent>();
869 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
870
871 let executor = Executor::new(
872 ceremony,
873 registry,
874 BackendRegistry::new(),
875 dry_run_output_config(),
876 true,
877 StartupSnapshot::placeholder(),
878 );
879
880 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
881
882 let mut facts = Vec::new();
884 while let Ok(ev) = event_rx.recv() {
885 if let ExecEvent::Fact { fact, .. } = ev {
886 facts.push(fact);
887 }
888 }
889
890 let summary = join.join().expect("executor join").expect("run ok");
891 assert_eq!(summary.steps_completed, 1);
892
893 let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
894 assert_eq!(
895 kinds,
896 vec![
897 "ceremony_started",
898 "entropy_seeded",
899 "step_started",
900 "step_completed",
901 "ceremony_completed",
902 ],
903 );
904
905 drop(cmd_tx);
906 }
907
908 #[test]
909 fn run_stamps_every_fact_from_the_injected_clock() {
910 use crate::test_support::{FixedClock, fixed_test_time};
911
912 let mut registry = ActionRegistry::new();
913 registry.register(Arc::new(PingAction));
914
915 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
916 let (event_tx, event_rx) = unbounded::<ExecEvent>();
917 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
918
919 let fixed = fixed_test_time();
920 let executor = Executor::new(
921 minimal_ceremony(),
922 registry,
923 BackendRegistry::new(),
924 dry_run_output_config(),
925 true,
926 StartupSnapshot::placeholder(),
927 )
928 .with_clock(Arc::new(FixedClock(fixed)));
929
930 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
931
932 let mut times = Vec::new();
933 while let Ok(ev) = event_rx.recv() {
934 if let ExecEvent::Fact { at, .. } = ev {
935 times.push(at);
936 }
937 }
938 join.join().expect("executor join").expect("run ok");
939
940 assert!(!times.is_empty());
943 assert!(times.iter().all(|&t| t == fixed));
944
945 drop(cmd_tx);
946 }
947
948 #[derive(Debug, PartialEq, Eq)]
949 enum PacingTrace {
950 CeremonyStarted,
951 StepStarted(String),
952 ContinuePrompt { step: Option<String> },
953 StepCompleted,
954 CeremonyCompleted,
955 }
956
957 #[test]
962 #[allow(clippy::too_many_lines)]
963 fn pacing_prompts_fire_at_step_start_not_step_end() {
964 let yaml = r#"
965version: "0.2"
966name: "Pacing"
967roles:
968 participant:
969 person: "Alice"
970sections:
971 main:
972 role: ${role.participant}
973 steps:
974 first:
975 action: attest
976 with:
977 statement: "first"
978 second:
979 action: attest
980 with:
981 statement: "second"
982"#;
983 let ceremony = rite_resolver::resolve(yaml, None)
984 .into_result()
985 .expect("resolve");
986 let mut registry = ActionRegistry::new();
987 registry.register(Arc::new(PingAction));
988
989 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
990 let (event_tx, event_rx) = unbounded::<ExecEvent>();
991 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
992
993 let executor = Executor::new(
994 ceremony,
995 registry,
996 BackendRegistry::new(),
997 dry_run_output_config(),
998 false, StartupSnapshot::placeholder(),
1000 );
1001
1002 let frontend = std::thread::spawn({
1006 let cmd_tx = cmd_tx.clone();
1007 move || {
1008 let mut trace = Vec::new();
1009 while let Ok(event) = event_rx.recv() {
1010 match event {
1011 ExecEvent::Fact {
1012 fact: StepFact::CeremonyStarted { .. },
1013 ..
1014 } => {
1015 trace.push(PacingTrace::CeremonyStarted);
1016 }
1017 ExecEvent::Fact {
1018 fact: StepFact::StepStarted { label, .. },
1019 ..
1020 } => {
1021 trace.push(PacingTrace::StepStarted(label));
1022 }
1023 ExecEvent::Fact {
1024 fact: StepFact::StepCompleted { .. },
1025 ..
1026 } => {
1027 trace.push(PacingTrace::StepCompleted);
1028 }
1029 ExecEvent::Fact {
1030 fact: StepFact::CeremonyCompleted { .. },
1031 ..
1032 } => {
1033 trace.push(PacingTrace::CeremonyCompleted);
1034 }
1035 ExecEvent::AwaitPrompt {
1036 prompt_id,
1037 prompt: Prompt::Continue { .. },
1038 step,
1039 ..
1040 } => {
1041 trace.push(PacingTrace::ContinuePrompt {
1042 step: step.map(|s| s.as_str().to_string()),
1043 });
1044 let _ = cmd_tx.send(UiCommand::PromptResponse {
1045 prompt_id,
1046 response: crate::protocol::Response::Acknowledge,
1047 });
1048 }
1049 _ => {}
1050 }
1051 }
1052 trace
1053 }
1054 });
1055
1056 let summary = executor
1057 .run(&cmd_rx, &event_tx, sink)
1058 .expect("ceremony runs");
1059 assert_eq!(summary.steps_completed, 2);
1060 drop(event_tx);
1063 drop(cmd_tx);
1064 let trace = frontend.join().expect("frontend join");
1065
1066 let mut iter = trace.iter();
1072 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1073 assert_eq!(
1074 iter.next(),
1075 Some(&PacingTrace::ContinuePrompt { step: None })
1076 );
1077 let first_step = match iter.next() {
1078 Some(PacingTrace::StepStarted(label)) => label.clone(),
1079 other => panic!("expected first StepStarted, got {other:?}"),
1080 };
1081 assert_eq!(
1082 iter.next(),
1083 Some(&PacingTrace::ContinuePrompt {
1084 step: Some("first".to_string()),
1085 }),
1086 "first step's Continue prompt must fire after StepStarted and before the body",
1087 );
1088 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1089 let second_step = match iter.next() {
1090 Some(PacingTrace::StepStarted(label)) => label.clone(),
1091 other => panic!("expected second StepStarted, got {other:?}"),
1092 };
1093 assert_eq!(
1094 iter.next(),
1095 Some(&PacingTrace::ContinuePrompt {
1096 step: Some("second".to_string()),
1097 }),
1098 );
1099 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1100 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1101 assert_eq!(iter.next(), None, "no further events expected");
1102 assert_eq!(first_step, "1");
1104 assert_eq!(second_step, "2");
1105 }
1106
1107 #[test]
1108 fn aborts_when_action_returns_aborted() {
1109 struct AbortAction;
1110 impl Action for AbortAction {
1111 fn metadata(&self) -> ActionMetadata {
1112 ActionMetadata {
1113 action_type: ActionType::Attest,
1114 description: "aborts immediately",
1115 category: ActionCategory::Verification,
1116 }
1117 }
1118 fn execute(
1119 &self,
1120 _step: &StepInfo,
1121 _ctx: &HandlerContext,
1122 _params: &serde_json::Value,
1123 _reporter: &mut Reporter<'_>,
1124 _backend: Option<&mut dyn Backend>,
1125 ) -> Result<StepResult, ActionError> {
1126 Err(ActionError::Aborted)
1127 }
1128 }
1129
1130 let ceremony = minimal_ceremony();
1131 let mut registry = ActionRegistry::new();
1132 registry.register(Arc::new(AbortAction));
1133
1134 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1135 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1136 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1137
1138 let executor = Executor::new(
1139 ceremony,
1140 registry,
1141 BackendRegistry::new(),
1142 dry_run_output_config(),
1143 true,
1144 StartupSnapshot::placeholder(),
1145 );
1146
1147 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1148
1149 let mut got_failed = false;
1150 while let Ok(ev) = event_rx.recv() {
1151 if let ExecEvent::Fact {
1152 fact: StepFact::CeremonyFailed { error, .. },
1153 ..
1154 } = ev
1155 {
1156 assert_eq!(error.kind, "aborted");
1157 assert_eq!(error.class, ErrorClass::Abort);
1160 assert_eq!(error.message, "Ceremony aborted by the operator");
1161 got_failed = true;
1162 }
1163 }
1164 assert!(got_failed, "expected CeremonyFailed event");
1165
1166 let result = join.join().expect("executor join");
1167 assert!(matches!(result, Err(ExecutionError::Aborted)));
1168 }
1169
1170 enum BeforeFail {
1176 Nothing,
1177 SideEffect,
1179 Prompt,
1182 Deviation,
1185 }
1186
1187 struct FlakyAction {
1190 fails_remaining: std::sync::Mutex<u32>,
1191 error: fn() -> ActionError,
1192 before_fail: BeforeFail,
1193 }
1194
1195 impl FlakyAction {
1196 fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1197 Arc::new(Self {
1198 fails_remaining: std::sync::Mutex::new(fails),
1199 error,
1200 before_fail,
1201 })
1202 }
1203 }
1204
1205 impl Action for FlakyAction {
1206 fn metadata(&self) -> ActionMetadata {
1207 ActionMetadata {
1208 action_type: ActionType::Attest,
1209 description: "flaky test action",
1210 category: ActionCategory::Verification,
1211 }
1212 }
1213
1214 fn execute(
1215 &self,
1216 step: &StepInfo,
1217 _ctx: &HandlerContext,
1218 _params: &serde_json::Value,
1219 reporter: &mut Reporter<'_>,
1220 _backend: Option<&mut dyn Backend>,
1221 ) -> Result<StepResult, ActionError> {
1222 let mut remaining = self.fails_remaining.lock().expect("lock");
1223 match self.before_fail {
1224 BeforeFail::Nothing => {}
1225 BeforeFail::SideEffect => {
1226 reporter.fact(StepFact::BackendOperation {
1227 step: step.id.clone(),
1228 kind: "test_op".to_string(),
1229 inputs: serde_json::Value::Null,
1230 outputs: serde_json::Value::Null,
1231 fingerprint: None,
1232 })?;
1233 }
1234 BeforeFail::Prompt => {
1235 reporter.prompt(&Prompt::Secret {
1236 label: "Enter PIN".to_string(),
1237 })?;
1238 }
1239 BeforeFail::Deviation => {
1240 reporter.fact(StepFact::DeviationRecorded {
1241 step: None,
1242 text: "operator note".to_string(),
1243 })?;
1244 }
1245 }
1246 if *remaining == 0 {
1247 return Ok(StepResult::completed("done".to_string()));
1248 }
1249 *remaining = remaining.saturating_sub(1);
1250 Err((self.error)())
1251 }
1252 }
1253
1254 fn transient() -> ActionError {
1255 ActionError::Backend(BackendError::TokenNotPresent)
1256 }
1257
1258 fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1259 let yaml = format!(
1260 r#"
1261version: "0.2"
1262name: "Retry"
1263roles:
1264 participant:
1265 person: "Alice"
1266sections:
1267 main:
1268 role: ${{role.participant}}
1269 steps:
1270 work:
1271 action: attest
1272 silent: true
1273 {retry_clause}
1274 with:
1275 statement: "x"
1276"#
1277 );
1278 rite_resolver::resolve(&yaml, None)
1279 .into_result()
1280 .expect("resolve")
1281 }
1282
1283 fn drive(
1287 ceremony: Ceremony,
1288 action: Arc<dyn Action>,
1289 confirm_answer: bool,
1290 ) -> (
1291 Result<ExecutionSummary, ExecutionError>,
1292 Vec<StepFact>,
1293 usize,
1294 ) {
1295 drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1296 }
1297
1298 fn drive_with_backends(
1301 ceremony: Ceremony,
1302 action: Arc<dyn Action>,
1303 confirm_answer: bool,
1304 backend_registry: BackendRegistry,
1305 ) -> (
1306 Result<ExecutionSummary, ExecutionError>,
1307 Vec<StepFact>,
1308 usize,
1309 ) {
1310 let mut registry = ActionRegistry::new();
1311 registry.register(action);
1312
1313 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1314 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1315 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1316
1317 let executor = Executor::new(
1318 ceremony,
1319 registry,
1320 backend_registry,
1321 dry_run_output_config(),
1322 true, StartupSnapshot::placeholder(),
1324 );
1325 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1326
1327 let mut facts = Vec::new();
1328 let mut confirms = 0usize;
1329 while let Ok(ev) = event_rx.recv() {
1330 match ev {
1331 ExecEvent::Fact { fact, .. } => facts.push(fact),
1332 ExecEvent::AwaitPrompt {
1333 prompt_id,
1334 prompt: Prompt::Confirm { .. },
1335 ..
1336 } => {
1337 confirms = confirms.saturating_add(1);
1338 cmd_tx
1339 .send(UiCommand::PromptResponse {
1340 prompt_id,
1341 response: Response::Bool(confirm_answer),
1342 })
1343 .expect("send confirm");
1344 }
1345 ExecEvent::AwaitPrompt {
1346 prompt_id,
1347 prompt: Prompt::Secret { .. },
1348 ..
1349 } => {
1350 cmd_tx
1351 .send(UiCommand::PromptResponse {
1352 prompt_id,
1353 response: Response::Secret("123456".to_string().into()),
1354 })
1355 .expect("send secret");
1356 }
1357 _ => {}
1358 }
1359 }
1360 let result = join.join().expect("executor join");
1361 (result, facts, confirms)
1362 }
1363
1364 fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1365 facts
1366 .iter()
1367 .filter_map(|f| match f {
1368 StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1369 _ => None,
1370 })
1371 .collect()
1372 }
1373
1374 #[test]
1375 fn transient_failure_is_retried_then_succeeds() {
1376 let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1377 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1378
1379 let summary = result.expect("run ok");
1380 assert_eq!(summary.steps_completed, 1);
1381 assert_eq!(confirms, 1, "one retry prompt");
1382 assert_eq!(attempt_numbers(&facts), vec![1]);
1383 let StepFact::StepAttemptFailed { error, .. } = facts
1385 .iter()
1386 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1387 .expect("attempt fact")
1388 else {
1389 unreachable!()
1390 };
1391 assert_eq!(error.class, ErrorClass::Environmental);
1392 assert!(
1393 facts
1394 .iter()
1395 .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1396 );
1397 }
1398
1399 #[test]
1400 fn retry_is_blocked_once_a_side_effect_was_emitted() {
1401 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1405 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1406
1407 assert!(result.is_err(), "run fails");
1408 assert_eq!(confirms, 0, "gate blocks the retry prompt");
1409 assert_eq!(attempt_numbers(&facts), vec![1]);
1410 assert!(
1411 facts
1412 .iter()
1413 .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1414 );
1415 }
1416
1417 #[test]
1418 fn prompting_before_a_transient_failure_stays_retriable() {
1419 let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1423 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1424
1425 let summary = result.expect("run ok");
1426 assert_eq!(summary.steps_completed, 1);
1427 assert_eq!(confirms, 1, "the retry prompt fired");
1428 assert_eq!(attempt_numbers(&facts), vec![1]);
1429 let secret_answers = facts
1430 .iter()
1431 .filter(|f| {
1432 matches!(
1433 f,
1434 StepFact::PromptAnswered {
1435 prompt: Prompt::Secret { .. },
1436 ..
1437 }
1438 )
1439 })
1440 .count();
1441 assert_eq!(secret_answers, 2, "each attempt records its own answer");
1442 }
1443
1444 #[test]
1445 fn a_deviation_note_does_not_block_retry() {
1446 let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1447 let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1448
1449 let summary = result.expect("run ok");
1450 assert_eq!(summary.steps_completed, 1);
1451 assert_eq!(confirms, 1, "the retry prompt fired");
1452 }
1453
1454 #[test]
1455 fn backend_acquisition_failure_is_retriable() {
1456 use std::sync::atomic::{AtomicU32, Ordering};
1457
1458 struct NullBackend {
1462 name: String,
1463 }
1464 impl Backend for NullBackend {
1465 fn name(&self) -> &str {
1466 &self.name
1467 }
1468 fn provider(&self) -> &'static str {
1469 "mock"
1470 }
1471 fn fingerprint(&self) -> String {
1472 format!("mock={}", self.name)
1473 }
1474 }
1475
1476 let probes = Arc::new(AtomicU32::new(0));
1477 let probes_in_factory = Arc::clone(&probes);
1478 let mut backends =
1479 crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1480 if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1481 Err(BackendError::HardwareFailure("no device".to_string()))
1482 } else {
1483 Ok(Box::new(NullBackend {
1484 name: "token".to_string(),
1485 }) as Box<dyn Backend>)
1486 }
1487 }));
1488 backends.declare(
1489 "token".to_string(),
1490 rite_sdk::BackendConfig {
1491 provider: "mock".to_string(),
1492 extra: serde_json::json!({}),
1493 },
1494 );
1495
1496 let yaml = r#"
1497version: "0.2"
1498name: "Retry"
1499backends:
1500 token:
1501 provider: mock
1502roles:
1503 participant:
1504 person: "Alice"
1505sections:
1506 main:
1507 role: ${role.participant}
1508 steps:
1509 work:
1510 action: attest
1511 backend: token
1512 silent: true
1513 with:
1514 statement: "x"
1515"#;
1516 let ceremony = rite_resolver::resolve(yaml, None)
1517 .into_result()
1518 .expect("resolve");
1519
1520 let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1521 let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1522
1523 let summary = result.expect("run ok after the operator retried");
1524 assert_eq!(summary.steps_completed, 1);
1525 assert_eq!(confirms, 1, "the retry prompt fired");
1526 assert_eq!(attempt_numbers(&facts), vec![1]);
1527 let StepFact::StepAttemptFailed { error, .. } = facts
1528 .iter()
1529 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1530 .expect("attempt fact")
1531 else {
1532 unreachable!()
1533 };
1534 assert_eq!(error.class, ErrorClass::Environmental);
1535 assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1536 }
1537
1538 #[test]
1539 fn retry_never_fails_immediately() {
1540 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1541 let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1542
1543 assert!(result.is_err());
1544 assert_eq!(confirms, 0, "retry: never never prompts");
1545 assert_eq!(attempt_numbers(&facts), vec![1]);
1546 }
1547
1548 #[test]
1549 fn retry_attempts_caps_total_tries() {
1550 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1551 let (result, facts, confirms) =
1552 drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1553
1554 assert!(result.is_err());
1555 assert_eq!(confirms, 1);
1558 assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1559 }
1560
1561 #[test]
1562 fn declining_the_retry_prompt_terminates() {
1563 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1564 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1565
1566 assert!(result.is_err());
1567 assert_eq!(confirms, 1, "prompted once, operator declined");
1568 assert_eq!(attempt_numbers(&facts), vec![1]);
1569 }
1570
1571 fn fact_kind(fact: &StepFact) -> &'static str {
1572 match fact {
1573 StepFact::CeremonyStarted { .. } => "ceremony_started",
1574 StepFact::ActStarted { .. } => "act_started",
1575 StepFact::StepStarted { .. } => "step_started",
1576 StepFact::PromptAnswered { .. } => "prompt_answered",
1577 StepFact::BackendOperation { .. } => "backend_operation",
1578 StepFact::AttestationRecorded { .. } => "attestation_recorded",
1579 StepFact::ArtifactWritten { .. } => "artifact_written",
1580 StepFact::DeviationRecorded { .. } => "deviation_recorded",
1581 StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
1582 StepFact::StepCompleted { .. } => "step_completed",
1583 StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1584 StepFact::CeremonyFailed { .. } => "ceremony_failed",
1585 StepFact::EntropySeeded { .. } => "entropy_seeded",
1586 StepFact::EntropyContributed { .. } => "entropy_contributed",
1587 StepFact::EntropyDrawn { .. } => "entropy_drawn",
1588 _ => "unknown",
1589 }
1590 }
1591}