Skip to main content

rite_model/
types.rs

1//! Shared semantic types for the ceremony domain model.
2
3use serde::{Deserialize, Serialize};
4
5/// Ceremony metadata (name and optional description).
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Metadata {
8    /// Human-readable ceremony name.
9    pub name: String,
10    /// Optional description.
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub description: Option<String>,
13}
14
15/// Extract the role type from a role ID.
16///
17/// `"witness__1"` → `"witness"`, `"operator"` → `"operator"`.
18pub fn role_type(id: &str) -> &str {
19    id.split_once("__").map_or(id, |(prefix, _)| prefix)
20}
21
22/// Derive a display name from a step or section ID.
23///
24/// Splits on `_` and `-`, title-cases each word, joins with space.
25/// `"verify_time"` → `"Verify Time"`, `"generate_root_ca"` → `"Generate Root Ca"`.
26///
27/// Note: this naive title-casing does not handle acronyms; `"root_ca"` becomes `"Root Ca"`
28/// rather than `"Root CA"`. A lookup table for common ceremony acronyms (CA, CSR, PKI, HSM,
29/// TPM) could improve this. See `derive_role_name` for the same limitation.
30pub fn derive_step_name(id: &str) -> String {
31    id.split(['_', '-'])
32        .map(|word| {
33            let mut chars = word.chars();
34            match chars.next() {
35                None => String::new(),
36                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
37            }
38        })
39        .collect::<Vec<_>>()
40        .join(" ")
41}
42
43/// Derive a display name from a role ID.
44///
45/// Title-cases each word (split on `_`, `-`, and `__`), including the discriminator suffix.
46/// `"witness__1"` → `"Witness 1"`, `"hsm_operator__primary"` → `"Hsm Operator Primary"`.
47pub fn derive_role_name(id: &str) -> String {
48    id.replace("__", " ")
49        .split(['_', '-', ' '])
50        .filter(|s| !s.is_empty())
51        .map(|word| {
52            let mut chars = word.chars();
53            match chars.next() {
54                None => String::new(),
55                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
56            }
57        })
58        .collect::<Vec<_>>()
59        .join(" ")
60}
61
62/// Action types available in ceremony steps.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum ActionType {
67    /// Verify system clock is correct before ceremony proceeds.
68    ///
69    /// Displays current time and requires operator confirmation.
70    /// Should be placed as the first step to ensure all timestamps are valid.
71    ClockCheck,
72    /// Human attests to something with yes/no decision (single person).
73    ///
74    /// Use when the verification requires human judgment about external state.
75    /// Example: "Verify all network cables are disconnected".
76    Confirm,
77    /// Machine compares two known values and records pass/fail (automatic).
78    ///
79    /// Use when both values are known to the system and comparison is deterministic.
80    /// Example: Compare computed `SHA-256` hash against expected value.
81    CheckValue,
82    /// Two-party verbal verification: reader speaks value aloud, confirmer verifies.
83    ///
84    /// Use when a value must be verified against something external (physical label,
85    /// document). Supports NATO phonetic alphabet and hex formatting.
86    OralReadback,
87    /// Capture machine information (hostname, CPU, OS) as evidence.
88    ///
89    /// Records device identity to prove which machine ran the ceremony.
90    /// Should be placed early in ceremony to establish machine context.
91    MachineInfo,
92
93    /// Generate RSA or EC keypair.
94    GenerateKeypair,
95    /// Wrap key using CMS `EnvelopedData`.
96    WrapKey,
97    /// Unwrap key using CMS `EnvelopedData`.
98    UnwrapKey,
99    /// Export public key from keypair.
100    ExportPublic,
101    /// Sign arbitrary data with a backend-managed key.
102    ///
103    /// The signature algorithm follows from the key unless `algorithm:` names
104    /// another one the key accepts. Requires a backend implementing `SignBackend`.
105    SignData,
106    /// Verify a signature over data, given the signer's public key.
107    ///
108    /// Needs no backend: verification takes only a public key, so it works on
109    /// evidence the ceremony did not produce. The key may be a bare public key
110    /// or a certificate carrying one. Naming a `backend:` delegates the check
111    /// to that backend instead.
112    VerifySignature,
113    /// Formal attestation statement.
114    Attest,
115    /// Fold human-supplied entropy into the ceremony seed.
116    ///
117    /// A participant supplies a free-form random value (for example, the
118    /// result of rolling physical dice), which is mixed into the entropy
119    /// source's ratchet. Any later drawn value reflects the contribution and
120    /// stays re-derivable by `rite verify`.
121    GatherEntropy,
122    /// TPM attestation with PCR measurements and cryptographic quotes.
123    ///
124    /// Requires the `rite-tpm` backend.
125    TpmAttest,
126
127    /// Read X.509 certificate from PIV smart card slot.
128    ///
129    /// No PIN required; reading certificates is unauthenticated on PIV cards.
130    /// Requires a PIV backend.
131    PivReadCertificate,
132    /// Sign data using PIV smart card on-device key.
133    ///
134    /// Handles PIN verification internally before signing.
135    /// Requires a PIV backend.
136    PivSign,
137    /// Generate a `YubiKey` attestation certificate for a PIV slot (Yubico extension).
138    ///
139    /// Slot `F9` signs the key's certificate to prove it was generated on-device.
140    /// Requires the `rite-yubikey` backend.
141    YubikeyAttestSlot,
142
143    /// Issue an X.509 certificate from a PKCS#10 CSR.
144    ///
145    /// Takes a CSR and a backend-managed signing key, assembles the `TBSCertificate`,
146    /// signs it via the backend's `SignBackend`, and produces a DER-encoded certificate.
147    /// Works with any backend implementing `SignBackend` (software, PKCS#11, `YubiKey`).
148    IssueCertificate,
149    /// Generate a PKCS#10 CSR signed by a backend-managed key.
150    ///
151    /// Takes a backend-managed signing key and subject parameters, assembles a
152    /// `CertReqInfo`, signs it via the backend's `SignBackend`, and produces a
153    /// `DER`-encoded `CSR`.
154    GenerateCsr,
155}
156
157/// Whether an action needs the `backend:` field on its step.
158///
159/// Three states, not two: an action can also run without a backend and accept
160/// one anyway. Verification is the case that forces the distinction, since a
161/// signature check needs only a public key but a validated deployment may
162/// require it inside the boundary.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165#[non_exhaustive]
166pub enum BackendUsage {
167    /// The step must name a backend; omitting it is an error.
168    Required,
169    /// The step runs in software unless it names a backend, which then does the
170    /// work instead. Naming one changes who performs the operation, not what
171    /// the step accepts or produces.
172    SoftwareUnlessNamed,
173    /// The action never uses a backend; naming one is a mistake worth warning about.
174    Unused,
175}
176
177impl ActionType {
178    /// Every action type, for callers that need to enumerate them.
179    ///
180    /// Maintained by hand: `#[non_exhaustive]` means no downstream crate can
181    /// derive this list, and adding a variant produces no error outside this
182    /// crate. Adding one here is what keeps editor completion and any other
183    /// catalogue from silently missing it. The test below catches omissions.
184    pub const ALL: &'static [ActionType] = &[
185        ActionType::ClockCheck,
186        ActionType::Confirm,
187        ActionType::CheckValue,
188        ActionType::OralReadback,
189        ActionType::MachineInfo,
190        ActionType::GenerateKeypair,
191        ActionType::WrapKey,
192        ActionType::UnwrapKey,
193        ActionType::ExportPublic,
194        ActionType::SignData,
195        ActionType::VerifySignature,
196        ActionType::Attest,
197        ActionType::GatherEntropy,
198        ActionType::TpmAttest,
199        ActionType::PivReadCertificate,
200        ActionType::PivSign,
201        ActionType::YubikeyAttestSlot,
202        ActionType::IssueCertificate,
203        ActionType::GenerateCsr,
204    ];
205
206    /// How this action relates to the `backend:` field on its step.
207    ///
208    /// TODO: Replace this with a nested enum split — `ActionType::Backend(BackendAction)` vs
209    /// `ActionType::Local(LocalAction)`, once a home is found for the actions that are
210    /// neither. This is a breaking change to every match on `ActionType` variants.
211    pub fn backend_usage(self) -> BackendUsage {
212        match self {
213            ActionType::GenerateKeypair
214            | ActionType::SignData
215            | ActionType::WrapKey
216            | ActionType::UnwrapKey
217            | ActionType::ExportPublic
218            | ActionType::GenerateCsr
219            | ActionType::IssueCertificate
220            | ActionType::PivReadCertificate
221            | ActionType::PivSign
222            | ActionType::YubikeyAttestSlot
223            | ActionType::TpmAttest => BackendUsage::Required,
224
225            ActionType::VerifySignature => BackendUsage::SoftwareUnlessNamed,
226
227            ActionType::ClockCheck
228            | ActionType::Confirm
229            | ActionType::CheckValue
230            | ActionType::OralReadback
231            | ActionType::MachineInfo
232            | ActionType::Attest
233            | ActionType::GatherEntropy => BackendUsage::Unused,
234        }
235    }
236
237    /// Returns the `with:` field names that are required for this action.
238    ///
239    /// The resolver reports a diagnostic for each missing field.
240    ///
241    /// TODO: Replace the stringly-typed `with: serde_json::Value` in `schema::StepBody` with a
242    /// typed `WithFields` enum (one variant per action, carrying its required fields as named
243    /// struct members). Serde would then enforce required fields at parse time and this method
244    /// would no longer be needed. This is a breaking change to the schema and lowering layers.
245    pub fn required_with_fields(self) -> &'static [&'static str] {
246        match self {
247            ActionType::CheckValue => &["actual", "expected"],
248            ActionType::GenerateCsr => &["subject"],
249            _ => &[],
250        }
251    }
252
253    /// Short human-readable description of what this action does.
254    ///
255    /// Used as fallback prose in script generation, TUI step display, and LSP
256    /// hover when no explicit `description` or `message` parameter is present.
257    pub fn describe(&self) -> &'static str {
258        match self {
259            ActionType::ClockCheck => "Verify system clock against a reference time.",
260            ActionType::Confirm => "Confirm readiness or completion of a manual step.",
261            ActionType::CheckValue => "Verify a value matches an expected result.",
262            ActionType::OralReadback => "Read back a value aloud for verification.",
263            ActionType::MachineInfo => "Record system and environment information.",
264            ActionType::Attest => "Record a signed attestation from a participant.",
265            ActionType::GatherEntropy => "Fold human-supplied entropy into the ceremony seed.",
266            ActionType::TpmAttest => "Record TPM platform attestation (PCR values).",
267            ActionType::GenerateKeypair => "Generate an asymmetric keypair.",
268            ActionType::ExportPublic => "Export the public component of a keypair.",
269            ActionType::SignData => "Sign data with a ceremony key.",
270            ActionType::VerifySignature => "Verify a signature against a public key.",
271            ActionType::WrapKey => "Wrap (encrypt) a key for secure transport.",
272            ActionType::UnwrapKey => "Unwrap (decrypt) a transported key.",
273            ActionType::GenerateCsr => "Generate a Certificate Signing Request.",
274            ActionType::IssueCertificate => "Issue an X.509 certificate from a CSR.",
275            ActionType::PivReadCertificate => "Read a certificate from a PIV smart card slot.",
276            ActionType::PivSign => "Sign data using a PIV smart card key.",
277            ActionType::YubikeyAttestSlot => "Attest a YubiKey PIV slot key.",
278        }
279    }
280}
281
282impl std::fmt::Display for ActionType {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            ActionType::ClockCheck => write!(f, "clock_check"),
286            ActionType::Confirm => write!(f, "confirm"),
287            ActionType::CheckValue => write!(f, "check_value"),
288            ActionType::OralReadback => write!(f, "oral_readback"),
289            ActionType::MachineInfo => write!(f, "machine_info"),
290            ActionType::GenerateKeypair => write!(f, "generate_keypair"),
291            ActionType::WrapKey => write!(f, "wrap_key"),
292            ActionType::UnwrapKey => write!(f, "unwrap_key"),
293            ActionType::ExportPublic => write!(f, "export_public"),
294            ActionType::SignData => write!(f, "sign_data"),
295            ActionType::VerifySignature => write!(f, "verify_signature"),
296            ActionType::Attest => write!(f, "attest"),
297            ActionType::GatherEntropy => write!(f, "gather_entropy"),
298            ActionType::TpmAttest => write!(f, "tpm_attest"),
299            ActionType::PivReadCertificate => write!(f, "piv_read_certificate"),
300            ActionType::PivSign => write!(f, "piv_sign"),
301            ActionType::YubikeyAttestSlot => write!(f, "yubikey_attest_slot"),
302            ActionType::IssueCertificate => write!(f, "issue_certificate"),
303            ActionType::GenerateCsr => write!(f, "generate_csr"),
304        }
305    }
306}
307
308/// Type of output produced by a ceremony.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311#[non_exhaustive]
312pub enum OutputType {
313    /// Public key in PEM format.
314    PublicKey,
315    /// Wrapped (encrypted) key in CMS format.
316    WrappedKey,
317    /// X.509 certificate.
318    Certificate,
319    /// DNSSEC signed resource record set.
320    SignedRrset,
321    /// Certificate Transparency Signed Certificate Timestamp.
322    Sct,
323
324    // Documents
325    /// Generic document.
326    Document,
327    /// Ceremony log or transcript.
328    CeremonyLog,
329}
330
331impl std::fmt::Display for OutputType {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        match self {
334            OutputType::PublicKey => write!(f, "public_key"),
335            OutputType::WrappedKey => write!(f, "wrapped_key"),
336            OutputType::Certificate => write!(f, "certificate"),
337            OutputType::SignedRrset => write!(f, "signed_rrset"),
338            OutputType::Sct => write!(f, "sct"),
339            OutputType::Document => write!(f, "document"),
340            OutputType::CeremonyLog => write!(f, "ceremony_log"),
341        }
342    }
343}
344
345impl OutputType {
346    /// Returns the default file extension for this output type.
347    pub fn default_extension(&self) -> &'static str {
348        match self {
349            OutputType::PublicKey | OutputType::Certificate => "pem",
350            OutputType::WrappedKey | OutputType::SignedRrset | OutputType::Sct => "bin",
351            OutputType::Document => "txt",
352            OutputType::CeremonyLog => "json",
353        }
354    }
355}
356
357/// Type of a ceremony parameter.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360#[non_exhaustive]
361pub enum ParameterType {
362    /// Text value.
363    String,
364    /// Date value (`YYYY-MM-DD`).
365    Date,
366    /// Integer value.
367    Integer,
368    /// Boolean value.
369    Boolean,
370}
371
372impl std::fmt::Display for ParameterType {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        match self {
375            ParameterType::String => write!(f, "string"),
376            ParameterType::Date => write!(f, "date (YYYY-MM-DD)"),
377            ParameterType::Integer => write!(f, "integer"),
378            ParameterType::Boolean => write!(f, "boolean (true/false)"),
379        }
380    }
381}
382
383/// Typed preset for common post-ceremony duty categories.
384///
385/// Provides built-in prose for scripts when no description is given.
386/// Use `Custom` for duties that don't fit a preset; description is then required.
387///
388/// Note: physical handling steps (sealing hardware, etc.) belong as `physical_action`
389/// steps in ceremony execution, not here.
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
391#[serde(rename_all = "snake_case")]
392#[non_exhaustive]
393pub enum DutyType {
394    /// Return materials to secure storage location.
395    ReturnToVault,
396    /// Hand key shares to assigned custodians.
397    DistributeShares,
398    /// Distribute backup media to designated recipients.
399    DistributeMedia,
400    /// Archive ceremony materials at storage locations.
401    ArchiveMaterials,
402    /// Publish ceremony record and witness attestations.
403    PublishRecord,
404    /// Notify stakeholders of ceremony completion.
405    NotifyStakeholders,
406    /// Import generated keys into operational system.
407    ImportKeys,
408    /// Free-form duty; description is required.
409    Custom,
410}
411
412impl DutyType {
413    /// Returns the built-in display name for this duty type.
414    pub fn display_name(&self) -> &'static str {
415        match self {
416            DutyType::ReturnToVault => "Return to Vault",
417            DutyType::DistributeShares => "Distribute Key Shares",
418            DutyType::DistributeMedia => "Distribute Backup Media",
419            DutyType::ArchiveMaterials => "Archive Materials",
420            DutyType::PublishRecord => "Publish Record",
421            DutyType::NotifyStakeholders => "Notify Stakeholders",
422            DutyType::ImportKeys => "Import Keys",
423            DutyType::Custom => "Custom Duty",
424        }
425    }
426
427    /// Returns the built-in prose description for this duty type.
428    ///
429    /// Returns `None` for `Custom` (description must be provided explicitly).
430    pub fn built_in_prose(&self) -> Option<&'static str> {
431        match self {
432            DutyType::ReturnToVault => Some("Return materials to secure storage"),
433            DutyType::DistributeShares => Some("Distribute key shares to assigned custodians"),
434            DutyType::DistributeMedia => Some("Distribute backup media to designated recipients"),
435            DutyType::ArchiveMaterials => {
436                Some("Archive ceremony materials at designated storage locations")
437            }
438            DutyType::PublishRecord => Some("Publish ceremony record and witness attestations"),
439            DutyType::NotifyStakeholders => Some("Notify stakeholders of ceremony completion"),
440            DutyType::ImportKeys => Some("Import generated keys into operational system"),
441            DutyType::Custom => None,
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn role_type_extracts_prefix() {
452        assert_eq!(role_type("witness__1"), "witness");
453        assert_eq!(role_type("hsm_operator__primary"), "hsm_operator");
454        assert_eq!(role_type("operator"), "operator");
455        assert_eq!(role_type("witness__"), "witness");
456    }
457
458    #[test]
459    fn derive_step_name_title_cases() {
460        assert_eq!(derive_step_name("verify_time"), "Verify Time");
461        assert_eq!(derive_step_name("generate_root_ca"), "Generate Root Ca");
462        assert_eq!(derive_step_name("wrap_root_ca_key"), "Wrap Root Ca Key");
463        assert_eq!(derive_step_name("witness1_attest"), "Witness1 Attest");
464        assert_eq!(derive_step_name("clock-check"), "Clock Check");
465        assert_eq!(derive_step_name("opening"), "Opening");
466    }
467
468    #[test]
469    fn derive_role_name_title_cases() {
470        assert_eq!(derive_role_name("witness__1"), "Witness 1");
471        assert_eq!(
472            derive_role_name("hsm_operator__primary"),
473            "Hsm Operator Primary"
474        );
475        assert_eq!(derive_role_name("operator"), "Operator");
476        assert_eq!(derive_role_name("ceremony-admin"), "Ceremony Admin");
477    }
478
479    #[test]
480    fn action_type_serde_roundtrip() {
481        // `snake_case` rename means these exact strings appear in ceremony YAML.
482        // A variant rename or serde attr change breaks YAML parsing silently.
483        let cases: &[(ActionType, &str)] = &[
484            (ActionType::ClockCheck, "\"clock_check\""),
485            (ActionType::Confirm, "\"confirm\""),
486            (ActionType::CheckValue, "\"check_value\""),
487            (ActionType::OralReadback, "\"oral_readback\""),
488            (ActionType::MachineInfo, "\"machine_info\""),
489            (ActionType::GenerateKeypair, "\"generate_keypair\""),
490            (ActionType::WrapKey, "\"wrap_key\""),
491            (ActionType::UnwrapKey, "\"unwrap_key\""),
492            (ActionType::ExportPublic, "\"export_public\""),
493            (ActionType::Attest, "\"attest\""),
494            (ActionType::TpmAttest, "\"tpm_attest\""),
495            (ActionType::PivReadCertificate, "\"piv_read_certificate\""),
496            (ActionType::PivSign, "\"piv_sign\""),
497            (ActionType::YubikeyAttestSlot, "\"yubikey_attest_slot\""),
498            (ActionType::IssueCertificate, "\"issue_certificate\""),
499            (ActionType::GenerateCsr, "\"generate_csr\""),
500        ];
501        for &(variant, expected) in cases {
502            let serialized = serde_json::to_string(&variant).unwrap();
503            assert_eq!(serialized, expected, "serialize {variant:?}");
504            let deserialized: ActionType = serde_json::from_str(expected).unwrap();
505            assert_eq!(deserialized, variant, "deserialize {expected}");
506        }
507    }
508
509    #[test]
510    fn action_type_display_matches_serde() {
511        // Display is used in transcript output and error messages; serde in YAML parsing.
512        // They must agree or transcripts reference action names that differ from YAML.
513        let actions = [
514            ActionType::ClockCheck,
515            ActionType::Confirm,
516            ActionType::CheckValue,
517            ActionType::OralReadback,
518            ActionType::MachineInfo,
519            ActionType::GenerateKeypair,
520            ActionType::WrapKey,
521            ActionType::UnwrapKey,
522            ActionType::ExportPublic,
523            ActionType::Attest,
524            ActionType::TpmAttest,
525            ActionType::PivReadCertificate,
526            ActionType::PivSign,
527            ActionType::YubikeyAttestSlot,
528            ActionType::IssueCertificate,
529            ActionType::GenerateCsr,
530        ];
531        for action in actions {
532            let display = action.to_string();
533            let serde_json = serde_json::to_string(&action).unwrap();
534            assert_eq!(
535                display,
536                serde_json.trim_matches('"'),
537                "Display and serde disagree for {action:?}"
538            );
539        }
540    }
541
542    #[test]
543    fn output_type_serde_roundtrip() {
544        // These strings appear in ceremony YAML `output:` blocks and in file extension
545        // mapping. A rename breaks both YAML parsing and output file naming.
546        let cases: &[(OutputType, &str)] = &[
547            (OutputType::PublicKey, "\"public_key\""),
548            (OutputType::WrappedKey, "\"wrapped_key\""),
549            (OutputType::Certificate, "\"certificate\""),
550            (OutputType::Document, "\"document\""),
551            (OutputType::CeremonyLog, "\"ceremony_log\""),
552        ];
553        for &(variant, expected) in cases {
554            let serialized = serde_json::to_string(&variant).unwrap();
555            assert_eq!(serialized, expected, "serialize {variant:?}");
556            let deserialized: OutputType = serde_json::from_str(expected).unwrap();
557            assert_eq!(deserialized, variant, "deserialize {expected}");
558        }
559    }
560
561    #[test]
562    fn parameter_type_serde_roundtrip() {
563        // These strings appear in ceremony YAML `parameters:` blocks.
564        let cases: &[(ParameterType, &str)] = &[
565            (ParameterType::String, "\"string\""),
566            (ParameterType::Date, "\"date\""),
567            (ParameterType::Integer, "\"integer\""),
568            (ParameterType::Boolean, "\"boolean\""),
569        ];
570        for (variant, expected) in cases {
571            let serialized = serde_json::to_string(variant).unwrap();
572            assert_eq!(serialized, *expected, "serialize {variant:?}");
573            let deserialized: ParameterType = serde_json::from_str(expected).unwrap();
574            // ParameterType does not implement PartialEq; check the display instead.
575            assert_eq!(
576                serde_json::to_string(&deserialized).unwrap(),
577                *expected,
578                "deserialize {expected}"
579            );
580        }
581    }
582
583    #[test]
584    fn duty_type_serde_roundtrip() {
585        // These strings appear in ceremony YAML `after:` blocks.
586        let cases: &[(DutyType, &str)] = &[
587            (DutyType::ReturnToVault, "\"return_to_vault\""),
588            (DutyType::DistributeShares, "\"distribute_shares\""),
589            (DutyType::DistributeMedia, "\"distribute_media\""),
590            (DutyType::ArchiveMaterials, "\"archive_materials\""),
591            (DutyType::PublishRecord, "\"publish_record\""),
592            (DutyType::NotifyStakeholders, "\"notify_stakeholders\""),
593            (DutyType::ImportKeys, "\"import_keys\""),
594            (DutyType::Custom, "\"custom\""),
595        ];
596        for (variant, expected) in cases {
597            let serialized = serde_json::to_string(variant).unwrap();
598            assert_eq!(serialized, *expected, "serialize {variant:?}");
599            let deserialized: DutyType = serde_json::from_str(expected).unwrap();
600            assert_eq!(deserialized, *variant, "deserialize {expected}");
601        }
602    }
603
604    #[test]
605    fn duty_type_custom_has_no_built_in_prose() {
606        // The runtime branches on this: if built_in_prose returns None, the description
607        // field is required. A regression here would panic or produce empty script output.
608        assert!(
609            DutyType::Custom.built_in_prose().is_none(),
610            "Custom duty must have no built-in prose"
611        );
612        // All other variants must have prose.
613        for duty in [
614            DutyType::ReturnToVault,
615            DutyType::DistributeShares,
616            DutyType::DistributeMedia,
617            DutyType::ArchiveMaterials,
618            DutyType::PublishRecord,
619            DutyType::NotifyStakeholders,
620            DutyType::ImportKeys,
621        ] {
622            assert!(
623                duty.built_in_prose().is_some(),
624                "{duty:?} must have built-in prose"
625            );
626        }
627    }
628
629    /// `ActionType::ALL` is written by hand, so it can fall behind the enum.
630    ///
631    /// The match below is exhaustive, so adding a variant fails to compile
632    /// here. That is the whole mechanism: it puts a compiler error next to the
633    /// list an author has to extend. The name check then catches a variant
634    /// listed twice, and `rite-ls` compares its own catalogue against `ALL`,
635    /// which is what catches one left out.
636    #[test]
637    fn all_lists_every_action_type() {
638        for action in ActionType::ALL {
639            match action {
640                ActionType::ClockCheck
641                | ActionType::Confirm
642                | ActionType::CheckValue
643                | ActionType::OralReadback
644                | ActionType::MachineInfo
645                | ActionType::GenerateKeypair
646                | ActionType::WrapKey
647                | ActionType::UnwrapKey
648                | ActionType::ExportPublic
649                | ActionType::SignData
650                | ActionType::VerifySignature
651                | ActionType::Attest
652                | ActionType::GatherEntropy
653                | ActionType::TpmAttest
654                | ActionType::PivReadCertificate
655                | ActionType::PivSign
656                | ActionType::YubikeyAttestSlot
657                | ActionType::IssueCertificate
658                | ActionType::GenerateCsr => {}
659            }
660        }
661
662        let names: std::collections::BTreeSet<String> =
663            ActionType::ALL.iter().map(ToString::to_string).collect();
664        assert_eq!(
665            names.len(),
666            ActionType::ALL.len(),
667            "two actions share a DSL name"
668        );
669    }
670}