Skip to main content

rite_model/validate/
message.rs

1//! The schema of each action's `with:` block, compiled once, and what a
2//! violation of it says to the ceremony author.
3//!
4//! One renderer, worded per keyword. A violation about a value reads as the
5//! rule, then the value found: `'with.shares' must be at most 100, found
6//! 101`. Where a generic rule would lose meaning, the schema carries an
7//! `x-message` and that is the rule: the
8//! one on the schema object that owns the failing keyword, or on an object
9//! reaching it through `allOf`, `oneOf`, `if`/`then` or `not` only, never
10//! through `properties` or `items`, so a message never captures errors about
11//! another field.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::OnceLock;
15
16use serde_json::Value;
17
18use super::{Keyword, Matched, Pointer, SchemaError, Validator, Violation};
19use crate::ActionType;
20use crate::params::with_schema;
21
22/// The compiled schema of one action's `with:` block.
23pub struct ParamsCheck {
24    validator: Validator,
25    schema: Value,
26}
27
28/// The check for `action`, compiled on first use and kept for the process.
29///
30/// # Errors
31///
32/// Returns why the generated schema did not compile, which is a mistake in
33/// the generator; a test compiles every action's schema.
34pub fn params_check(action: ActionType) -> Result<&'static ParamsCheck, SchemaError> {
35    static CHECKS: OnceLock<HashMap<ActionType, Result<ParamsCheck, SchemaError>>> =
36        OnceLock::new();
37    let checks = CHECKS.get_or_init(|| {
38        ActionType::ALL
39            .iter()
40            .map(|&action| {
41                let schema = with_schema(action).to_value();
42                let check =
43                    Validator::new(&schema).map(|validator| ParamsCheck { validator, schema });
44                (action, check)
45            })
46            .collect()
47    });
48    match checks.get(&action) {
49        Some(Ok(check)) => Ok(check),
50        Some(Err(error)) => Err(error.clone()),
51        // Every action is in `ALL`, which a test holds to the enum.
52        None => Err(SchemaError(format!("no schema for '{action}'"))),
53    }
54}
55
56/// Where a diagnostic about a violation points: at a key, or at a value.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum Anchor {
60    /// The key of the entry at the pointer.
61    Key,
62    /// The value at the pointer.
63    Value,
64}
65
66/// Whether a message may quote the value it is about.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum Values {
70    /// Quote it: the value is as the definition writes it.
71    Shown,
72    /// Leave it out: the value was evaluated, and an expression may have
73    /// copied a secret into it.
74    Withheld,
75}
76
77impl Violation {
78    /// What a diagnostic about this violation underlines: the offending key
79    /// for an unexpected one, the key of the object missing a field for a
80    /// missing one, and the value for everything else.
81    #[must_use]
82    pub fn anchor(&self) -> (Pointer, Anchor) {
83        match &self.keyword {
84            Keyword::AdditionalProperties { unexpected } => match unexpected.as_slice() {
85                [key] => (self.instance_path.join(key), Anchor::Key),
86                _ => (self.instance_path.clone(), Anchor::Value),
87            },
88            Keyword::Required { .. } => (self.instance_path.clone(), Anchor::Key),
89            Keyword::Type { .. }
90            | Keyword::Enum { .. }
91            | Keyword::Const { .. }
92            | Keyword::Pattern { .. }
93            | Keyword::Minimum { .. }
94            | Keyword::Maximum { .. }
95            | Keyword::MinItems { .. }
96            | Keyword::OneOf { .. }
97            | Keyword::Not
98            | Keyword::False => (self.instance_path.clone(), Anchor::Value),
99        }
100    }
101}
102
103impl ParamsCheck {
104    /// Every way `with` fails the action's schema, one violation per thing
105    /// to fix.
106    ///
107    /// An object with several unexpected keys gives one violation per key, so
108    /// each can point at its own key. A value of the wrong type gives that
109    /// violation alone: the rules about its value do not apply to it.
110    /// Otherwise, of the rules one value breaks, those with an `x-message`
111    /// say more than the generic ones and are the ones kept, and of two
112    /// bounds on the same side only the tighter is.
113    #[must_use]
114    pub fn validate(&self, with: &Value) -> Vec<Violation> {
115        let violations = self.validator.validate(with);
116        let mut mistyped: HashSet<Pointer> = HashSet::new();
117        let mut explained: HashSet<Pointer> = HashSet::new();
118        let mut lowest_maximum: HashMap<Pointer, f64> = HashMap::new();
119        let mut highest_minimum: HashMap<Pointer, f64> = HashMap::new();
120        for violation in &violations {
121            let at = violation.instance_path.clone();
122            match &violation.keyword {
123                Keyword::Type { .. } => {
124                    mistyped.insert(at);
125                }
126                Keyword::Maximum { limit } => {
127                    let limit = limit.as_f64().unwrap_or(f64::INFINITY);
128                    let lowest = lowest_maximum.entry(at.clone()).or_insert(limit);
129                    *lowest = lowest.min(limit);
130                }
131                Keyword::Minimum { limit } => {
132                    let limit = limit.as_f64().unwrap_or(f64::NEG_INFINITY);
133                    let highest = highest_minimum.entry(at.clone()).or_insert(limit);
134                    *highest = highest.max(limit);
135                }
136                Keyword::Enum { .. }
137                | Keyword::Const { .. }
138                | Keyword::Pattern { .. }
139                | Keyword::MinItems { .. }
140                | Keyword::Required { .. }
141                | Keyword::AdditionalProperties { .. }
142                | Keyword::OneOf { .. }
143                | Keyword::Not
144                | Keyword::False => {}
145            }
146            if self.explains(violation) {
147                explained.insert(violation.instance_path.clone());
148            }
149        }
150        // Two rules can set the same bound; it is reported once.
151        let mut bounded: HashSet<(Pointer, bool)> = HashSet::new();
152        let kept = |violation: &Violation| {
153            let at = &violation.instance_path;
154            let tightest = |limits: &HashMap<Pointer, f64>, limit: &Value| {
155                limits.get(at).copied() == limit.as_f64()
156            };
157            // A type error, and each key, is reported on its own.
158            if is_about_type_or_key(&violation.keyword) {
159                return true;
160            }
161            if mistyped.contains(at) {
162                return false;
163            }
164            if explained.contains(at) {
165                return self.explains(violation);
166            }
167            match &violation.keyword {
168                Keyword::Maximum { limit } => {
169                    tightest(&lowest_maximum, limit) && bounded.insert((at.clone(), true))
170                }
171                Keyword::Minimum { limit } => {
172                    tightest(&highest_minimum, limit) && bounded.insert((at.clone(), false))
173                }
174                Keyword::Type { .. }
175                | Keyword::Required { .. }
176                | Keyword::AdditionalProperties { .. }
177                | Keyword::Enum { .. }
178                | Keyword::Const { .. }
179                | Keyword::Pattern { .. }
180                | Keyword::MinItems { .. }
181                | Keyword::OneOf { .. }
182                | Keyword::Not
183                | Keyword::False => true,
184            }
185        };
186        violations
187            .into_iter()
188            .filter(kept)
189            .flat_map(|violation| {
190                if let Keyword::AdditionalProperties { unexpected } = &violation.keyword
191                    && unexpected.len() > 1
192                {
193                    return unexpected
194                        .iter()
195                        .map(|key| Violation {
196                            keyword: Keyword::AdditionalProperties {
197                                unexpected: vec![key.clone()],
198                            },
199                            ..violation.clone()
200                        })
201                        .collect();
202                }
203                vec![violation]
204            })
205            .collect()
206    }
207
208    /// Whether the schema words this violation with an `x-message`. A type
209    /// error or a key never is: see [`message`](Self::message).
210    fn explains(&self, violation: &Violation) -> bool {
211        !is_about_type_or_key(&violation.keyword)
212            && x_message(&self.schema, &violation.schema_path).is_some()
213    }
214
215    /// The violation in words, for the author of the step holding `with`.
216    ///
217    /// A violation about a value reads as the rule, then the value found when
218    /// `values` allows it. Keys are always named: they are the definition's,
219    /// never a secret.
220    #[must_use]
221    pub fn message(
222        &self,
223        action: ActionType,
224        with: &Value,
225        violation: &Violation,
226        values: Values,
227    ) -> String {
228        let at = &violation.instance_path;
229        let field = field_name(at);
230        match &violation.keyword {
231            Keyword::AdditionalProperties { unexpected } => {
232                let accepted = self.accepted(&violation.schema_path);
233                let keys = unexpected.join("', '");
234                if !at.segments().is_empty() {
235                    format!(
236                        "'{field}' has no '{keys}' field. Accepted: {}",
237                        accepted.join(", ")
238                    )
239                } else if accepted.is_empty() {
240                    format!("action '{action}' takes no 'with:' parameters, found '{keys}'")
241                } else {
242                    format!(
243                        "action '{action}' has no '{keys}' parameter. Accepted: {}",
244                        accepted.join(", ")
245                    )
246                }
247            }
248            Keyword::Required { property } => {
249                if at.segments().is_empty() {
250                    format!("action '{action}' requires 'with.{property}'")
251                } else {
252                    format!("'{field}' requires '{property}'")
253                }
254            }
255            // A value of the wrong type has not reached the rule an
256            // `x-message` explains, so it reads the same everywhere.
257            keyword @ Keyword::Type { .. } => found(
258                self.rule(keyword, &violation.schema_path, &field),
259                values,
260                at.lookup(with),
261            ),
262            keyword @ (Keyword::Enum { .. }
263            | Keyword::Const { .. }
264            | Keyword::Pattern { .. }
265            | Keyword::Minimum { .. }
266            | Keyword::Maximum { .. }
267            | Keyword::MinItems { .. }
268            | Keyword::OneOf { .. }
269            | Keyword::Not
270            | Keyword::False) => {
271                let rule = x_message(&self.schema, &violation.schema_path).map_or_else(
272                    || self.rule(keyword, &violation.schema_path, &field),
273                    str::to_string,
274                );
275                found(rule, values, at.lookup(with))
276            }
277        }
278    }
279
280    /// What a keyword about a value asks of `field`, in words.
281    fn rule(&self, keyword: &Keyword, keyword_at: &Pointer, field: &str) -> String {
282        let options = match keyword {
283            Keyword::Enum { options } => Some(options.clone()),
284            // A `oneOf` of constants is an enumeration whose values each
285            // carry a description, and reads as one.
286            Keyword::OneOf { .. } => self.constants(keyword_at),
287            Keyword::Type { .. }
288            | Keyword::Const { .. }
289            | Keyword::Pattern { .. }
290            | Keyword::Minimum { .. }
291            | Keyword::Maximum { .. }
292            | Keyword::MinItems { .. }
293            | Keyword::Required { .. }
294            | Keyword::AdditionalProperties { .. }
295            | Keyword::Not
296            | Keyword::False => None,
297        };
298        if let Some(options) = options {
299            return match options.as_slice() {
300                [only] => format!("'{field}' must be {}", shown(only)),
301                _ => format!(
302                    "'{field}' must be one of: {}",
303                    options.iter().map(plain).collect::<Vec<_>>().join(", ")
304                ),
305            };
306        }
307        match keyword {
308            Keyword::Type { expected } => {
309                let types: Vec<&str> = expected.iter().map(|t| type_name(t)).collect();
310                format!("'{field}' must be {}", types.join(" or "))
311            }
312            Keyword::Const { expected } => format!("'{field}' must be {}", shown(expected)),
313            Keyword::Pattern { pattern } => format!("'{field}' must match {pattern}"),
314            Keyword::Minimum { limit } => format!("'{field}' must be at least {limit}"),
315            Keyword::Maximum { limit } => format!("'{field}' must be at most {limit}"),
316            Keyword::MinItems { limit } => format!("'{field}' needs at least {limit} entries"),
317            Keyword::OneOf {
318                matched: Matched::None,
319            } => format!("'{field}' takes none of the forms it can have"),
320            Keyword::OneOf {
321                matched: Matched::Several,
322            } => format!("'{field}' takes more than one of the forms it can have"),
323            Keyword::Not | Keyword::False => format!("'{field}' is not accepted here"),
324            // Worded by `message`, which names the keys involved; and an
325            // enumeration is worded above.
326            Keyword::Enum { .. }
327            | Keyword::Required { .. }
328            | Keyword::AdditionalProperties { .. } => format!("'{field}' is not accepted"),
329        }
330    }
331
332    /// The values of the `oneOf` at `keyword`, when every alternative is a
333    /// `const`.
334    fn constants(&self, keyword: &Pointer) -> Option<Vec<Value>> {
335        keyword
336            .lookup(&self.schema)?
337            .as_array()?
338            .iter()
339            .map(|alternative| alternative.get("const").cloned())
340            .collect()
341    }
342
343    /// The keys the object whose `additionalProperties` is at `keyword`
344    /// accepts.
345    fn accepted(&self, keyword: &Pointer) -> Vec<String> {
346        keyword
347            .parent()
348            .and_then(|object| object.join("properties").lookup(&self.schema))
349            .and_then(Value::as_object)
350            .map(|properties| properties.keys().cloned().collect())
351            .unwrap_or_default()
352    }
353}
354
355/// Whether `keyword` is about the value's type or about a key, which the
356/// messages word themselves, rather than about the value.
357fn is_about_type_or_key(keyword: &Keyword) -> bool {
358    match keyword {
359        Keyword::Type { .. } | Keyword::Required { .. } | Keyword::AdditionalProperties { .. } => {
360            true
361        }
362        Keyword::Enum { .. }
363        | Keyword::Const { .. }
364        | Keyword::Pattern { .. }
365        | Keyword::Minimum { .. }
366        | Keyword::Maximum { .. }
367        | Keyword::MinItems { .. }
368        | Keyword::OneOf { .. }
369        | Keyword::Not
370        | Keyword::False => false,
371    }
372}
373
374/// The `x-message` for the keyword at `keyword`: on the object that owns it,
375/// or on one reaching that object through applicators only.
376fn x_message<'a>(schema: &'a Value, keyword: &Pointer) -> Option<&'a str> {
377    let mut at = keyword.parent()?;
378    loop {
379        if let Some(message) = at
380            .lookup(schema)
381            .and_then(|object| object.get("x-message"))
382            .and_then(Value::as_str)
383        {
384            return Some(message);
385        }
386        let segments = at.segments();
387        at = match segments {
388            [.., applicator, index]
389                if matches!(applicator.as_str(), "allOf" | "oneOf")
390                    && index.parse::<usize>().is_ok() =>
391            {
392                at.parent()?.parent()?
393            }
394            [.., applicator] if matches!(applicator.as_str(), "if" | "then" | "not") => {
395                at.parent()?
396            }
397            _ => return None,
398        };
399    }
400}
401
402/// `rule`, followed by the scalar `value` it is about when `values` allows.
403fn found(rule: String, values: Values, value: Option<&Value>) -> String {
404    match (values, value) {
405        (Values::Shown, Some(value)) if is_scalar(value) => {
406            format!("{rule}, found {}", shown(value))
407        }
408        (Values::Shown | Values::Withheld, _) => rule,
409    }
410}
411
412/// `with.policy.usages[0]` for the pointer `/policy/usages/0`.
413fn field_name(at: &Pointer) -> String {
414    let mut name = "with".to_string();
415    for segment in at.segments() {
416        if segment.parse::<usize>().is_ok() {
417            name.push('[');
418            name.push_str(segment);
419            name.push(']');
420        } else {
421            name.push('.');
422            name.push_str(segment);
423        }
424    }
425    name
426}
427
428fn type_name(name: &str) -> &str {
429    match name {
430        "string" => "a string",
431        "integer" => "an integer",
432        "number" => "a number",
433        "boolean" => "true or false",
434        "array" => "a list",
435        "object" => "a map",
436        "null" => "empty",
437        other => other,
438    }
439}
440
441fn is_scalar(value: &Value) -> bool {
442    !matches!(value, Value::Object(_) | Value::Array(_))
443}
444
445/// A value as a message quotes it: a string in single quotes, anything else
446/// as JSON.
447fn shown(value: &Value) -> String {
448    match value {
449        Value::String(text) => format!("'{text}'"),
450        other => other.to_string(),
451    }
452}
453
454/// A value in a list of alternatives, unquoted.
455fn plain(value: &Value) -> String {
456    match value {
457        Value::String(text) => text.clone(),
458        other => other.to_string(),
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use serde_json::json;
466
467    fn messages(action: ActionType, with: &Value) -> Vec<String> {
468        let check = params_check(action).unwrap();
469        check
470            .validate(with)
471            .iter()
472            .map(|violation| check.message(action, with, violation, Values::Shown))
473            .collect()
474    }
475
476    fn sole(action: ActionType, with: &Value) -> String {
477        let found = messages(action, with);
478        let [message] = found.as_slice() else {
479            panic!("expected one message, got {found:?}");
480        };
481        message.clone()
482    }
483
484    #[test]
485    fn each_keyword_has_its_sentence() {
486        assert_eq!(
487            sole(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"})),
488            "'with.algorithm' must be one of: RSA-2048, RSA-4096, ECDSA-P256, ECDSA-P384, \
489             Ed25519, ML-DSA-44, ML-DSA-65, ML-DSA-87, ML-KEM-512, ML-KEM-768, ML-KEM-1024, \
490             AES-128, AES-256, found 'RSA-9999'"
491        );
492        assert_eq!(
493            sole(ActionType::EncryptData, &json!({"scheme": "AES-256-KW"})),
494            "'with.scheme' must be 'CMS-AES-256-GCM', found 'AES-256-KW'"
495        );
496        assert_eq!(
497            sole(
498                ActionType::IssueCertificate,
499                &json!({"validity_days": "ten"})
500            ),
501            "'with.validity_days' must be an integer, found 'ten'"
502        );
503        assert_eq!(
504            sole(
505                ActionType::SplitSecret,
506                &json!({"threshold": 2, "shares": 101})
507            ),
508            "'with.shares' must be at most 100, found 101"
509        );
510        assert_eq!(
511            sole(ActionType::CheckValue, &json!({"actual": "x"})),
512            "action 'check_value' requires 'with.expected'"
513        );
514        assert_eq!(
515            sole(ActionType::Confirm, &json!({"statement": "x"})),
516            "action 'confirm' has no 'statement' parameter. Accepted: message"
517        );
518        assert_eq!(
519            sole(ActionType::ExportPublic, &json!({"x": 1})),
520            "action 'export_public' takes no 'with:' parameters, found 'x'"
521        );
522        assert_eq!(
523            sole(
524                ActionType::GenerateKey,
525                &json!({"policy": {"usages": ["sign", "fly"]}})
526            ),
527            "'with.policy.usages[1]' must be one of: sign, verify, encrypt, decrypt, wrap, \
528             unwrap, derive, found 'fly'"
529        );
530        assert!(
531            sole(
532                ActionType::GenerateKey,
533                &json!({"policy": {"extractible": true}})
534            )
535            .starts_with("'with.policy' has no 'extractible' field. Accepted: ")
536        );
537    }
538
539    #[test]
540    fn an_x_message_replaces_the_generic_sentence() {
541        assert_eq!(
542            sole(
543                ActionType::WrapKey,
544                &json!({"expect_recipient": "deadbeef"})
545            ),
546            "'expect_recipient' must be a fingerprint of the form \
547             'sha256:<64 lowercase hex digits>', found 'deadbeef'"
548        );
549        assert_eq!(
550            sole(
551                ActionType::EnterSecret,
552                &json!({"message": "m", "length": 4, "min_length": 2})
553            ),
554            "'length' is exact, so 'min_length' and 'max_length' cannot be given beside it"
555        );
556        assert!(
557            sole(
558                ActionType::EnterValue,
559                &json!({"message": "m", "format": "paper32"})
560            )
561            .contains("use enter_secret")
562        );
563        assert!(
564            sole(
565                ActionType::EnterShare,
566                &json!({"message": "m", "format": "bip39"})
567            )
568            .starts_with("a share is written in paper32 or hex")
569        );
570        assert!(
571            sole(
572                ActionType::EnterSecret,
573                &json!({"message": "m", "format": "wibble"})
574            )
575            .starts_with("'format' must name a format (text, digits")
576        );
577    }
578
579    /// Several unknown keys are several diagnostics, each at its own key.
580    #[test]
581    fn each_unexpected_key_is_its_own_violation() {
582        let check = params_check(ActionType::Confirm).unwrap();
583        let found = check.validate(&json!({"a": 1, "b": 2}));
584        let anchors: Vec<_> = found.iter().map(Violation::anchor).collect();
585        assert_eq!(
586            anchors,
587            [
588                (Pointer::parse("/a"), Anchor::Key),
589                (Pointer::parse("/b"), Anchor::Key)
590            ]
591        );
592    }
593
594    /// What the schema refuses and accepts, case by case: each refusal fails
595    /// in the room if it reaches it, after keys exist.
596    #[test]
597    #[allow(clippy::too_many_lines)]
598    fn the_schema_refuses_values_wrong_in_every_build() {
599        for (action, with, says) in [
600            (
601                ActionType::SignData,
602                json!({"algorithm": "RSA-PSS-SHA1"}),
603                "must be one of",
604            ),
605            (
606                ActionType::IssueCertificate,
607                json!({"profile": "wildcard"}),
608                "must be one of",
609            ),
610            (
611                ActionType::UnwrapKey,
612                json!({"algorithm": "AES-192"}),
613                "must be one of",
614            ),
615            (
616                ActionType::WrapKey,
617                json!({"scheme": "CMS-RSA-CBC"}),
618                "must be one of",
619            ),
620            (
621                ActionType::EncryptData,
622                json!({"scheme": "CMS-RSA-CBC"}),
623                "must be",
624            ),
625            (
626                ActionType::GenerateKey,
627                json!({"algorithm": 4096}),
628                "must be a string",
629            ),
630            // The runtime renders every fingerprint in lowercase, so an
631            // uppercase one would never match the key it names.
632            (
633                ActionType::WrapKey,
634                json!({"expect_recipient": format!("sha256:{}", "AB".repeat(32))}),
635                "lowercase",
636            ),
637            (
638                ActionType::UnwrapKey,
639                json!({"expect_key": "cmac-aes:763CBC"}),
640                "lowercase",
641            ),
642            // A check value is three bytes, so a fingerprint's length under
643            // the check value's prefix is not one.
644            (
645                ActionType::UnwrapKey,
646                json!({"expect_key": format!("cmac-aes:{}", "ab".repeat(32))}),
647                "6 lowercase hex digits",
648            ),
649            (
650                ActionType::UnwrapKey,
651                json!({"expect_key": "763cbc"}),
652                "cmac-aes:",
653            ),
654            (
655                ActionType::Reveal,
656                json!({"message": "m", "format": "words"}),
657                "must be one of",
658            ),
659            (
660                ActionType::Reveal,
661                json!({"message": "m", "length": 0}),
662                "at least 1",
663            ),
664            (
665                ActionType::Reveal,
666                json!({"message": "m", "format": "bip39", "length": 32}),
667                "counts words",
668            ),
669            (
670                ActionType::EnterShare,
671                json!({"message": "m", "length": 3}),
672                "longer than 3",
673            ),
674            (
675                ActionType::SplitSecret,
676                json!({"threshold": 1, "shares": 3}),
677                "at least 2",
678            ),
679            (
680                ActionType::SplitSecret,
681                json!({"threshold": "two", "shares": 3}),
682                "an integer",
683            ),
684            (
685                ActionType::SplitSecret,
686                json!({"scheme": "sss/v9", "threshold": 2, "shares": 3}),
687                "rite-sss/v1",
688            ),
689            (
690                ActionType::EnterValue,
691                json!({"message": "m", "format": {"pattern": "[0-9]+"}, "length": 6}),
692                "a pattern says the whole shape",
693            ),
694            (
695                ActionType::EnterSecret,
696                json!({"message": "m", "format": "emoji"}),
697                "must name a format",
698            ),
699            (
700                ActionType::EnterSecret,
701                json!({"message": "m", "format": {"regex": "[0-9]+"}}),
702                "must name a format",
703            ),
704            (
705                ActionType::EnterSecret,
706                json!({"message": "m", "length": "six"}),
707                "an integer",
708            ),
709            (
710                ActionType::EnterValue,
711                json!({"message": "m", "format": "bip39"}),
712                "enter_secret",
713            ),
714            (
715                ActionType::OralReadback,
716                json!({"format": "nato_phonetik"}),
717                "must be one of",
718            ),
719            (
720                ActionType::PivSign,
721                json!({"algorithm": "Ed25519"}),
722                "must be one of",
723            ),
724            (
725                ActionType::PivReadCertificate,
726                json!({"slot": "9b"}),
727                "PIV slot",
728            ),
729            (
730                ActionType::IssueCertificate,
731                json!({"path_len": 300}),
732                "at most 255",
733            ),
734            (
735                ActionType::GenerateKey,
736                json!({"policy": {"extractable": "yes"}}),
737                "true or false",
738            ),
739        ] {
740            let found = messages(action, &with);
741            assert!(
742                found.iter().any(|message| message.contains(says)),
743                "{action} {with}: {found:?}"
744            );
745        }
746    }
747
748    /// The values the old checks accepted, the schema accepts.
749    #[test]
750    fn the_schema_accepts_the_vocabulary() {
751        let fingerprint = format!("sha256:{}", "ab".repeat(32));
752        for (action, with) in [
753            (ActionType::GenerateKey, json!({"algorithm": "RSA-4096"})),
754            (ActionType::SignData, json!({"algorithm": "RSA-PSS-SHA256"})),
755            (ActionType::IssueCertificate, json!({"profile": "root_ca"})),
756            (
757                ActionType::IssueCertificate,
758                json!({"profile": "intermediate_ca"}),
759            ),
760            (ActionType::WrapKey, json!({"scheme": "AES-256-KW"})),
761            (
762                ActionType::WrapKey,
763                json!({"expect_recipient": fingerprint.clone()}),
764            ),
765            (
766                ActionType::EncryptData,
767                json!({"scheme": "CMS-AES-256-GCM"}),
768            ),
769            (ActionType::UnwrapKey, json!({"expect_key": fingerprint})),
770            (
771                ActionType::UnwrapKey,
772                json!({"algorithm": "AES-256", "expect_key": "cmac-aes:763cbc"}),
773            ),
774            (
775                ActionType::GenerateKey,
776                json!({"policy": {"usages": ["sign", "verify", "wrap", "unwrap", "derive"]}}),
777            ),
778            (
779                ActionType::Reveal,
780                json!({"message": "m", "format": "paper32", "length": 32}),
781            ),
782            (
783                ActionType::Reveal,
784                json!({"message": "m", "format": "bip39", "length": 24}),
785            ),
786            (
787                ActionType::EnterShare,
788                json!({"message": "m", "length": 35}),
789            ),
790            (
791                ActionType::SplitSecret,
792                json!({"threshold": 2, "shares": 100}),
793            ),
794            (
795                ActionType::EnterSecret,
796                json!({"message": "m", "format": "paper32", "length": 32}),
797            ),
798            (ActionType::OralReadback, json!({"format": "nato"})),
799            (
800                ActionType::PivSign,
801                json!({"slot": "piv:9d", "algorithm": "ECDSA-SHA384"}),
802            ),
803            (ActionType::PivReadCertificate, json!({"slot": "8a"})),
804        ] {
805            assert_eq!(
806                messages(action, &with),
807                Vec::<String>::new(),
808                "{action} {with}"
809            );
810        }
811    }
812
813    /// An `x-message` says the rule; the value found follows it as it
814    /// follows a generic one.
815    #[test]
816    fn an_x_message_is_followed_by_the_value_found() {
817        assert_eq!(
818            sole(
819                ActionType::Reveal,
820                &json!({"message": "m", "format": "bip39", "length": 32})
821            ),
822            "'length' counts words for 'bip39', and a phrase has 12, 15, 18, 21 or 24 words, \
823             found 32"
824        );
825    }
826
827    /// A value of the wrong type is told so, in the same words everywhere,
828    /// and once: the `x-message` and the names that a string may take are
829    /// about values of the right type.
830    #[test]
831    fn a_wrong_type_is_one_message_in_plain_words() {
832        // Unquoted, so YAML reads it as a number.
833        assert_eq!(
834            sole(ActionType::PivReadCertificate, &json!({"slot": 82})),
835            "'with.slot' must be a string, found 82"
836        );
837        assert_eq!(
838            sole(
839                ActionType::EnterShare,
840                &json!({"message": "m", "length": "35"})
841            ),
842            "'with.length' must be an integer, found '35'"
843        );
844        assert_eq!(
845            sole(ActionType::EncryptData, &json!({"scheme": 5})),
846            "'with.scheme' must be a string, found 5"
847        );
848    }
849
850    /// A value breaking several rules gets the one that says most: an
851    /// `x-message` over a generic rule, the tighter of two bounds. And an
852    /// `x-message` speaks only for the rule it explains.
853    #[test]
854    fn one_value_gets_the_message_that_says_most() {
855        // The u8 bound is 255; the scheme's limit is tighter.
856        assert_eq!(
857            sole(
858                ActionType::SplitSecret,
859                &json!({"threshold": 2, "shares": 300})
860            ),
861            "'with.shares' must be at most 100, found 300"
862        );
863        // Below the type's bound and outside the phrase lengths.
864        assert_eq!(
865            sole(
866                ActionType::Reveal,
867                &json!({"message": "m", "format": "bip39", "length": 0})
868            ),
869            "'length' counts words for 'bip39', and a phrase has 12, 15, 18, 21 or 24 words, \
870             found 0"
871        );
872        // A share is never words; a typo is just not a format.
873        assert_eq!(
874            sole(
875                ActionType::EnterShare,
876                &json!({"message": "m", "format": "bip39"})
877            ),
878            "a share is written in paper32 or hex; words are for the secret the shares \
879             combine into, found 'bip39'"
880        );
881        assert_eq!(
882            sole(
883                ActionType::EnterShare,
884                &json!({"message": "m", "format": "hexx"})
885            ),
886            "'with.format' must be one of: paper32, hex, found 'hexx'"
887        );
888        // The header message is the minimum's, not the type's bound.
889        assert_eq!(
890            sole(
891                ActionType::EnterShare,
892                &json!({"message": "m", "length": 2})
893            ),
894            "'length' is a share's secret plus 3 header bytes, so it is longer than 3, found 2"
895        );
896        assert!(
897            sole(
898                ActionType::EnterShare,
899                &json!({"message": "m", "length": 9_007_199_254_740_992_u64})
900            )
901            .starts_with("'with.length' must be at most ")
902        );
903    }
904
905    /// An evaluated value may hold a secret an expression copied in, so a
906    /// message about it names the field and the rule only.
907    #[test]
908    fn a_withheld_value_is_never_quoted() {
909        let check = params_check(ActionType::UnwrapKey).unwrap();
910        for with in [
911            json!({"expect_key": "c2VjcmV0LWtleS1tYXRlcmlhbA=="}),
912            json!({"algorithm": "c2VjcmV0LWtleS1tYXRlcmlhbA=="}),
913            json!({"label": 1_234_567}),
914        ] {
915            let found = check.validate(&with);
916            assert!(!found.is_empty(), "{with}");
917            for violation in &found {
918                let message =
919                    check.message(ActionType::UnwrapKey, &with, violation, Values::Withheld);
920                assert!(
921                    !message.contains("c2Vj") && !message.contains("1234567"),
922                    "{message}"
923                );
924                assert!(!message.contains("found"), "{message}");
925            }
926        }
927    }
928
929    #[test]
930    fn every_action_has_a_check() {
931        for &action in ActionType::ALL {
932            assert!(params_check(action).is_ok(), "{action}");
933        }
934    }
935}