1use std::collections::{HashMap, HashSet};
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rite_model::{
39 ActId, ActionType, ArtifactId, Ceremony, Level, MaterialId, MaterialKind, MaterialSource,
40 OutputId, ParamId, RoleId, Sha256Digest, Step, TranscriptHeader,
41};
42use rite_sdk::{Backend, BackendError, Retriability};
43use thiserror::Error;
44
45use crate::actions::ArtifactValue;
46use crate::backend::BackendRegistry;
47use crate::clock::{Clock, SystemClock};
48use crate::entropy::DERIVATION_V1;
49use crate::executor::{
50 ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
51};
52use crate::expressions;
53use crate::output_config::OutputConfig;
54use crate::protocol::{ExecEvent, MaterialOverview, Response, UiCommand, UiSignal};
55use crate::reporter::{Reporter, ReporterError};
56use crate::state::{ExecutionState, HandlerContext, StepResult};
57use crate::step_info::StepInfo;
58use crate::system_info::StartupSnapshot;
59use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
60use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
61
62fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
70 if dry_run {
71 let mut m = [0u8; 32];
72 for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
73 *slot = *byte;
74 }
75 return Ok((m, "dry-run".to_string()));
76 }
77 let m =
78 crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
79 Ok((m, "os".to_string()))
80}
81
82pub const PRODUCER: &str = concat!("rite ", env!("CARGO_PKG_VERSION"));
85
86fn new_run_id() -> Result<String, ExecutionError> {
88 let id: [u8; 16] =
89 crate::os_random::os_random().map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
90 Ok(base16ct::lower::encode_string(&id))
91}
92
93#[derive(Debug, Error)]
95pub enum ActionError {
96 #[error("ceremony aborted by operator")]
99 Aborted,
100 #[error("frontend channel disconnected")]
102 Disconnected,
103 #[error("transcript write failed: {0}")]
105 Transcript(std::io::Error),
106 #[error("backend error: {0}")]
108 Backend(#[from] BackendError),
109 #[error("{0}")]
111 Failed(String),
112}
113
114impl From<ReporterError> for ActionError {
115 fn from(value: ReporterError) -> Self {
116 match value {
117 ReporterError::Aborted => ActionError::Aborted,
118 ReporterError::Disconnected => ActionError::Disconnected,
119 ReporterError::Transcript(e) => ActionError::Transcript(e),
120 ReporterError::NoCurrentStep(_)
121 | ReporterError::DuplicateDraw { .. }
122 | ReporterError::Unseeded
123 | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
124 }
125 }
126}
127
128impl ActionError {
129 #[must_use]
139 pub fn retriability(&self) -> Retriability {
140 match self {
141 ActionError::Backend(e) => e.retriability(),
142 ActionError::Aborted
143 | ActionError::Disconnected
144 | ActionError::Transcript(_)
145 | ActionError::Failed(_) => Retriability::Fatal,
146 }
147 }
148}
149
150pub fn parse_params<P: serde::de::DeserializeOwned>(
159 params: &serde_json::Value,
160) -> Result<P, ActionError> {
161 serde_json::from_value(params.clone())
162 .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
163}
164
165impl From<ReporterError> for ExecutionError {
166 fn from(value: ReporterError) -> Self {
167 match value {
168 ReporterError::Aborted => ExecutionError::Aborted,
169 ReporterError::Disconnected => {
170 ExecutionError::TranscriptError("frontend channel disconnected".to_string())
171 }
172 ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
173 ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
174 ReporterError::DuplicateDraw { .. }
175 | ReporterError::Unseeded
176 | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
177 }
178 }
179}
180
181pub trait Action: Send + Sync {
188 fn action_type(&self) -> ActionType;
190
191 fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
193
194 fn unsupported_params(&self, _params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
209 Vec::new()
210 }
211
212 fn execute(
219 &self,
220 step: &StepInfo,
221 ctx: &HandlerContext,
222 params: &serde_json::Value,
223 reporter: &mut Reporter<'_>,
224 backend: Option<&mut dyn Backend>,
225 ) -> Result<StepResult, ActionError>;
226}
227
228#[derive(Debug, Clone)]
236pub struct StepUnsupportedParam {
237 pub step: rite_model::StepId,
239 pub message: String,
241}
242
243#[derive(Default)]
245pub struct ActionRegistry {
246 actions: HashMap<ActionType, Arc<dyn Action>>,
247}
248
249impl ActionRegistry {
250 #[must_use]
252 pub fn new() -> Self {
253 Self::default()
254 }
255
256 pub fn register(&mut self, action: Arc<dyn Action>) {
258 let action_type = action.action_type();
259 self.actions.insert(action_type, action);
260 }
261
262 #[must_use]
264 pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
265 self.actions.get(action)
266 }
267
268 pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
270 self.actions.keys()
271 }
272
273 #[must_use]
277 pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
278 let mut seen = std::collections::HashSet::new();
279 let mut result = Vec::new();
280 for step in steps {
281 if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
282 result.push(step.action);
283 }
284 }
285 result
286 }
287
288 #[must_use]
297 pub fn unsupported_step_params(&self, steps: &[rite_model::Step]) -> Vec<StepUnsupportedParam> {
298 let mut found = Vec::new();
299 for step in steps {
300 let Some(handler) = self.actions.get(&step.action) else {
301 continue;
302 };
303 let Some(mut params) = rite_model::expression::literal_expr_value(&step.with) else {
304 continue;
305 };
306 let step_info = crate::executor::step_info_from(step);
307 handler.apply_defaults(&mut params, &step_info);
308 found.extend(
309 handler
310 .unsupported_params(¶ms, &step_info)
311 .into_iter()
312 .map(|message| StepUnsupportedParam {
313 step: step.id.clone(),
314 message,
315 }),
316 );
317 }
318 found
319 }
320}
321
322#[derive(Debug)]
324pub struct ExecutionSummary {
325 pub ceremony_name: String,
327 pub steps_completed: usize,
329 pub transcript_fingerprint: TranscriptFingerprint,
331}
332
333pub struct Executor {
335 ceremony: Ceremony,
336 registry: ActionRegistry,
337 backend_registry: BackendRegistry,
338 output_config: OutputConfig,
339 dry_run: bool,
340 startup: StartupSnapshot,
341 clock: Arc<dyn Clock>,
342}
343
344impl Executor {
345 #[must_use]
351 pub fn new(
352 ceremony: Ceremony,
353 registry: ActionRegistry,
354 backend_registry: BackendRegistry,
355 output_config: OutputConfig,
356 dry_run: bool,
357 startup: StartupSnapshot,
358 ) -> Self {
359 Self {
360 ceremony,
361 registry,
362 backend_registry,
363 output_config,
364 dry_run,
365 startup,
366 clock: Arc::new(SystemClock),
367 }
368 }
369
370 #[must_use]
373 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
374 self.clock = clock;
375 self
376 }
377
378 pub fn run(
391 self,
392 cmd_rx: &Receiver<UiCommand>,
393 event_tx: &Sender<ExecEvent>,
394 mut transcript_sink: Box<dyn TranscriptSink>,
395 ) -> Result<ExecutionSummary, ExecutionError> {
396 validate_parameters(&self.ceremony)?;
397
398 let header = TranscriptHeader::new(PRODUCER, &new_run_id()?, self.dry_run);
400 transcript_sink
401 .begin(&header)
402 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
403
404 let resolved_params: HashMap<ParamId, serde_json::Value> = self
405 .ceremony
406 .parameters
407 .iter()
408 .map(|(id, p)| (id.clone(), p.value.clone()))
409 .collect();
410
411 let roles: HashMap<RoleId, String> = self
412 .ceremony
413 .roles
414 .iter()
415 .map(|(id, r)| (id.clone(), r.name.clone()))
416 .collect();
417
418 let materials_map: HashMap<MaterialId, String> = self
419 .ceremony
420 .materials
421 .iter()
422 .map(|(id, m)| (id.clone(), m.display_name().to_string()))
423 .collect();
424
425 let ceremony_name = self.ceremony.metadata.name.clone();
426 let clock = Arc::clone(&self.clock);
429 let mut reporter = Reporter::new(
430 event_tx,
431 cmd_rx,
432 transcript_sink.as_mut(),
433 Arc::clone(&clock),
434 );
435
436 let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
437
438 match outcome {
443 Ok(counts) => {
444 let at = clock.now();
445 let completed = StepFact::CeremonyCompleted {};
446 transcript_sink
447 .record(at, completed.default_level(), &completed)
448 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
449 let _ = event_tx.send(ExecEvent::Fact {
450 at,
451 fact: completed,
452 });
453 let fingerprint = transcript_sink
454 .finalize()
455 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
456 let _ = event_tx.send(ExecEvent::Finalized {
457 fingerprint: fingerprint.as_str().to_string(),
458 });
459 Ok(ExecutionSummary {
460 ceremony_name,
461 steps_completed: counts.completed,
462 transcript_fingerprint: fingerprint,
463 })
464 }
465 Err(err) => {
466 let at = clock.now();
467 let record = err.to_error_record();
468 let failed = StepFact::CeremonyFailed { error: record };
469 let _ = transcript_sink.record(at, failed.default_level(), &failed);
470 let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
471 if let Ok(fingerprint) = transcript_sink.finalize() {
472 let _ = event_tx.send(ExecEvent::Finalized {
473 fingerprint: fingerprint.as_str().to_string(),
474 });
475 }
476 Err(err)
477 }
478 }
479 }
480
481 #[allow(clippy::too_many_lines)]
482 fn execute_inner(
483 mut self,
484 reporter: &mut Reporter<'_>,
485 resolved_params: HashMap<ParamId, serde_json::Value>,
486 roles: HashMap<RoleId, String>,
487 materials_map: HashMap<MaterialId, String>,
488 ) -> Result<StepCounts, ExecutionError> {
489 let dry_run = self.dry_run;
490
491 reporter.fact(StepFact::CeremonyStarted {
492 name: self.ceremony.metadata.name.clone(),
493 template: self.ceremony.source_digest.clone(),
494 })?;
495
496 let (m, source) = gather_machine_entropy(dry_run)?;
501 reporter.seed_entropy(&m);
502 reporter.fact(StepFact::EntropySeeded {
503 m: base16ct::lower::encode_string(&m),
504 source,
505 derivation: DERIVATION_V1.to_string(),
506 })?;
507
508 for (id, role) in self.ceremony.roles.iter() {
512 reporter.fact(StepFact::RoleDeclared {
513 role: id.clone(),
514 name: role.name.clone(),
515 })?;
516 }
517 for (id, role) in self.ceremony.roles.iter() {
518 if let Some(person) = &role.person {
519 reporter.fact(StepFact::RoleAssigned {
520 role: id.clone(),
521 person: person.clone(),
522 })?;
523 }
524 }
525 for (id, parameter) in self.ceremony.parameters.iter() {
526 reporter.fact(StepFact::ParameterBound {
527 name: id.clone(),
528 value: parameter.value.clone(),
529 })?;
530 }
531
532 reporter.signal(UiSignal::CeremonyOverview {
537 description: self.ceremony.metadata.description.clone(),
538 materials: self
539 .ceremony
540 .materials
541 .iter()
542 .map(|(_, m)| MaterialOverview::from_material(m))
543 .collect(),
544 step_count: self.ceremony.execution_plan.len(),
545 })?;
546
547 reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
552 reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
553
554 if !dry_run {
559 reporter.prompt(&Prompt::Continue {
560 hint: Some("Press Enter to start the ceremony".to_string()),
561 })?;
562 }
563
564 let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
565
566 for (id, material) in self.ceremony.materials.iter() {
570 let artifact = load_material_artifact(id.as_str(), material)?;
571 let identifier = match &material.kind {
572 MaterialKind::Physical { identifier, .. } => identifier.clone(),
573 MaterialKind::Digital {
574 source: Some(MaterialSource::Identifier { identifier }),
575 } => Some(identifier.clone()),
576 MaterialKind::Digital { .. } => None,
577 };
578 let digest = match &artifact {
580 ArtifactValue::Bytes(bytes) => Some(Sha256Digest::of(bytes)),
581 _ => None,
582 };
583 reporter.fact(StepFact::MaterialLoaded {
584 name: id.clone(),
585 identifier,
586 })?;
587 if let Some(digest) = digest {
588 reporter.fact(StepFact::MaterialDigest {
589 name: id.clone(),
590 digest,
591 })?;
592 }
593 state = state.with_material(ArtifactId::new(id.as_str()), artifact);
594 }
595
596 let mut backends = RunBackends {
597 registry: &mut self.backend_registry,
598 bound: HashSet::new(),
599 };
600 let has_acts = !self.ceremony.acts.is_empty();
601 let mut current_act: Option<ActId> = None;
602 let mut counts = StepCounts::default();
603
604 let plan = std::mem::take(&mut self.ceremony.execution_plan);
607 for step in &plan {
608 if has_acts {
610 let step_act = self
611 .ceremony
612 .sections
613 .get(&step.section)
614 .and_then(|s| s.act.clone());
615 if step_act != current_act {
616 if let Some(act_id) = &step_act
617 && let Some(act) = self.ceremony.acts.get(act_id)
618 {
619 reporter.fact(StepFact::ActStarted {
620 id: act_id.clone(),
621 label: act
622 .name
623 .clone()
624 .unwrap_or_else(|| act_id.as_str().to_string()),
625 })?;
626 }
627 current_act = step_act;
628 }
629 }
630
631 reporter.set_current_step(Some(step.id.clone()));
632 reporter.set_current_backend(step.backend.clone());
633 reporter.fact(StepFact::StepStarted {
634 id: step.id.clone(),
635 label: step.step_label.clone(),
636 role: step.role.clone().unwrap_or_else(|| RoleId::new("")),
637 })?;
638
639 if !step.silent && !dry_run {
646 reporter.prompt(&Prompt::Continue {
647 hint: Some(format!("Press Enter to start step {}", step.step_label)),
648 })?;
649 }
650
651 let handler = self
653 .registry
654 .get(&step.action)
655 .ok_or(ExecutionError::UnknownAction(step.action))?
656 .clone();
657
658 let ctx = state.handler_context();
659 let step_info = step_info_from(step);
660 let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
661 handler.apply_defaults(&mut params, &step_info);
662
663 let result = execute_step_with_retry(
664 handler.as_ref(),
665 step,
666 &step_info,
667 &ctx,
668 ¶ms,
669 reporter,
670 &mut backends,
671 )?;
672
673 reporter.fact(StepFact::StepCompleted {
675 id: step.id.clone(),
676 outcome: result.outcome.clone(),
677 })?;
678
679 if let StepOutcome::Completed { .. } = &result.outcome {
680 counts.completed = counts.completed.saturating_add(1);
681 }
682
683 state = merge_step_result(state, result);
684
685 if let Some(artifact_id) = &step.creates
689 && !dry_run
690 {
691 let output_id = OutputId::new(artifact_id.as_str());
692 if self.ceremony.outputs.contains(&output_id) {
693 std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
694 ExecutionError::OutputWriteFailed {
695 name: "artifacts directory".to_string(),
696 reason: e.to_string(),
697 }
698 })?;
699
700 let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
701 ExecutionError::OutputWriteFailed {
702 name: artifact_id.as_str().to_string(),
703 reason: "artifact not produced".to_string(),
704 }
705 })?;
706
707 let (path, digest, _size, _mime_type) =
708 write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
709
710 let file = path
714 .file_name()
715 .and_then(|name| name.to_str())
716 .ok_or_else(|| ExecutionError::OutputWriteFailed {
717 name: artifact_id.as_str().to_string(),
718 reason: format!("'{}' has no UTF-8 file name", path.display()),
719 })?
720 .to_string();
721
722 let digest = match artifact_value {
725 ArtifactValue::Secret(_) => None,
726 _ => Some(digest),
727 };
728
729 reporter.fact_at(
730 artifact_level(step.action, artifact_value),
731 StepFact::ArtifactWritten {
732 step: step.id.clone(),
733 name: artifact_id.as_str().to_string(),
734 file,
735 digest,
736 },
737 )?;
738 }
739 }
740 }
741
742 reporter.set_current_step(None);
743 reporter.set_current_backend(None);
744 drop(plan);
745
746 Ok(counts)
747 }
748}
749
750#[derive(Default, Debug, Clone, Copy)]
751struct StepCounts {
752 completed: usize,
753}
754
755fn artifact_level(action: ActionType, value: &ArtifactValue) -> Level {
765 match value {
766 ArtifactValue::Certificate(_) | ArtifactValue::PublicKey(_) => Level::PUBLIC,
767 ArtifactValue::Bytes(_)
768 if matches!(
769 action,
770 ActionType::GenerateCsr | ActionType::SignData | ActionType::PivSign
771 ) =>
772 {
773 Level::PUBLIC
774 }
775 ArtifactValue::Bytes(_)
776 | ArtifactValue::Text(_)
777 | ArtifactValue::WrappedKey(_)
778 | ArtifactValue::EncryptedData(_)
779 | ArtifactValue::BackendKey { .. }
780 | ArtifactValue::Shares(_) => Level::RESTRICTED,
781 ArtifactValue::Secret(_) => Level::CONFIDENTIAL,
782 }
783}
784
785struct RunBackends<'a> {
787 registry: &'a mut BackendRegistry,
788 bound: HashSet<String>,
789}
790
791impl RunBackends<'_> {
792 fn acquire(
796 &mut self,
797 name: &str,
798 reporter: &mut Reporter<'_>,
799 ) -> Result<&mut dyn Backend, ActionError> {
800 let backend = self.registry.get_mut(name)?;
801 if self.bound.insert(name.to_string()) {
802 reporter.fact(StepFact::BackendBound {
803 name: name.to_string(),
804 provider: backend.provider().to_string(),
805 identity: backend.fingerprint(),
806 })?;
807 }
808 Ok(backend)
809 }
810}
811
812fn execute_step_with_retry(
822 handler: &dyn Action,
823 step: &Step,
824 step_info: &StepInfo,
825 ctx: &HandlerContext,
826 params: &serde_json::Value,
827 reporter: &mut Reporter<'_>,
828 backends: &mut RunBackends<'_>,
829) -> Result<StepResult, ExecutionError> {
830 let mut attempt: u32 = 1;
831 loop {
832 let side_effects_before = reporter.side_effects_emitted();
833
834 let attempt_result = match &step_info.backend {
838 Some(name) => backends.acquire(name, reporter).and_then(|backend| {
839 handler.execute(step_info, ctx, params, reporter, Some(backend))
840 }),
841 None => handler.execute(step_info, ctx, params, reporter, None),
842 };
843
844 let action_err = match attempt_result {
845 Ok(result) => return Ok(result),
846 Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
849 Err(action_err) => action_err,
850 };
851
852 let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
855 let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
856 let exec_err = action_error_to_execution(action_err, &step.id);
857 reporter.fact(StepFact::StepAttemptFailed {
858 step: step.id.clone(),
859 attempt,
860 error: exec_err.to_error_record(),
861 })?;
862
863 let policy_allows = match step.retry {
864 RetryPolicy::Never => false,
865 RetryPolicy::MaxAttempts(max) => attempt < max,
866 RetryPolicy::Prompt => true,
867 };
868
869 if retriable && policy_allows {
870 let answer = reporter.prompt(&Prompt::Confirm {
874 question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
875 default: Some(false),
876 })?;
877 if matches!(answer, Response::Bool(true)) {
878 attempt = attempt.saturating_add(1);
879 continue;
880 }
881 }
882 return Err(exec_err);
883 }
884}
885
886fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
891 match err {
892 ActionError::Aborted => ExecutionError::Aborted,
893 ActionError::Disconnected => ExecutionError::StepFailed {
894 step: step_id.clone(),
895 reason: "frontend channel disconnected".to_string(),
896 },
897 ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
898 ActionError::Backend(e) => ExecutionError::BackendError(e),
899 ActionError::Failed(reason) => ExecutionError::StepFailed {
900 step: step_id.clone(),
901 reason,
902 },
903 }
904}
905
906impl ExecutionError {
907 fn to_error_record(&self) -> ErrorRecord {
913 let (class, kind) = match self {
914 ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
915 ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
916 ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
917 ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
918 ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
919 ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
920 ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
921 ExecutionError::MaterialLoadFailed { .. } => {
922 (ErrorClass::Integrity, "material_load_failed")
923 }
924 ExecutionError::OutputWriteFailed { .. } => {
925 (ErrorClass::Integrity, "output_write_failed")
926 }
927 ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
928 ExecutionError::BackendError(e) => {
929 let class = if e.retriability().is_retriable() {
930 ErrorClass::Environmental
931 } else {
932 ErrorClass::Integrity
933 };
934 (class, "backend_error")
935 }
936 };
937 ErrorRecord::new(class, kind, self.to_string())
938 }
939}
940
941fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
942 let missing: Vec<_> = ceremony
943 .parameters
944 .iter()
945 .filter(|(_, p)| p.value.is_null())
946 .map(|(id, _)| id.as_str().to_string())
947 .collect();
948 if missing.is_empty() {
949 Ok(())
950 } else {
951 Err(ExecutionError::ValidationFailed(format!(
952 "Parameters missing values: {}",
953 missing.join(", ")
954 )))
955 }
956}
957
958fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
960 let mut artifacts = state.artifacts;
961 for (id, value) in result.artifacts {
962 artifacts.insert(id, value);
963 }
964 ExecutionState {
965 params: state.params,
966 roles: state.roles,
967 materials: state.materials,
968 dry_run: state.dry_run,
969 artifacts,
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use crossbeam_channel::unbounded;
976 use rite_model::ActionType;
977
978 use super::*;
979 use crate::transcript_sink::InMemorySink;
980
981 #[test]
982 fn action_error_retriability_delegates_to_backend() {
983 assert_eq!(
984 ActionError::Backend(BackendError::TokenNotPresent).retriability(),
985 Retriability::Transient,
986 );
987 assert_eq!(
988 ActionError::Backend(BackendError::PinBlocked).retriability(),
989 Retriability::Fatal,
990 );
991 }
992
993 #[test]
994 fn handler_level_errors_are_fatal() {
995 assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
996 assert_eq!(
997 ActionError::Disconnected.retriability(),
998 Retriability::Fatal
999 );
1000 assert_eq!(
1001 ActionError::Failed("check_value mismatch".into()).retriability(),
1002 Retriability::Fatal,
1003 );
1004 }
1005
1006 struct PingAction;
1007
1008 impl Action for PingAction {
1009 fn action_type(&self) -> ActionType {
1010 ActionType::Attest
1011 }
1012
1013 fn execute(
1014 &self,
1015 _step: &StepInfo,
1016 _ctx: &HandlerContext,
1017 _params: &serde_json::Value,
1018 reporter: &mut Reporter<'_>,
1019 _backend: Option<&mut dyn Backend>,
1020 ) -> Result<StepResult, ActionError> {
1021 reporter.log(crate::protocol::Icon::Info, "ping")?;
1022 Ok(StepResult::completed("pinged".to_string()))
1023 }
1024 }
1025
1026 fn minimal_ceremony() -> Ceremony {
1027 let yaml = r#"
1028version: "0.3"
1029name: "Test"
1030roles:
1031 participant:
1032 person: "Alice"
1033sections:
1034 main:
1035 role: ${role.participant}
1036 steps:
1037 ping:
1038 action: attest
1039 silent: true
1040 with:
1041 statement: "I confirm."
1042"#;
1043 rite_resolver::resolve(yaml, None)
1044 .into_result()
1045 .expect("resolve")
1046 }
1047
1048 struct PickyAction;
1051
1052 impl Action for PickyAction {
1053 fn action_type(&self) -> ActionType {
1054 ActionType::Attest
1055 }
1056
1057 fn unsupported_params(&self, params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
1058 match params.get("statement").and_then(serde_json::Value::as_str) {
1059 Some("puce") => vec!["this build has no puce".to_string()],
1060 _ => Vec::new(),
1061 }
1062 }
1063
1064 fn execute(
1065 &self,
1066 _step: &StepInfo,
1067 _ctx: &HandlerContext,
1068 _params: &serde_json::Value,
1069 _reporter: &mut Reporter<'_>,
1070 _backend: Option<&mut dyn Backend>,
1071 ) -> Result<StepResult, ActionError> {
1072 Ok(StepResult::completed("done".to_string()))
1073 }
1074 }
1075
1076 fn ceremony_with_statement(statement: &str) -> Ceremony {
1077 let yaml = format!(
1078 r#"
1079version: "0.3"
1080name: "Test"
1081roles:
1082 participant:
1083 person: "Alice"
1084sections:
1085 main:
1086 role: ${{role.participant}}
1087 steps:
1088 ping:
1089 action: attest
1090 with:
1091 statement: "{statement}"
1092"#
1093 );
1094 rite_resolver::resolve(&yaml, None)
1095 .into_result()
1096 .expect("resolve")
1097 }
1098
1099 #[test]
1100 fn reports_what_this_build_cannot_carry_out() {
1101 let ceremony = ceremony_with_statement("puce");
1102 let mut registry = ActionRegistry::new();
1103 registry.register(Arc::new(PickyAction));
1104
1105 let found = registry.unsupported_step_params(&ceremony.execution_plan);
1106 let [issue] = found.as_slice() else {
1107 panic!("expected exactly one finding, got {found:?}");
1108 };
1109 assert_eq!(issue.step.as_str(), "ping");
1110 assert_eq!(issue.message, "this build has no puce");
1111 }
1112
1113 #[test]
1114 fn accepts_a_block_the_handler_is_happy_with() {
1115 let ceremony = ceremony_with_statement("I confirm.");
1116 let mut registry = ActionRegistry::new();
1117 registry.register(Arc::new(PickyAction));
1118
1119 assert!(
1120 registry
1121 .unsupported_step_params(&ceremony.execution_plan)
1122 .is_empty()
1123 );
1124 }
1125
1126 #[test]
1127 fn skips_an_action_this_build_has_no_handler_for() {
1128 let ceremony = ceremony_with_statement("puce");
1131 let registry = ActionRegistry::new();
1132
1133 assert!(
1134 registry
1135 .unsupported_step_params(&ceremony.execution_plan)
1136 .is_empty()
1137 );
1138 }
1139
1140 fn dry_run_output_config() -> OutputConfig {
1141 OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
1142 }
1143
1144 #[test]
1145 fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
1146 let ceremony = minimal_ceremony();
1147 let mut registry = ActionRegistry::new();
1148 registry.register(Arc::new(PingAction));
1149
1150 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1151 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1152 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1153
1154 let executor = Executor::new(
1155 ceremony,
1156 registry,
1157 BackendRegistry::new(),
1158 dry_run_output_config(),
1159 true,
1160 StartupSnapshot::placeholder(),
1161 );
1162
1163 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1164
1165 let mut facts = Vec::new();
1167 while let Ok(ev) = event_rx.recv() {
1168 if let ExecEvent::Fact { fact, .. } = ev {
1169 facts.push(fact);
1170 }
1171 }
1172
1173 let summary = join.join().expect("executor join").expect("run ok");
1174 assert_eq!(summary.steps_completed, 1);
1175
1176 let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
1177 assert_eq!(
1178 kinds,
1179 vec![
1180 "ceremony_started",
1181 "entropy_seeded",
1182 "role_declared",
1183 "role_assigned",
1184 "step_started",
1185 "step_completed",
1186 "ceremony_completed",
1187 ],
1188 );
1189
1190 drop(cmd_tx);
1191 }
1192
1193 struct OpensContent;
1196
1197 impl Action for OpensContent {
1198 fn action_type(&self) -> ActionType {
1199 ActionType::Attest
1200 }
1201
1202 fn execute(
1203 &self,
1204 step: &StepInfo,
1205 _ctx: &HandlerContext,
1206 _params: &serde_json::Value,
1207 _reporter: &mut Reporter<'_>,
1208 _backend: Option<&mut dyn Backend>,
1209 ) -> Result<StepResult, ActionError> {
1210 let produces = step.produces.clone().expect("step creates");
1211 Ok(StepResult::completed_with_artifact(
1212 "opened",
1213 produces,
1214 crate::actions::ArtifactValue::Secret(secrecy::SecretBox::new(Box::new(
1215 b"the recovery phrase".to_vec(),
1216 ))),
1217 ))
1218 }
1219 }
1220
1221 fn ceremony_writing_opened_content() -> Ceremony {
1223 let yaml = r#"
1224version: "0.3"
1225name: "Test"
1226roles:
1227 participant:
1228 person: "Alice"
1229output:
1230 opened:
1231 type: document
1232sections:
1233 main:
1234 role: ${role.participant}
1235 steps:
1236 open:
1237 action: attest
1238 silent: true
1239 with:
1240 statement: "I confirm."
1241 creates: opened
1242"#;
1243 rite_resolver::resolve(yaml, None)
1244 .into_result()
1245 .expect("resolve")
1246 }
1247
1248 fn run_acknowledging_prompts(
1251 ceremony: Ceremony,
1252 output_config: OutputConfig,
1253 ) -> (Result<ExecutionSummary, ExecutionError>, Vec<StepFact>) {
1254 let mut registry = ActionRegistry::new();
1255 registry.register(Arc::new(OpensContent));
1256
1257 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1258 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1259 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1260
1261 let executor = Executor::new(
1262 ceremony,
1263 registry,
1264 BackendRegistry::new(),
1265 output_config,
1266 false,
1267 StartupSnapshot::placeholder(),
1268 );
1269
1270 let frontend = std::thread::spawn({
1271 let cmd_tx = cmd_tx.clone();
1272 move || {
1273 let mut facts = Vec::new();
1274 while let Ok(event) = event_rx.recv() {
1275 match event {
1276 ExecEvent::Fact { fact, .. } => facts.push(fact),
1277 ExecEvent::AwaitPrompt { prompt_id, .. } => {
1278 let _ = cmd_tx.send(UiCommand::PromptResponse {
1279 prompt_id,
1280 response: crate::protocol::Response::Acknowledge,
1281 });
1282 }
1283 _ => {}
1284 }
1285 }
1286 facts
1287 }
1288 });
1289
1290 let result = executor.run(&cmd_rx, &event_tx, sink);
1291 drop(event_tx);
1292 drop(cmd_tx);
1293 let facts = frontend.join().expect("frontend join");
1294 (result, facts)
1295 }
1296
1297 #[test]
1298 fn opened_content_declared_as_an_output_is_written() {
1299 let dir = tempfile::tempdir().expect("tempdir");
1300 let config = OutputConfig::new(dir.path().to_path_buf());
1301 let (result, facts) = run_acknowledging_prompts(ceremony_writing_opened_content(), config);
1302
1303 let summary = result.expect("ceremony runs");
1304 assert_eq!(summary.steps_completed, 1);
1305 let written = facts.iter().find_map(|f| match f {
1306 StepFact::ArtifactWritten {
1307 name, file, digest, ..
1308 } => Some((name.clone(), file.clone(), digest.clone())),
1309 _ => None,
1310 });
1311 let (name, file, digest) = written.expect("the artifact is written");
1312 assert_eq!(name, "opened");
1313 assert_eq!(digest, None);
1315 assert_eq!(
1316 std::fs::read(dir.path().join("artifacts").join(file)).expect("read artifact"),
1317 b"the recovery phrase"
1318 );
1319 }
1320
1321 #[test]
1322 fn run_stamps_every_fact_from_the_injected_clock() {
1323 use crate::test_support::{FixedClock, fixed_test_time};
1324
1325 let mut registry = ActionRegistry::new();
1326 registry.register(Arc::new(PingAction));
1327
1328 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1329 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1330 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1331
1332 let fixed = fixed_test_time();
1333 let executor = Executor::new(
1334 minimal_ceremony(),
1335 registry,
1336 BackendRegistry::new(),
1337 dry_run_output_config(),
1338 true,
1339 StartupSnapshot::placeholder(),
1340 )
1341 .with_clock(Arc::new(FixedClock(fixed)));
1342
1343 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1344
1345 let mut times = Vec::new();
1346 while let Ok(ev) = event_rx.recv() {
1347 if let ExecEvent::Fact { at, .. } = ev {
1348 times.push(at);
1349 }
1350 }
1351 join.join().expect("executor join").expect("run ok");
1352
1353 assert!(!times.is_empty());
1356 assert!(times.iter().all(|&t| t == fixed));
1357
1358 drop(cmd_tx);
1359 }
1360
1361 #[derive(Debug, PartialEq, Eq)]
1362 enum PacingTrace {
1363 CeremonyStarted,
1364 StepStarted(String),
1365 ContinuePrompt { step: Option<String> },
1366 StepCompleted,
1367 CeremonyCompleted,
1368 }
1369
1370 #[test]
1375 #[allow(clippy::too_many_lines)]
1376 fn pacing_prompts_fire_at_step_start_not_step_end() {
1377 let yaml = r#"
1378version: "0.3"
1379name: "Pacing"
1380roles:
1381 participant:
1382 person: "Alice"
1383sections:
1384 main:
1385 role: ${role.participant}
1386 steps:
1387 first:
1388 action: attest
1389 with:
1390 statement: "first"
1391 second:
1392 action: attest
1393 with:
1394 statement: "second"
1395"#;
1396 let ceremony = rite_resolver::resolve(yaml, None)
1397 .into_result()
1398 .expect("resolve");
1399 let mut registry = ActionRegistry::new();
1400 registry.register(Arc::new(PingAction));
1401
1402 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1403 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1404 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1405
1406 let executor = Executor::new(
1407 ceremony,
1408 registry,
1409 BackendRegistry::new(),
1410 dry_run_output_config(),
1411 false, StartupSnapshot::placeholder(),
1413 );
1414
1415 let frontend = std::thread::spawn({
1419 let cmd_tx = cmd_tx.clone();
1420 move || {
1421 let mut trace = Vec::new();
1422 while let Ok(event) = event_rx.recv() {
1423 match event {
1424 ExecEvent::Fact {
1425 fact: StepFact::CeremonyStarted { .. },
1426 ..
1427 } => {
1428 trace.push(PacingTrace::CeremonyStarted);
1429 }
1430 ExecEvent::Fact {
1431 fact: StepFact::StepStarted { label, .. },
1432 ..
1433 } => {
1434 trace.push(PacingTrace::StepStarted(label));
1435 }
1436 ExecEvent::Fact {
1437 fact: StepFact::StepCompleted { .. },
1438 ..
1439 } => {
1440 trace.push(PacingTrace::StepCompleted);
1441 }
1442 ExecEvent::Fact {
1443 fact: StepFact::CeremonyCompleted { .. },
1444 ..
1445 } => {
1446 trace.push(PacingTrace::CeremonyCompleted);
1447 }
1448 ExecEvent::AwaitPrompt {
1449 prompt_id,
1450 prompt: Prompt::Continue { .. },
1451 step,
1452 ..
1453 } => {
1454 trace.push(PacingTrace::ContinuePrompt {
1455 step: step.map(|s| s.as_str().to_string()),
1456 });
1457 let _ = cmd_tx.send(UiCommand::PromptResponse {
1458 prompt_id,
1459 response: crate::protocol::Response::Acknowledge,
1460 });
1461 }
1462 _ => {}
1463 }
1464 }
1465 trace
1466 }
1467 });
1468
1469 let summary = executor
1470 .run(&cmd_rx, &event_tx, sink)
1471 .expect("ceremony runs");
1472 assert_eq!(summary.steps_completed, 2);
1473 drop(event_tx);
1476 drop(cmd_tx);
1477 let trace = frontend.join().expect("frontend join");
1478
1479 let mut iter = trace.iter();
1485 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1486 assert_eq!(
1487 iter.next(),
1488 Some(&PacingTrace::ContinuePrompt { step: None })
1489 );
1490 let first_step = match iter.next() {
1491 Some(PacingTrace::StepStarted(label)) => label.clone(),
1492 other => panic!("expected first StepStarted, got {other:?}"),
1493 };
1494 assert_eq!(
1495 iter.next(),
1496 Some(&PacingTrace::ContinuePrompt {
1497 step: Some("first".to_string()),
1498 }),
1499 "first step's Continue prompt must fire after StepStarted and before the body",
1500 );
1501 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1502 let second_step = match iter.next() {
1503 Some(PacingTrace::StepStarted(label)) => label.clone(),
1504 other => panic!("expected second StepStarted, got {other:?}"),
1505 };
1506 assert_eq!(
1507 iter.next(),
1508 Some(&PacingTrace::ContinuePrompt {
1509 step: Some("second".to_string()),
1510 }),
1511 );
1512 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1513 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1514 assert_eq!(iter.next(), None, "no further events expected");
1515 assert_eq!(first_step, "1");
1517 assert_eq!(second_step, "2");
1518 }
1519
1520 #[test]
1521 fn aborts_when_action_returns_aborted() {
1522 struct AbortAction;
1523 impl Action for AbortAction {
1524 fn action_type(&self) -> ActionType {
1525 ActionType::Attest
1526 }
1527 fn execute(
1528 &self,
1529 _step: &StepInfo,
1530 _ctx: &HandlerContext,
1531 _params: &serde_json::Value,
1532 _reporter: &mut Reporter<'_>,
1533 _backend: Option<&mut dyn Backend>,
1534 ) -> Result<StepResult, ActionError> {
1535 Err(ActionError::Aborted)
1536 }
1537 }
1538
1539 let ceremony = minimal_ceremony();
1540 let mut registry = ActionRegistry::new();
1541 registry.register(Arc::new(AbortAction));
1542
1543 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1544 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1545 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1546
1547 let executor = Executor::new(
1548 ceremony,
1549 registry,
1550 BackendRegistry::new(),
1551 dry_run_output_config(),
1552 true,
1553 StartupSnapshot::placeholder(),
1554 );
1555
1556 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1557
1558 let mut got_failed = false;
1559 while let Ok(ev) = event_rx.recv() {
1560 if let ExecEvent::Fact {
1561 fact: StepFact::CeremonyFailed { error, .. },
1562 ..
1563 } = ev
1564 {
1565 assert_eq!(error.kind, "aborted");
1566 assert_eq!(error.class, ErrorClass::Abort);
1569 assert_eq!(error.message, "Ceremony aborted by the operator");
1570 got_failed = true;
1571 }
1572 }
1573 assert!(got_failed, "expected CeremonyFailed event");
1574
1575 let result = join.join().expect("executor join");
1576 assert!(matches!(result, Err(ExecutionError::Aborted)));
1577 }
1578
1579 enum BeforeFail {
1585 Nothing,
1586 SideEffect,
1588 Prompt,
1591 Deviation,
1594 }
1595
1596 struct FlakyAction {
1599 fails_remaining: std::sync::Mutex<u32>,
1600 error: fn() -> ActionError,
1601 before_fail: BeforeFail,
1602 }
1603
1604 impl FlakyAction {
1605 fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1606 Arc::new(Self {
1607 fails_remaining: std::sync::Mutex::new(fails),
1608 error,
1609 before_fail,
1610 })
1611 }
1612 }
1613
1614 impl Action for FlakyAction {
1615 fn action_type(&self) -> ActionType {
1616 ActionType::Attest
1617 }
1618
1619 fn execute(
1620 &self,
1621 _step: &StepInfo,
1622 _ctx: &HandlerContext,
1623 _params: &serde_json::Value,
1624 reporter: &mut Reporter<'_>,
1625 _backend: Option<&mut dyn Backend>,
1626 ) -> Result<StepResult, ActionError> {
1627 let mut remaining = self.fails_remaining.lock().expect("lock");
1628 match self.before_fail {
1629 BeforeFail::Nothing => {}
1630 BeforeFail::SideEffect => {
1631 reporter.backend_operation(
1632 "test_op",
1633 serde_json::Value::Null,
1634 serde_json::Value::Null,
1635 None,
1636 )?;
1637 }
1638 BeforeFail::Prompt => {
1639 reporter.prompt(&Prompt::Secret {
1640 label: "Enter PIN".to_string(),
1641 validator: rite_model::ValidatorSpec::NonEmpty,
1642 })?;
1643 }
1644 BeforeFail::Deviation => {
1645 reporter.fact(StepFact::DeviationRecorded {
1646 step: None,
1647 text: "operator note".to_string(),
1648 })?;
1649 }
1650 }
1651 if *remaining == 0 {
1652 return Ok(StepResult::completed("done".to_string()));
1653 }
1654 *remaining = remaining.saturating_sub(1);
1655 Err((self.error)())
1656 }
1657 }
1658
1659 fn transient() -> ActionError {
1660 ActionError::Backend(BackendError::TokenNotPresent)
1661 }
1662
1663 fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1664 let yaml = format!(
1665 r#"
1666version: "0.3"
1667name: "Retry"
1668roles:
1669 participant:
1670 person: "Alice"
1671sections:
1672 main:
1673 role: ${{role.participant}}
1674 steps:
1675 work:
1676 action: attest
1677 silent: true
1678 {retry_clause}
1679 with:
1680 statement: "x"
1681"#
1682 );
1683 rite_resolver::resolve(&yaml, None)
1684 .into_result()
1685 .expect("resolve")
1686 }
1687
1688 fn drive(
1692 ceremony: Ceremony,
1693 action: Arc<dyn Action>,
1694 confirm_answer: bool,
1695 ) -> (
1696 Result<ExecutionSummary, ExecutionError>,
1697 Vec<StepFact>,
1698 usize,
1699 ) {
1700 drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1701 }
1702
1703 fn drive_with_backends(
1706 ceremony: Ceremony,
1707 action: Arc<dyn Action>,
1708 confirm_answer: bool,
1709 backend_registry: BackendRegistry,
1710 ) -> (
1711 Result<ExecutionSummary, ExecutionError>,
1712 Vec<StepFact>,
1713 usize,
1714 ) {
1715 let mut registry = ActionRegistry::new();
1716 registry.register(action);
1717
1718 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1719 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1720 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1721
1722 let executor = Executor::new(
1723 ceremony,
1724 registry,
1725 backend_registry,
1726 dry_run_output_config(),
1727 true, StartupSnapshot::placeholder(),
1729 );
1730 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1731
1732 let mut facts = Vec::new();
1733 let mut confirms = 0usize;
1734 while let Ok(ev) = event_rx.recv() {
1735 match ev {
1736 ExecEvent::Fact { fact, .. } => facts.push(fact),
1737 ExecEvent::AwaitPrompt {
1738 prompt_id,
1739 prompt: Prompt::Confirm { .. },
1740 ..
1741 } => {
1742 confirms = confirms.saturating_add(1);
1743 cmd_tx
1744 .send(UiCommand::PromptResponse {
1745 prompt_id,
1746 response: Response::Bool(confirm_answer),
1747 })
1748 .expect("send confirm");
1749 }
1750 ExecEvent::AwaitPrompt {
1751 prompt_id,
1752 prompt: Prompt::Secret { .. },
1753 ..
1754 } => {
1755 cmd_tx
1756 .send(UiCommand::PromptResponse {
1757 prompt_id,
1758 response: Response::Secret("123456".to_string().into()),
1759 })
1760 .expect("send secret");
1761 }
1762 _ => {}
1763 }
1764 }
1765 let result = join.join().expect("executor join");
1766 (result, facts, confirms)
1767 }
1768
1769 fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1770 facts
1771 .iter()
1772 .filter_map(|f| match f {
1773 StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1774 _ => None,
1775 })
1776 .collect()
1777 }
1778
1779 #[test]
1780 fn transient_failure_is_retried_then_succeeds() {
1781 let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1782 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1783
1784 let summary = result.expect("run ok");
1785 assert_eq!(summary.steps_completed, 1);
1786 assert_eq!(confirms, 1, "one retry prompt");
1787 assert_eq!(attempt_numbers(&facts), vec![1]);
1788 let StepFact::StepAttemptFailed { error, .. } = facts
1790 .iter()
1791 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1792 .expect("attempt fact")
1793 else {
1794 unreachable!()
1795 };
1796 assert_eq!(error.class, ErrorClass::Environmental);
1797 assert!(
1798 facts
1799 .iter()
1800 .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1801 );
1802 }
1803
1804 #[test]
1805 fn retry_is_blocked_once_a_side_effect_was_emitted() {
1806 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1810 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1811
1812 assert!(result.is_err(), "run fails");
1813 assert_eq!(confirms, 0, "gate blocks the retry prompt");
1814 assert_eq!(attempt_numbers(&facts), vec![1]);
1815 assert!(
1816 facts
1817 .iter()
1818 .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1819 );
1820 }
1821
1822 #[test]
1823 fn prompting_before_a_transient_failure_stays_retriable() {
1824 let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1828 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1829
1830 let summary = result.expect("run ok");
1831 assert_eq!(summary.steps_completed, 1);
1832 assert_eq!(confirms, 1, "the retry prompt fired");
1833 assert_eq!(attempt_numbers(&facts), vec![1]);
1834 let secret_answers = facts
1835 .iter()
1836 .filter(|f| {
1837 matches!(
1838 f,
1839 StepFact::PromptAnswered {
1840 prompt: Prompt::Secret { .. },
1841 ..
1842 }
1843 )
1844 })
1845 .count();
1846 assert_eq!(secret_answers, 2, "each attempt records its own answer");
1847 }
1848
1849 #[test]
1850 fn a_deviation_note_does_not_block_retry() {
1851 let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1852 let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1853
1854 let summary = result.expect("run ok");
1855 assert_eq!(summary.steps_completed, 1);
1856 assert_eq!(confirms, 1, "the retry prompt fired");
1857 }
1858
1859 #[test]
1860 fn backend_acquisition_failure_is_retriable() {
1861 use std::sync::atomic::{AtomicU32, Ordering};
1862
1863 struct NullBackend {
1867 name: String,
1868 }
1869 impl Backend for NullBackend {
1870 fn name(&self) -> &str {
1871 &self.name
1872 }
1873 fn provider(&self) -> &'static str {
1874 "mock"
1875 }
1876 fn fingerprint(&self) -> String {
1877 format!("mock={}", self.name)
1878 }
1879 }
1880
1881 let probes = Arc::new(AtomicU32::new(0));
1882 let probes_in_factory = Arc::clone(&probes);
1883 let mut backends =
1884 crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1885 if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1886 Err(BackendError::HardwareFailure("no device".to_string()))
1887 } else {
1888 Ok(Box::new(NullBackend {
1889 name: "token".to_string(),
1890 }) as Box<dyn Backend>)
1891 }
1892 }));
1893 backends.declare(
1894 "token".to_string(),
1895 rite_sdk::BackendConfig {
1896 provider: "mock".to_string(),
1897 extra: serde_json::json!({}),
1898 },
1899 );
1900
1901 let yaml = r#"
1902version: "0.3"
1903name: "Retry"
1904backends:
1905 token:
1906 provider: mock
1907roles:
1908 participant:
1909 person: "Alice"
1910sections:
1911 main:
1912 role: ${role.participant}
1913 steps:
1914 work:
1915 action: attest
1916 backend: token
1917 silent: true
1918 with:
1919 statement: "x"
1920"#;
1921 let ceremony = rite_resolver::resolve(yaml, None)
1922 .into_result()
1923 .expect("resolve");
1924
1925 let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1926 let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1927
1928 let summary = result.expect("run ok after the operator retried");
1929 assert_eq!(summary.steps_completed, 1);
1930 assert_eq!(confirms, 1, "the retry prompt fired");
1931 assert_eq!(attempt_numbers(&facts), vec![1]);
1932 let StepFact::StepAttemptFailed { error, .. } = facts
1933 .iter()
1934 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1935 .expect("attempt fact")
1936 else {
1937 unreachable!()
1938 };
1939 assert_eq!(error.class, ErrorClass::Environmental);
1940 assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1941 }
1942
1943 #[test]
1944 fn retry_never_fails_immediately() {
1945 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1946 let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1947
1948 assert!(result.is_err());
1949 assert_eq!(confirms, 0, "retry: never never prompts");
1950 assert_eq!(attempt_numbers(&facts), vec![1]);
1951 }
1952
1953 #[test]
1954 fn retry_attempts_caps_total_tries() {
1955 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1956 let (result, facts, confirms) =
1957 drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1958
1959 assert!(result.is_err());
1960 assert_eq!(confirms, 1);
1963 assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1964 }
1965
1966 #[test]
1967 fn declining_the_retry_prompt_terminates() {
1968 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1969 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1970
1971 assert!(result.is_err());
1972 assert_eq!(confirms, 1, "prompted once, operator declined");
1973 assert_eq!(attempt_numbers(&facts), vec![1]);
1974 }
1975
1976 #[test]
1977 fn artifact_levels_follow_what_the_artifact_is() {
1978 use secrecy::SecretBox;
1979
1980 let bytes = || ArtifactValue::Bytes(b"x".to_vec());
1981 assert_eq!(
1982 artifact_level(ActionType::SignData, &bytes()),
1983 Level::PUBLIC
1984 );
1985 assert_eq!(
1986 artifact_level(ActionType::GenerateCsr, &bytes()),
1987 Level::PUBLIC
1988 );
1989 assert_eq!(
1991 artifact_level(ActionType::DecryptData, &bytes()),
1992 Level::RESTRICTED
1993 );
1994 assert_eq!(
1995 artifact_level(
1996 ActionType::DecryptData,
1997 &ArtifactValue::Secret(SecretBox::new(Box::new(b"x".to_vec())))
1998 ),
1999 Level::CONFIDENTIAL
2000 );
2001 }
2002
2003 fn fact_kind(fact: &StepFact) -> &'static str {
2004 match fact {
2005 StepFact::CeremonyStarted { .. } => "ceremony_started",
2006 StepFact::ActStarted { .. } => "act_started",
2007 StepFact::StepStarted { .. } => "step_started",
2008 StepFact::PromptAnswered { .. } => "prompt_answered",
2009 StepFact::BackendOperation { .. } => "backend_operation",
2010 StepFact::AttestationRecorded { .. } => "attestation_recorded",
2011 StepFact::ArtifactWritten { .. } => "artifact_written",
2012 StepFact::DeviationRecorded { .. } => "deviation_recorded",
2013 StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
2014 StepFact::StepCompleted { .. } => "step_completed",
2015 StepFact::CeremonyCompleted { .. } => "ceremony_completed",
2016 StepFact::CeremonyFailed { .. } => "ceremony_failed",
2017 StepFact::EntropySeeded { .. } => "entropy_seeded",
2018 StepFact::EntropyContributed { .. } => "entropy_contributed",
2019 StepFact::EntropyDrawn { .. } => "entropy_drawn",
2020 StepFact::RoleDeclared { .. } => "role_declared",
2021 StepFact::RoleAssigned { .. } => "role_assigned",
2022 StepFact::ParameterBound { .. } => "parameter_bound",
2023 StepFact::MaterialLoaded { .. } => "material_loaded",
2024 StepFact::MaterialDigest { .. } => "material_digest",
2025 StepFact::BackendBound { .. } => "backend_bound",
2026 StepFact::MachineInfoRecorded { .. } => "machine_info_recorded",
2027 _ => "unknown",
2028 }
2029 }
2030}