use std::sync::Arc;
use crossbeam_channel::{Receiver, Sender, TryRecvError};
use rite_model::{ErrorClass, ErrorRecord, Prompt, ResponseRecord, StepFact, StepId};
use secrecy::ExposeSecret;
use thiserror::Error;
use crate::clock::Clock;
use crate::entropy::CeremonyRandom;
use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
use crate::transcript::sha256_hex;
use crate::transcript_sink::TranscriptSink;
#[derive(Debug, Error)]
pub enum ReporterError {
#[error("ceremony aborted by operator")]
Aborted,
#[error("frontend channel disconnected")]
Disconnected,
#[error("transcript write failed: {0}")]
Transcript(#[from] std::io::Error),
#[error("internal: no current step set for {0}")]
NoCurrentStep(&'static str),
#[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")]
DuplicateDraw {
step: StepId,
purpose: String,
},
#[error("internal: entropy source drawn before it was seeded")]
Unseeded,
#[error("entropy draw of {len} bytes exceeds the {max}-byte HKDF-Expand limit")]
DrawTooLong {
len: usize,
max: usize,
},
}
impl ReporterError {
#[must_use]
pub fn to_error_record(&self) -> ErrorRecord {
let (class, kind) = match self {
ReporterError::Aborted => (ErrorClass::Abort, "aborted"),
ReporterError::Disconnected => (ErrorClass::Integrity, "frontend_disconnected"),
ReporterError::Transcript(_) => (ErrorClass::Integrity, "transcript_io"),
ReporterError::NoCurrentStep(_) => (ErrorClass::Integrity, "internal_no_current_step"),
ReporterError::DuplicateDraw { .. } => {
(ErrorClass::Integrity, "duplicate_entropy_draw")
}
ReporterError::Unseeded => (ErrorClass::Integrity, "internal_entropy_unseeded"),
ReporterError::DrawTooLong { .. } => (ErrorClass::Integrity, "entropy_draw_too_long"),
};
ErrorRecord::new(class, kind, self.to_string())
}
}
pub struct Reporter<'a> {
event_tx: &'a Sender<ExecEvent>,
cmd_rx: &'a Receiver<UiCommand>,
transcript: &'a mut dyn TranscriptSink,
next_prompt_id: u64,
current_step: Option<StepId>,
random: Option<CeremonyRandom>,
clock: Arc<dyn Clock>,
facts_emitted: u64,
}
impl<'a> Reporter<'a> {
pub fn new(
event_tx: &'a Sender<ExecEvent>,
cmd_rx: &'a Receiver<UiCommand>,
transcript: &'a mut dyn TranscriptSink,
clock: Arc<dyn Clock>,
) -> Self {
Self {
event_tx,
cmd_rx,
transcript,
next_prompt_id: 0,
current_step: None,
random: None,
clock,
facts_emitted: 0,
}
}
#[must_use]
pub fn facts_emitted(&self) -> u64 {
self.facts_emitted
}
pub fn seed_entropy(&mut self, m: &[u8]) {
self.random = Some(CeremonyRandom::from_machine_seed(m));
}
pub fn set_current_step(&mut self, step: Option<StepId>) {
self.current_step = step;
}
#[must_use]
pub fn current_step(&self) -> Option<&StepId> {
self.current_step.as_ref()
}
pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
let at = self.clock.now();
self.transcript.record(at, &fact)?;
self.facts_emitted = self.facts_emitted.saturating_add(1);
self.event_tx
.send(ExecEvent::Fact { at, fact })
.map_err(|_| ReporterError::Disconnected)?;
Ok(())
}
pub fn draw(&mut self, purpose: &str, len: usize) -> Result<Vec<u8>, ReporterError> {
if len > crate::entropy::MAX_DRAW_LEN {
return Err(ReporterError::DrawTooLong {
len,
max: crate::entropy::MAX_DRAW_LEN,
});
}
let step = self
.current_step
.clone()
.ok_or(ReporterError::NoCurrentStep("draw"))?;
let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
let drawn =
random
.draw(&step, purpose, len)
.ok_or_else(|| ReporterError::DuplicateDraw {
step: step.clone(),
purpose: purpose.to_string(),
})?;
self.fact(StepFact::EntropyDrawn {
step,
path: drawn.path,
value: base16ct::lower::encode_string(&drawn.value),
})?;
Ok(drawn.value)
}
pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> {
let step = self
.current_step
.clone()
.ok_or(ReporterError::NoCurrentStep("fold_entropy"))?;
let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
random.fold(contribution.as_bytes());
let epoch = random.epoch();
self.fact(StepFact::EntropyContributed {
step,
epoch,
contribution: contribution.to_string(),
})
}
pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
self.event_tx
.send(ExecEvent::Signal(signal))
.map_err(|_| ReporterError::Disconnected)?;
Ok(())
}
pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
let signal = UiSignal::LogLine {
step: self.current_step.clone(),
icon,
text: text.into(),
};
self.event_tx
.send(ExecEvent::Signal(signal))
.map_err(|_| ReporterError::Disconnected)?;
Ok(())
}
pub fn progress(
&mut self,
phase: impl Into<String>,
fraction: Option<f32>,
) -> Result<(), ReporterError> {
let step = self
.current_step
.clone()
.ok_or(ReporterError::NoCurrentStep("progress"))?;
let signal = UiSignal::Progress {
step,
phase: phase.into(),
fraction,
};
self.event_tx
.send(ExecEvent::Signal(signal))
.map_err(|_| ReporterError::Disconnected)?;
Ok(())
}
pub fn check_abort(&mut self) -> Result<(), ReporterError> {
loop {
match self.cmd_rx.try_recv() {
Ok(cmd) => self.handle_offline_command(cmd)?,
Err(TryRecvError::Empty) => return Ok(()),
Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
}
}
}
pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
let step = self.current_step.clone();
let prompt_id = self.allocate_prompt_id();
let mut previous_rejection: Option<String> = None;
loop {
self.event_tx
.send(ExecEvent::AwaitPrompt {
step: step.clone(),
prompt_id,
prompt: prompt.clone(),
previous_attempt_rejected_because: previous_rejection.take(),
})
.map_err(|_| ReporterError::Disconnected)?;
loop {
let cmd = self
.cmd_rx
.recv()
.map_err(|_| ReporterError::Disconnected)?;
match cmd {
UiCommand::Abort => return Err(ReporterError::Aborted),
UiCommand::LogDeviation { text } => {
self.emit_deviation(step.clone(), text)?;
}
UiCommand::PromptResponse {
prompt_id: incoming_id,
response,
} => {
if incoming_id != prompt_id {
continue;
}
match validate(prompt, &response) {
Ok(()) => {
let record = response_to_record(&response);
self.fact(StepFact::PromptAnswered {
step: step.clone(),
prompt: prompt.clone(),
response: record,
})?;
return Ok(response);
}
Err(reason) => {
previous_rejection = Some(reason);
break; }
}
}
}
}
}
}
fn allocate_prompt_id(&mut self) -> PromptId {
let id = PromptId::new(self.next_prompt_id);
self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
id
}
fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
match cmd {
UiCommand::Abort => Err(ReporterError::Aborted),
UiCommand::LogDeviation { text } => {
let step = self.current_step.clone();
self.emit_deviation(step, text)
}
UiCommand::PromptResponse { .. } => Ok(()),
}
}
fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
self.fact(StepFact::DeviationRecorded { step, text })
}
}
fn response_to_record(response: &Response) -> ResponseRecord {
match response {
Response::Bool(b) => ResponseRecord::Bool { value: *b },
Response::Text(t) => ResponseRecord::Text { value: t.clone() },
Response::Secret(s) => ResponseRecord::SecretRedacted {
sha256_of_plaintext: sha256_hex(s.expose_secret().as_bytes()),
},
Response::Acknowledge => ResponseRecord::Acknowledged,
}
}
fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
match (prompt, response) {
(Prompt::Confirm { .. }, Response::Bool(_))
| (Prompt::Continue { .. }, Response::Acknowledge)
| (Prompt::Secret { .. }, Response::Secret(_)) => Ok(()),
(Prompt::Text { validator, .. }, Response::Text(value)) => {
apply_validator(validator, value)
}
(Prompt::Literal { expected, .. }, Response::Text(value)) => {
if value == expected {
Ok(())
} else {
Err(format!("expected exactly: {expected}"))
}
}
_ => Err("response shape does not match prompt shape".to_string()),
}
}
fn apply_validator(spec: &rite_model::ValidatorSpec, value: &str) -> Result<(), String> {
use rite_model::ValidatorSpec;
match spec {
ValidatorSpec::NonEmpty => {
if value.trim().is_empty() {
Err("value must not be empty".to_string())
} else {
Ok(())
}
}
ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()),
ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")),
_ => Err("unknown validator variant".to_string()),
}
}
#[cfg(test)]
mod tests {
use crossbeam_channel::unbounded;
use secrecy::SecretString;
use super::*;
use crate::clock::SystemClock;
use crate::transcript_sink::InMemorySink;
use rite_model::ValidatorSpec;
fn ids(step: &str) -> StepId {
StepId::new(step)
}
fn test_clock() -> Arc<dyn Clock> {
Arc::new(SystemClock)
}
#[test]
fn fact_stamps_event_from_the_injected_clock() {
use crate::test_support::{FixedClock, fixed_test_time};
let (event_tx, event_rx) = unbounded();
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let mut sink = InMemorySink::new();
let fixed = fixed_test_time();
let mut reporter =
Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
reporter
.fact(StepFact::CeremonyStarted {
name: "T".to_string(),
})
.expect("fact");
match event_rx.recv().expect("event") {
ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
other => panic!("expected Fact, got {other:?}"),
}
}
#[test]
fn fact_writes_to_sink_and_forwards_to_ui() {
let (event_tx, event_rx) = unbounded();
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
reporter
.fact(StepFact::DeviationRecorded {
step: Some(ids("s1")),
text: "minor".to_string(),
})
.expect("emit fact");
assert_eq!(sink.len(), 1);
match event_rx.try_recv().expect("ui event") {
ExecEvent::Fact {
fact: StepFact::DeviationRecorded { text, .. },
..
} => {
assert_eq!(text, "minor");
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
let (event_tx, _event_rx) = unbounded();
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
let err = reporter
.draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
.expect_err("oversized draw");
assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
if len == crate::entropy::MAX_DRAW_LEN + 1));
}
#[test]
fn log_does_not_touch_transcript() {
let (event_tx, event_rx) = unbounded();
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
reporter.log(Icon::Info, "hello").expect("log");
assert_eq!(sink.len(), 0);
match event_rx.try_recv().expect("ui event") {
ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn check_abort_returns_aborted_when_abort_in_queue() {
let (event_tx, _event_rx) = unbounded::<ExecEvent>();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
cmd_tx.send(UiCommand::Abort).expect("send");
let err = reporter.check_abort().expect_err("abort");
assert!(matches!(err, ReporterError::Aborted));
}
#[test]
fn check_abort_processes_deviation_then_returns_ok() {
let (event_tx, event_rx) = unbounded();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
cmd_tx
.send(UiCommand::LogDeviation {
text: "phone rang".to_string(),
})
.expect("send");
reporter.check_abort().expect("ok");
assert_eq!(sink.len(), 1);
match event_rx.try_recv().expect("ui event") {
ExecEvent::Fact {
fact: StepFact::DeviationRecorded { text, .. },
..
} => {
assert_eq!(text, "phone rang");
}
other => panic!("unexpected event: {other:?}"),
}
}
struct AwaitedPrompt {
prompt_id: PromptId,
rejection: Option<String>,
}
fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
loop {
if let ExecEvent::AwaitPrompt {
prompt_id,
previous_attempt_rejected_because,
..
} = event_rx
.recv()
.expect("event channel closed before AwaitPrompt")
{
return AwaitedPrompt {
prompt_id,
rejection: previous_attempt_rejected_because,
};
}
}
}
fn spawn_frontend<F>(
event_rx: Receiver<ExecEvent>,
cmd_tx: Sender<UiCommand>,
mut respond: F,
) -> std::thread::JoinHandle<()>
where
F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
{
std::thread::spawn(move || {
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::AwaitPrompt {
prompt_id, prompt, ..
} = ev
{
respond(prompt_id, &prompt, &cmd_tx);
}
}
})
}
#[test]
fn prompt_round_trip_emits_await_and_records_fact() {
let (event_tx, event_rx) = unbounded();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
tx.send(UiCommand::PromptResponse {
prompt_id,
response: Response::Bool(true),
})
.expect("send response");
});
let response = reporter
.prompt(&Prompt::Confirm {
question: "proceed?".to_string(),
default: Some(true),
})
.expect("prompt");
assert!(matches!(response, Response::Bool(true)));
assert_eq!(sink.len(), 1);
assert!(matches!(
sink.facts().first(),
Some(StepFact::PromptAnswered { .. })
));
drop(event_tx);
frontend.join().expect("frontend join");
}
#[test]
fn prompt_redacts_secret_in_record() {
let (event_tx, event_rx) = unbounded();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
tx.send(UiCommand::PromptResponse {
prompt_id,
response: Response::Secret(SecretString::from("hunter2".to_string())),
})
.expect("send response");
});
reporter
.prompt(&Prompt::Secret {
label: "PIN".to_string(),
})
.expect("prompt");
match sink.facts().first().expect("fact") {
StepFact::PromptAnswered { response, .. } => match response {
ResponseRecord::SecretRedacted {
sha256_of_plaintext,
} => {
assert!(!sha256_of_plaintext.contains("hunter"));
assert_eq!(sha256_of_plaintext.len(), 64);
}
other => panic!("expected redacted secret, got {other:?}"),
},
other => panic!("unexpected fact: {other:?}"),
}
drop(event_tx);
frontend.join().expect("frontend join");
}
#[test]
fn prompt_loop_handles_deviation_then_response() {
let (event_tx, event_rx) = unbounded();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
tx.send(UiCommand::LogDeviation {
text: "noted".to_string(),
})
.expect("send deviation");
tx.send(UiCommand::PromptResponse {
prompt_id,
response: Response::Acknowledge,
})
.expect("send response");
});
reporter
.prompt(&Prompt::Continue { hint: None })
.expect("prompt");
assert_eq!(sink.len(), 2);
assert!(matches!(
sink.facts().first(),
Some(StepFact::DeviationRecorded { .. })
));
assert!(matches!(
sink.facts().get(1),
Some(StepFact::PromptAnswered { .. })
));
drop(event_tx);
frontend.join().expect("frontend join");
}
#[test]
fn validator_signature_compiles_with_predefined_variant() {
let _v = ValidatorSpec::Predefined("serial_number".to_string());
}
#[test]
fn non_empty_validator_rejects_empty_and_accepts_text() {
assert!(
super::validate(
&Prompt::Text {
label: "name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
&Response::Text(String::new()),
)
.is_err()
);
assert!(
super::validate(
&Prompt::Text {
label: "name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
&Response::Text("Alice".to_string()),
)
.is_ok()
);
}
#[test]
fn non_empty_validator_rejects_whitespace_only() {
let err = super::validate(
&Prompt::Text {
label: "name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
&Response::Text(" \t".to_string()),
)
.expect_err("should reject whitespace");
assert!(err.contains("must not be empty"));
}
#[test]
fn literal_validator_requires_byte_for_byte_match() {
let prompt = Prompt::Literal {
label: "type 'attest'".to_string(),
expected: "attest".to_string(),
};
assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
.expect_err("case-sensitive");
assert!(err.contains("attest"));
}
#[test]
fn regex_validator_not_yet_implemented_returns_rejection() {
let err = super::validate(
&Prompt::Text {
label: "id".to_string(),
validator: ValidatorSpec::Regex(r"^[a-z]+$".to_string()),
},
&Response::Text("abc".to_string()),
)
.expect_err("regex not implemented");
assert!(err.contains("regex"));
}
#[test]
fn predefined_validator_unknown_name_rejected() {
let err = super::validate(
&Prompt::Text {
label: "sn".to_string(),
validator: ValidatorSpec::Predefined("serial_number".to_string()),
},
&Response::Text("AB1234".to_string()),
)
.expect_err("predefined unknown");
assert!(err.contains("serial_number"));
}
#[test]
fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
let (event_tx, event_rx) = unbounded();
let (cmd_tx, cmd_rx) = unbounded();
let mut sink = InMemorySink::new();
let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
reporter.set_current_step(Some(ids("s1")));
let frontend = std::thread::spawn(move || {
let first = recv_await_prompt(&event_rx);
assert!(first.rejection.is_none(), "first prompt has no rejection");
cmd_tx
.send(UiCommand::PromptResponse {
prompt_id: first.prompt_id,
response: Response::Text(String::new()),
})
.expect("send empty");
let second = recv_await_prompt(&event_rx);
assert!(
second.rejection.is_some(),
"second prompt carries the rejection reason",
);
cmd_tx
.send(UiCommand::PromptResponse {
prompt_id: second.prompt_id,
response: Response::Text("Alice".to_string()),
})
.expect("send Alice");
while event_rx.recv().is_ok() {}
});
let response = reporter
.prompt(&Prompt::Text {
label: "name".to_string(),
validator: ValidatorSpec::NonEmpty,
})
.expect("prompt");
match response {
Response::Text(t) => assert_eq!(t, "Alice"),
_ => panic!("expected Text"),
}
drop(event_tx);
frontend.join().expect("frontend join");
}
}