use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ir::{ActId, RoleId, StepId};
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum ValidatorSpec {
NonEmpty,
Regex(String),
Predefined(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Prompt {
Confirm {
question: String,
default: Option<bool>,
},
Text {
label: String,
validator: ValidatorSpec,
},
Secret {
label: String,
},
Literal {
label: String,
expected: String,
},
Continue {
hint: Option<String>,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseRecord {
Bool {
value: bool,
},
Text {
value: String,
},
SecretRedacted {},
Acknowledged,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorClass {
Environmental,
Procedural,
Integrity,
Abort,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorRecord {
pub class: ErrorClass,
pub kind: String,
pub message: String,
}
impl ErrorRecord {
pub fn new(class: ErrorClass, kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
class,
kind: kind.into(),
message: message.into(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum StepOutcome {
Completed {
message: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StepFact {
CeremonyStarted {
name: String,
},
ActStarted {
id: ActId,
label: String,
},
StepStarted {
id: StepId,
label: String,
role: RoleId,
role_name: String,
},
PromptAnswered {
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<StepId>,
prompt: Prompt,
response: ResponseRecord,
},
BackendOperation {
step: StepId,
kind: String,
inputs: serde_json::Value,
outputs: serde_json::Value,
fingerprint: Option<String>,
},
WrapRecipientRecorded {
step: StepId,
source: String,
fingerprint: String,
declared: bool,
},
AttestationRecorded {
step: StepId,
role: RoleId,
statement: String,
},
ArtifactWritten {
step: StepId,
name: String,
path: PathBuf,
sha256: String,
},
DeviationRecorded {
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<StepId>,
text: String,
},
StepAttemptFailed {
step: StepId,
attempt: u32,
error: ErrorRecord,
},
StepCompleted {
id: StepId,
outcome: StepOutcome,
},
CeremonyCompleted {},
CeremonyFailed {
error: ErrorRecord,
},
EntropySeeded {
m: String,
source: String,
derivation: String,
},
EntropyContributed {
step: StepId,
epoch: u32,
contribution: String,
},
EntropyDrawn {
step: StepId,
path: String,
value: String,
},
}
impl StepFact {
#[must_use]
pub fn is_side_effect(&self) -> bool {
match self {
StepFact::BackendOperation { .. }
| StepFact::AttestationRecorded { .. }
| StepFact::ArtifactWritten { .. }
| StepFact::EntropyContributed { .. }
| StepFact::EntropyDrawn { .. } => true,
StepFact::CeremonyStarted { .. }
| StepFact::ActStarted { .. }
| StepFact::StepStarted { .. }
| StepFact::PromptAnswered { .. }
| StepFact::WrapRecipientRecorded { .. }
| StepFact::DeviationRecorded { .. }
| StepFact::StepAttemptFailed { .. }
| StepFact::StepCompleted { .. }
| StepFact::CeremonyCompleted {}
| StepFact::CeremonyFailed { .. }
| StepFact::EntropySeeded { .. } => false,
}
}
}
#[cfg(test)]
mod schema_snapshot_tests {
use super::*;
use serde_json::json;
fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
let actual = serde_json::to_value(fact).expect("serialize StepFact");
assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
}
#[test]
fn ceremony_started() {
assert_json(
&StepFact::CeremonyStarted {
name: "Root CA".to_string(),
},
&json!({
"type": "ceremony_started",
"name": "Root CA",
}),
);
}
#[test]
fn act_started() {
assert_json(
&StepFact::ActStarted {
id: ActId::new("setup"),
label: "Setup".to_string(),
},
&json!({
"type": "act_started",
"id": "setup",
"label": "Setup",
}),
);
}
#[test]
fn step_started() {
assert_json(
&StepFact::StepStarted {
id: StepId::new("s1"),
label: "2.1".to_string(),
role: RoleId::new("crypto_officer"),
role_name: "Crypto Officer".to_string(),
},
&json!({
"type": "step_started",
"id": "s1",
"label": "2.1",
"role": "crypto_officer",
"role_name": "Crypto Officer",
}),
);
}
#[test]
fn prompt_answered_confirm_bool() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Confirm {
question: "Proceed?".to_string(),
default: Some(true),
},
response: ResponseRecord::Bool { value: true },
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "confirm", "question": "Proceed?", "default": true },
"response": { "type": "bool", "value": true },
}),
);
}
#[test]
fn prompt_answered_text_nonempty() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "Name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
response: ResponseRecord::Text {
value: "Alice".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "Name",
"validator": { "kind": "non_empty" },
},
"response": { "type": "text", "value": "Alice" },
}),
);
}
#[test]
fn prompt_answered_text_regex() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
},
response: ResponseRecord::Text {
value: "AB12".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
},
"response": { "type": "text", "value": "AB12" },
}),
);
}
#[test]
fn prompt_answered_text_predefined() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Predefined("serial_number".to_string()),
},
response: ResponseRecord::Text {
value: "ABCD".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "predefined", "value": "serial_number" },
},
"response": { "type": "text", "value": "ABCD" },
}),
);
}
#[test]
fn prompt_answered_secret_redacted() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Secret {
label: "PIN".to_string(),
},
response: ResponseRecord::SecretRedacted {},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "secret", "label": "PIN" },
"response": { "type": "secret_redacted" },
}),
);
}
#[test]
fn prompt_answered_literal_text() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Literal {
label: "Type 'attest'".to_string(),
expected: "attest".to_string(),
},
response: ResponseRecord::Text {
value: "attest".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
"response": { "type": "text", "value": "attest" },
}),
);
}
#[test]
fn prompt_answered_continue_acknowledged() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Continue {
hint: Some("Press Enter".to_string()),
},
response: ResponseRecord::Acknowledged,
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "continue", "hint": "Press Enter" },
"response": { "type": "acknowledged" },
}),
);
}
#[test]
fn backend_operation() {
assert_json(
&StepFact::BackendOperation {
step: StepId::new("s1"),
kind: "generate_key".to_string(),
inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
outputs: json!({ "key_id": "k1" }),
fingerprint: Some("sha256:deadbeef".to_string()),
},
&json!({
"type": "backend_operation",
"step": "s1",
"kind": "generate_key",
"inputs": { "algorithm": "rsa", "bits": 4096 },
"outputs": { "key_id": "k1" },
"fingerprint": "sha256:deadbeef",
}),
);
}
#[test]
fn wrap_recipient_recorded() {
assert_json(
&StepFact::WrapRecipientRecorded {
step: StepId::new("s1"),
source: "escrow_pubkey".to_string(),
fingerprint: "sha256:deadbeef".to_string(),
declared: true,
},
&json!({
"type": "wrap_recipient_recorded",
"step": "s1",
"source": "escrow_pubkey",
"fingerprint": "sha256:deadbeef",
"declared": true,
}),
);
}
#[test]
fn attestation_recorded() {
assert_json(
&StepFact::AttestationRecorded {
step: StepId::new("s1"),
role: RoleId::new("crypto_officer"),
statement: "I confirm.".to_string(),
},
&json!({
"type": "attestation_recorded",
"step": "s1",
"role": "crypto_officer",
"statement": "I confirm.",
}),
);
}
#[test]
fn artifact_written() {
assert_json(
&StepFact::ArtifactWritten {
step: StepId::new("s1"),
name: "root.crt".to_string(),
path: "artifacts/root.crt".into(),
sha256: "a".repeat(64),
},
&json!({
"type": "artifact_written",
"step": "s1",
"name": "root.crt",
"path": "artifacts/root.crt",
"sha256": "a".repeat(64),
}),
);
}
#[test]
fn deviation_recorded() {
assert_json(
&StepFact::DeviationRecorded {
step: Some(StepId::new("s1")),
text: "phone rang".to_string(),
},
&json!({
"type": "deviation_recorded",
"step": "s1",
"text": "phone rang",
}),
);
}
#[test]
fn step_completed_completed() {
assert_json(
&StepFact::StepCompleted {
id: StepId::new("s1"),
outcome: StepOutcome::Completed {
message: "done".to_string(),
},
},
&json!({
"type": "step_completed",
"id": "s1",
"outcome": { "status": "completed", "message": "done" },
}),
);
}
#[test]
fn ceremony_completed() {
assert_json(
&StepFact::CeremonyCompleted {},
&json!({
"type": "ceremony_completed",
}),
);
}
#[test]
fn ceremony_failed() {
assert_json(
&StepFact::CeremonyFailed {
error: ErrorRecord::new(
ErrorClass::Abort,
"aborted",
"ceremony aborted by operator",
),
},
&json!({
"type": "ceremony_failed",
"error": {
"class": "abort",
"kind": "aborted",
"message": "ceremony aborted by operator",
},
}),
);
}
#[test]
fn step_attempt_failed() {
assert_json(
&StepFact::StepAttemptFailed {
step: StepId::new("import_key"),
attempt: 1,
error: ErrorRecord::new(
ErrorClass::Environmental,
"backend_error",
"Token not present",
),
},
&json!({
"type": "step_attempt_failed",
"step": "import_key",
"attempt": 1,
"error": {
"class": "environmental",
"kind": "backend_error",
"message": "Token not present",
},
}),
);
}
#[test]
fn entropy_seeded() {
assert_json(
&StepFact::EntropySeeded {
m: "00112233".to_string(),
source: "os".to_string(),
derivation: "rite-kdf/v1".to_string(),
},
&json!({
"type": "entropy_seeded",
"m": "00112233",
"source": "os",
"derivation": "rite-kdf/v1",
}),
);
}
#[test]
fn entropy_contributed() {
assert_json(
&StepFact::EntropyContributed {
step: StepId::new("roll_dice"),
epoch: 1,
contribution: "3 1 6 4 2 5".to_string(),
},
&json!({
"type": "entropy_contributed",
"step": "roll_dice",
"epoch": 1,
"contribution": "3 1 6 4 2 5",
}),
);
}
#[test]
fn entropy_drawn() {
assert_json(
&StepFact::EntropyDrawn {
step: StepId::new("issue"),
path: "0/issue/cert-serial".to_string(),
value: "aabbccddeeff00112233".to_string(),
},
&json!({
"type": "entropy_drawn",
"step": "issue",
"path": "0/issue/cert-serial",
"value": "aabbccddeeff00112233",
}),
);
}
}