Skip to main content

rite_model/
params.rs

1//! Rules over a step's `with:` values that hold in every build.
2//!
3//! These sit beside [`ActionType::required_with_fields`] and
4//! [`ActionType::reads_contract`]: what an action requires of its block, in
5//! one place both the resolver and the runtime can read. Keeping them here lets
6//! an editor report them, since the language server links the model and the
7//! resolver but no backend.
8//!
9//! Only rules that travel with the document belong here. `RSA-9999` is not a
10//! key algorithm on any machine, so rejecting it is portable. Whether *this*
11//! binary can generate an ML-DSA key is a property of the build doing the
12//! checking, and that question is answered by the action handler instead, at
13//! the point where a backend exists to ask.
14
15use serde::{Deserialize, Serialize};
16
17use crate::transcript::{Format, ValidatorSpec, compile_pattern};
18use crate::types::{ActionType, CertProfile, SharingScheme};
19use rite_sdk::{KeyAlgorithm, KeyUsages, SignAlgorithm, WrapScheme};
20
21/// A `with:` value an action cannot accept.
22///
23/// The message names the field it is about, so a reader with no span still
24/// knows where to look.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ParamError {
27    /// What is wrong, phrased for the ceremony author.
28    pub message: String,
29}
30
31/// Check the literal part of a step's `with:` block.
32///
33/// `with` is the projection produced by
34/// [`literal_expr_value`](crate::expression::literal_expr_value), so a field
35/// whose value is an expression is *absent* rather than wrong. Nothing here
36/// may report a field as missing: required fields are
37/// [`ActionType::required_with_fields`]'s job, and a required field is
38/// routinely written as `${param.x}`.
39#[must_use]
40pub fn check(action: ActionType, with: &serde_json::Value) -> Vec<ParamError> {
41    match action {
42        ActionType::GenerateKey => {
43            let mut errors = named_value(with, "algorithm", |name| {
44                name.parse::<KeyAlgorithm>()
45                    .map_err(|_| format!("unknown key algorithm '{name}'"))
46            });
47            errors.extend(key_usages(with));
48            errors
49        }
50        ActionType::SignData | ActionType::VerifySignature => {
51            named_value(with, "algorithm", |name| {
52                name.parse::<SignAlgorithm>()
53                    .map_err(|_| format!("unknown signature algorithm '{name}'"))
54            })
55        }
56        ActionType::IssueCertificate => named_value(with, "profile", |name| {
57            name.parse::<CertProfile>()
58                .map_err(|_| format!("unknown certificate profile '{name}'"))
59        }),
60        ActionType::WrapKey => {
61            let mut errors = fingerprint(with, "expect_recipient");
62            errors.extend(scheme(with, |_| Ok(())));
63            errors
64        }
65        // Narrower than `wrap_key`'s vocabulary on purpose: the container is
66        // the only thing `encrypt_data` writes, and a name it would refuse
67        // mid-ceremony belongs in `rite check` rather than in the room.
68        ActionType::EncryptData => {
69            named_value(with, "scheme", |name| match name.parse::<WrapScheme>() {
70                Ok(WrapScheme::CmsAes256Gcm) => Ok(()),
71                Ok(other) => Err(format!(
72                    "encrypt_data writes {}, not {other}",
73                    WrapScheme::CmsAes256Gcm
74                )),
75                Err(_) => Err(format!("unknown wrapping scheme '{name}'")),
76            })
77        }
78        ActionType::SplitSecret => {
79            let mut errors = named_value(with, "scheme", |name| {
80                name.parse::<SharingScheme>()
81                    .map(|_| ())
82                    .map_err(|e| format!("unknown sharing scheme '{name}'; {e}"))
83            });
84            // The limit is the named scheme's, or the default's when the
85            // scheme is absent or not a literal; a scheme that fails to parse
86            // is reported above and the counts are still checked against
87            // something.
88            let scheme = with
89                .get("scheme")
90                .and_then(serde_json::Value::as_str)
91                .and_then(|name| name.parse::<SharingScheme>().ok())
92                .unwrap_or(SharingScheme::RiteSssV1);
93            let max = u64::from(scheme.max_shares());
94            errors.extend(share_count(with, "threshold", max));
95            errors.extend(share_count(with, "shares", max));
96            if let (Some(threshold), Some(shares)) = (
97                with.get("threshold").and_then(serde_json::Value::as_u64),
98                with.get("shares").and_then(serde_json::Value::as_u64),
99            ) && shares < threshold
100            {
101                errors.push(ParamError {
102                    message: format!(
103                        "'shares' is {shares} and 'threshold' is {threshold}; fewer shares than \
104                         the threshold could never reconstruct the secret"
105                    ),
106                });
107            }
108            errors
109        }
110        ActionType::UnwrapKey | ActionType::ImportKey => {
111            let mut errors = key_identity(with, "expect_key");
112            errors.extend(named_value(with, "algorithm", |name| {
113                name.parse::<KeyAlgorithm>()
114                    .map_err(|_| format!("unknown key algorithm '{name}'"))
115            }));
116            errors
117        }
118        ActionType::EnterValue | ActionType::EnterSecret => entry_shape(with),
119
120        ActionType::ClockCheck
121        | ActionType::Confirm
122        | ActionType::CheckValue
123        | ActionType::OralReadback
124        | ActionType::MachineInfo
125        | ActionType::ExportPublic
126        | ActionType::Attest
127        | ActionType::GatherEntropy
128        | ActionType::TpmAttest
129        | ActionType::PivReadCertificate
130        | ActionType::PivSign
131        | ActionType::YubikeyAttestSlot
132        | ActionType::DecryptData
133        | ActionType::CombineShares
134        | ActionType::GenerateCsr => Vec::new(),
135    }
136}
137
138/// A share count or threshold: an integer from 2 to the scheme's limit.
139///
140/// One is not a split. An absent field is deferred, as everywhere here; a
141/// present one that is not an integer is wrong whatever it would have parsed
142/// to.
143fn share_count(with: &serde_json::Value, field: &'static str, max: u64) -> Vec<ParamError> {
144    let Some(value) = with.get(field) else {
145        return Vec::new();
146    };
147    let message = match value.as_u64() {
148        Some(n) if (2..=max).contains(&n) => return Vec::new(),
149        Some(n) => format!("'{field}' is {n}; it must be from 2 to {max}"),
150        None => format!("'{field}' must be an integer from 2 to {max}, found {value}"),
151    };
152    vec![ParamError { message }]
153}
154
155/// The container a step names, checked against the vocabulary rather than left
156/// to fail at the backend, and then against what the action itself writes.
157///
158/// One message for a name outside the vocabulary, whichever action asked, and
159/// `accept` for the narrowing an action does on top of that.
160fn scheme(
161    with: &serde_json::Value,
162    accept: impl FnOnce(WrapScheme) -> Result<(), String>,
163) -> Vec<ParamError> {
164    named_value(with, "scheme", |name| {
165        let named = name
166            .parse::<WrapScheme>()
167            .map_err(|_| format!("unknown wrapping scheme '{name}'"))?;
168        accept(named)
169    })
170}
171
172/// Apply `parse` to a present string field.
173///
174/// An absent field is deferred, not missing. A field holding something other
175/// than a string is wrong whatever the value would have been.
176fn named_value<T>(
177    with: &serde_json::Value,
178    field: &'static str,
179    parse: impl FnOnce(&str) -> Result<T, String>,
180) -> Vec<ParamError> {
181    let Some(value) = with.get(field) else {
182        return Vec::new();
183    };
184    let Some(name) = value.as_str() else {
185        return vec![ParamError {
186            message: format!("'{field}' must be a string, found {value}"),
187        }];
188    };
189    parse(name)
190        .err()
191        .map(|message| ParamError { message })
192        .into_iter()
193        .collect()
194}
195
196/// The shape an `enter_value` or `enter_secret` step gives the value it asks
197/// for, checked the way the step will build it.
198///
199/// A field still carrying an expression is absent from the projection, so the
200/// rule is built from what is literal, and a conflict between a literal field
201/// and one resolved at run time is found there.
202fn entry_shape(with: &serde_json::Value) -> Vec<ParamError> {
203    let mut errors = Vec::new();
204    let mut length = |field: &'static str| -> Option<usize> {
205        let value = with.get(field)?;
206        match value.as_u64().and_then(|n| usize::try_from(n).ok()) {
207            Some(n) if n > 0 => Some(n),
208            _ => {
209                errors.push(ParamError {
210                    message: format!("'{field}' must be a positive integer, found {value}"),
211                });
212                None
213            }
214        }
215    };
216    let mut shape = EntryShape {
217        length: length("length"),
218        min_length: length("min_length"),
219        max_length: length("max_length"),
220        format: None,
221    };
222    match with.get("format").map(FormatSpec::from_json) {
223        None => {}
224        Some(Ok(format)) => shape.format = Some(format),
225        Some(Err(message)) => errors.push(ParamError { message }),
226    }
227    if let Err(message) = shape.validator() {
228        errors.push(ParamError { message });
229    }
230    errors
231}
232
233/// `format:` as a step writes it: the name of a [`Format`], or a pattern.
234///
235/// A pattern is a format too, one whose argument is the expression, so it is
236/// written under the same key rather than beside it.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(untagged)]
239pub enum FormatSpec {
240    /// `format: hex`
241    Named(Format),
242    /// `format: { pattern: "..." }`
243    Pattern {
244        /// A regular expression the whole value must match.
245        pattern: String,
246    },
247}
248
249impl FormatSpec {
250    /// Read the field, with a message that says what the two forms are.
251    ///
252    /// # Errors
253    ///
254    /// Returns why the value is neither a format name nor a pattern.
255    pub fn from_json(value: &serde_json::Value) -> Result<Self, String> {
256        if let Some(name) = value.as_str() {
257            return name.parse().map(FormatSpec::Named);
258        }
259        match value.get("pattern").and_then(|p| p.as_str()) {
260            Some(pattern) if value.as_object().is_some_and(|m| m.len() == 1) => {
261                Ok(FormatSpec::Pattern {
262                    pattern: pattern.to_string(),
263                })
264            }
265            _ => Err(format!(
266                "'format' must name a format (text, digits, alphanumeric, hex, base64) or be \
267                 {{ pattern: \"...\" }}, found {value}"
268            )),
269        }
270    }
271}
272
273/// What an `enter_value` or `enter_secret` step says about the value it asks
274/// for, as the `with:` fields spell it.
275///
276/// One place turns these into a [`ValidatorSpec`], so `rite check` and the
277/// running step cannot disagree about which combinations mean something.
278#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct EntryShape {
280    /// `format:`, what kind of value it is.
281    pub format: Option<FormatSpec>,
282    /// `length:`, an exact length.
283    pub length: Option<usize>,
284    /// `min_length:`.
285    pub min_length: Option<usize>,
286    /// `max_length:`.
287    pub max_length: Option<usize>,
288}
289
290impl EntryShape {
291    /// The rule these fields describe.
292    ///
293    /// Nothing given asks only for a non-empty value. A pattern says the
294    /// whole shape, so a length beside it is refused rather than combined
295    /// with it in a way the author would have to guess at. Lengths count the
296    /// format's own units: characters for text, bytes for an encoding.
297    ///
298    /// # Errors
299    ///
300    /// Returns why the fields do not describe one rule, phrased for the
301    /// ceremony author.
302    pub fn validator(&self) -> Result<ValidatorSpec, String> {
303        let bounded =
304            self.length.is_some() || self.min_length.is_some() || self.max_length.is_some();
305        let format = match &self.format {
306            Some(FormatSpec::Pattern { pattern }) => {
307                if bounded {
308                    return Err("a pattern says the whole shape of the value, so 'length', \
309                         'min_length' and 'max_length' cannot be given beside it"
310                        .to_string());
311                }
312                compile_pattern(pattern)?;
313                return Ok(ValidatorSpec::Regex(pattern.clone()));
314            }
315            Some(FormatSpec::Named(format)) => Some(*format),
316            None => None,
317        };
318        let (min_length, max_length) = match (self.length, self.min_length, self.max_length) {
319            (Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
320                return Err(
321                    "'length' is exact, so 'min_length' and 'max_length' cannot be given \
322                     beside it"
323                        .to_string(),
324                );
325            }
326            (Some(length), None, None) => (Some(length), Some(length)),
327            (None, min, max) => (min, max),
328        };
329        if let (Some(min), Some(max)) = (min_length, max_length)
330            && min > max
331        {
332            return Err(format!(
333                "'min_length' is {min} and 'max_length' is {max}, so no value fits"
334            ));
335        }
336        match format {
337            None if !bounded => Ok(ValidatorSpec::NonEmpty),
338            format => Ok(ValidatorSpec::Format {
339                format: format.unwrap_or(Format::Text),
340                min_length,
341                max_length,
342            }),
343        }
344    }
345}
346
347/// Check the names under `policy: { usages: [...] }`.
348///
349/// These are PKCS#11 usages, what the token permits the key to do. The X.509
350/// `KeyUsage` extension is a different thing and comes from `profile:` on
351/// `issue_certificate`.
352fn key_usages(with: &serde_json::Value) -> Vec<ParamError> {
353    const FIELD: &str = "policy.usages";
354    let Some(usages) = with.get("policy").and_then(|p| p.get("usages")) else {
355        return Vec::new();
356    };
357    let Some(names) = usages.as_array() else {
358        return vec![ParamError {
359            message: format!("'{FIELD}' must be a list, found {usages}"),
360        }];
361    };
362    names
363        .iter()
364        .filter_map(|name| {
365            let known = name
366                .as_str()
367                .is_some_and(|name| KeyUsages::usage_named(name).is_some());
368            (!known).then(|| ParamError {
369                message: format!(
370                    "unknown key usage {name} under '{FIELD}'. Supported: {}",
371                    KeyUsages::all_names().join(", ")
372                ),
373            })
374        })
375        .collect()
376}
377
378/// A fingerprint the ceremony declares in advance, `sha256:<64 hex digits>`.
379///
380/// A value in any other shape can never equal what the runtime computes, so
381/// the step would fail in the room, after the keys exist. The runtime renders
382/// every fingerprint in lowercase, so uppercase hex is one such shape and is
383/// rejected here rather than at the step.
384fn fingerprint(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
385    named_value(with, field, |value| {
386        let hex = value.strip_prefix("sha256:").unwrap_or_default();
387        if hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
388            Ok(())
389        } else {
390            Err(format!(
391                "'{field}' must be a fingerprint of the form \
392                 'sha256:<64 lowercase hex digits>', found '{value}'"
393            ))
394        }
395    })
396}
397
398/// A key's identity the ceremony declares in advance, in whichever form the
399/// key has one: `sha256:<64 hex digits>` over the public half of a keypair, or
400/// `cmac-aes:<6 hex digits>` for a symmetric key, which has no public half.
401///
402/// Same argument as [`fingerprint`]: a value in neither shape can never equal
403/// what the runtime computes, so the mismatch is worth reporting before the
404/// room rather than during it.
405fn key_identity(with: &serde_json::Value, field: &'static str) -> Vec<ParamError> {
406    named_value(with, field, |value| {
407        let lower_hex = |s: &str, len: usize| {
408            s.len() == len && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
409        };
410        let matches_form = match value.split_once(':') {
411            Some(("sha256", hex)) => lower_hex(hex, 64),
412            Some(("cmac-aes", hex)) => lower_hex(hex, 6),
413            _ => false,
414        };
415        if matches_form {
416            Ok(())
417        } else {
418            Err(format!(
419                "'{field}' must name the key either as 'sha256:<64 lowercase hex digits>', \
420                 the fingerprint of a public key, or as 'cmac-aes:<6 lowercase hex digits>', \
421                 the check value of a symmetric key. Found '{value}'"
422            ))
423        }
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use serde_json::json;
431
432    fn sole(action: ActionType, with: &serde_json::Value) -> String {
433        let errors = check(action, with);
434        let [error] = errors.as_slice() else {
435            panic!("expected exactly one error, got {errors:?}");
436        };
437        error.message.clone()
438    }
439
440    #[test]
441    fn rejects_a_value_outside_the_vocabulary() {
442        assert!(
443            sole(ActionType::GenerateKey, &json!({"algorithm": "RSA-9999"}))
444                .contains("unknown key algorithm")
445        );
446        assert!(
447            sole(ActionType::SignData, &json!({"algorithm": "RSA-PSS-SHA1"}))
448                .contains("unknown signature algorithm")
449        );
450        assert!(
451            sole(
452                ActionType::IssueCertificate,
453                &json!({"profile": "wildcard"})
454            )
455            .contains("unknown certificate profile")
456        );
457        assert!(
458            sole(
459                ActionType::WrapKey,
460                &json!({"expect_recipient": "deadbeef"})
461            )
462            .contains("sha256:<64 lowercase hex digits>")
463        );
464    }
465
466    /// The runtime renders every fingerprint in lowercase, so an uppercase one
467    /// would pass resolution and then never match, failing in the room against
468    /// the very key it names.
469    #[test]
470    fn rejects_a_fingerprint_that_is_not_lowercase() {
471        let upper = format!("sha256:{}", "AB".repeat(32));
472        let message = sole(ActionType::WrapKey, &json!({ "expect_recipient": upper }));
473        assert!(message.contains("lowercase"), "{message}");
474
475        let lower = format!("sha256:{}", "ab".repeat(32));
476        assert!(
477            check(ActionType::WrapKey, &json!({ "expect_recipient": lower })).is_empty(),
478            "the shape the runtime produces is the shape that passes"
479        );
480    }
481
482    /// `encrypt_data` takes a narrower vocabulary than `wrap_key`: a scheme it
483    /// would refuse mid-ceremony is refused at check time instead.
484    #[test]
485    fn rejects_a_scheme_encrypt_data_does_not_write() {
486        let message = sole(ActionType::EncryptData, &json!({"scheme": "AES-256-KW"}));
487        assert!(
488            message.contains("encrypt_data writes CMS-AES-256-GCM"),
489            "{message}"
490        );
491
492        let message = sole(ActionType::EncryptData, &json!({"scheme": "CMS-RSA-CBC"}));
493        assert!(message.contains("unknown wrapping scheme"), "{message}");
494
495        assert!(
496            check(
497                ActionType::EncryptData,
498                &json!({"scheme": "CMS-AES-256-GCM"})
499            )
500            .is_empty()
501        );
502        assert!(
503            check(ActionType::EncryptData, &json!({})).is_empty(),
504            "deferred"
505        );
506
507        // The same name is fine on a wrap, which does write it.
508        assert!(check(ActionType::WrapKey, &json!({"scheme": "AES-256-KW"})).is_empty());
509    }
510
511    /// A scheme name outside the vocabulary would fail in the room, after the
512    /// keys exist, so resolution rejects it.
513    #[test]
514    fn rejects_a_wrapping_scheme_outside_the_vocabulary() {
515        let message = sole(ActionType::WrapKey, &json!({"scheme": "CMS-RSA-CBC"}));
516        assert!(message.contains("unknown wrapping scheme"), "{message}");
517
518        assert!(
519            check(
520                ActionType::WrapKey,
521                &json!({"scheme": "RSA-AES-KEY-WRAP-SHA256"})
522            )
523            .is_empty()
524        );
525        assert!(
526            check(ActionType::WrapKey, &json!({})).is_empty(),
527            "deferred"
528        );
529    }
530
531    #[test]
532    fn accepts_the_vocabulary() {
533        for (action, with) in [
534            (ActionType::GenerateKey, json!({"algorithm": "RSA-4096"})),
535            (ActionType::SignData, json!({"algorithm": "RSA-PSS-SHA256"})),
536            (ActionType::IssueCertificate, json!({"profile": "root_ca"})),
537            (
538                ActionType::UnwrapKey,
539                json!({"expect_key": format!("sha256:{}", "ab".repeat(32))}),
540            ),
541            (
542                ActionType::UnwrapKey,
543                json!({"algorithm": "AES-256", "expect_key": "cmac-aes:763cbc"}),
544            ),
545        ] {
546            assert!(check(action, &with).is_empty(), "{action} rejected {with}");
547        }
548    }
549
550    /// `expect_key` names the recovered key in whichever form it has one, so
551    /// both are accepted and anything else is refused before the room.
552    #[test]
553    fn expect_key_takes_a_fingerprint_or_a_check_value() {
554        let message = sole(
555            ActionType::UnwrapKey,
556            &json!({"expect_key": "cmac-aes:763CBC"}),
557        );
558        assert!(message.contains("lowercase"), "{message}");
559
560        // A check value is three bytes, so a fingerprint's length under the
561        // check value's prefix is not a check value.
562        let message = sole(
563            ActionType::UnwrapKey,
564            &json!({"expect_key": format!("cmac-aes:{}", "ab".repeat(32))}),
565        );
566        assert!(message.contains("6 lowercase hex digits"), "{message}");
567
568        let message = sole(ActionType::UnwrapKey, &json!({"expect_key": "763cbc"}));
569        assert!(
570            message.contains("sha256:") && message.contains("cmac-aes:"),
571            "{message}"
572        );
573    }
574
575    /// Nothing travels with a wrapped key saying what it is, so `algorithm:`
576    /// declares it, and a name outside the vocabulary fails here rather than
577    /// after the blob has been decrypted.
578    #[test]
579    fn rejects_an_unwrap_algorithm_outside_the_vocabulary() {
580        let message = sole(ActionType::UnwrapKey, &json!({"algorithm": "AES-192"}));
581        assert!(message.contains("unknown key algorithm"), "{message}");
582    }
583
584    #[test]
585    fn an_absent_field_is_deferred_not_missing() {
586        // The projection drops `algorithm: ${param.algo}`, and a required
587        // field written as an expression must not be reported here.
588        assert!(check(ActionType::GenerateKey, &json!({})).is_empty());
589        assert!(check(ActionType::IssueCertificate, &json!({})).is_empty());
590    }
591
592    #[test]
593    fn rejects_an_unknown_key_usage() {
594        let message = sole(
595            ActionType::GenerateKey,
596            &json!({"policy": {"usages": ["sign", "key_cert_sign"]}}),
597        );
598        assert!(message.contains("unknown key usage"), "{message}");
599        assert!(
600            message.contains("wrap"),
601            "the message should list what is available: {message}"
602        );
603    }
604
605    #[test]
606    fn accepts_the_pkcs11_usages() {
607        let with = json!({"policy": {"usages": ["sign", "verify", "wrap", "unwrap", "derive"]}});
608        assert!(check(ActionType::GenerateKey, &with).is_empty());
609    }
610
611    #[test]
612    fn a_non_string_value_is_rejected_whatever_it_would_have_meant() {
613        assert!(
614            sole(ActionType::GenerateKey, &json!({"algorithm": 4096})).contains("must be a string")
615        );
616    }
617
618    #[test]
619    fn split_secret_counts_are_bounded_by_the_scheme() {
620        assert!(
621            check(
622                ActionType::SplitSecret,
623                &json!({"threshold": 2, "shares": 100})
624            )
625            .is_empty()
626        );
627        assert!(
628            sole(
629                ActionType::SplitSecret,
630                &json!({"threshold": 2, "shares": 101})
631            )
632            .contains("from 2 to 100")
633        );
634        assert!(
635            sole(
636                ActionType::SplitSecret,
637                &json!({"threshold": 1, "shares": 3})
638            )
639            .contains("from 2 to 100")
640        );
641        assert!(
642            sole(
643                ActionType::SplitSecret,
644                &json!({"threshold": 4, "shares": 3})
645            )
646            .contains("fewer shares than the threshold")
647        );
648        assert!(
649            sole(
650                ActionType::SplitSecret,
651                &json!({"threshold": "two", "shares": 3})
652            )
653            .contains("must be an integer")
654        );
655        assert!(
656            sole(
657                ActionType::SplitSecret,
658                &json!({"scheme": "sss/v9", "threshold": 2, "shares": 3})
659            )
660            .contains("unknown sharing scheme")
661        );
662        // A `${param}` threshold is absent from the literal projection and is
663        // deferred, not reported.
664        assert!(check(ActionType::SplitSecret, &json!({"shares": 3})).is_empty());
665    }
666
667    /// One rule from the shape fields, the same one the step will apply.
668    #[test]
669    fn an_entry_shape_becomes_one_rule() {
670        let shape = EntryShape {
671            format: Some(FormatSpec::Named(Format::Digits)),
672            length: Some(6),
673            ..EntryShape::default()
674        };
675        assert!(matches!(
676            shape.validator(),
677            Ok(ValidatorSpec::Format {
678                format: Format::Digits,
679                min_length: Some(6),
680                max_length: Some(6),
681            })
682        ));
683
684        // Bounds alone: the format is text.
685        let shape = EntryShape {
686            max_length: Some(8),
687            ..EntryShape::default()
688        };
689        assert!(matches!(
690            shape.validator(),
691            Ok(ValidatorSpec::Format {
692                format: Format::Text,
693                min_length: None,
694                max_length: Some(8),
695            })
696        ));
697
698        assert!(matches!(
699            EntryShape::default().validator(),
700            Ok(ValidatorSpec::NonEmpty)
701        ));
702        assert!(matches!(
703            EntryShape {
704                format: Some(FormatSpec::Pattern {
705                    pattern: "[a-z]+".to_string()
706                }),
707                ..EntryShape::default()
708            }
709            .validator(),
710            Ok(ValidatorSpec::Regex(_))
711        ));
712    }
713
714    #[test]
715    fn rejects_an_entry_shape_that_is_two_rules() {
716        assert!(
717            sole(
718                ActionType::EnterValue,
719                &json!({"format": {"pattern": "[0-9]+"}, "length": 6})
720            )
721            .contains("pattern")
722        );
723        assert!(
724            sole(
725                ActionType::EnterSecret,
726                &json!({"length": 6, "min_length": 4})
727            )
728            .contains("'length' is exact")
729        );
730        assert!(
731            sole(
732                ActionType::EnterSecret,
733                &json!({"min_length": 8, "max_length": 6})
734            )
735            .contains("no value fits")
736        );
737    }
738
739    #[test]
740    fn rejects_an_entry_shape_outside_the_vocabulary() {
741        assert!(
742            sole(ActionType::EnterSecret, &json!({"format": "emoji"})).contains("unknown format")
743        );
744        assert!(
745            sole(
746                ActionType::EnterSecret,
747                &json!({"format": {"pattern": "[0-9"}})
748            )
749            .contains("invalid pattern")
750        );
751        assert!(
752            sole(
753                ActionType::EnterSecret,
754                &json!({"format": {"regex": "[0-9]+"}})
755            )
756            .contains("'format' must name a format")
757        );
758        assert!(sole(ActionType::EnterSecret, &json!({"length": 0})).contains("positive integer"));
759        assert!(
760            sole(ActionType::EnterSecret, &json!({"length": "six"})).contains("positive integer")
761        );
762        assert!(check(ActionType::EnterSecret, &json!({"message": "PIN"})).is_empty());
763        assert!(
764            check(
765                ActionType::EnterSecret,
766                &json!({"message": "Key", "format": "hex", "length": 32})
767            )
768            .is_empty()
769        );
770    }
771}