1use 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
22pub struct ParamsCheck {
24 validator: Validator,
25 schema: Value,
26}
27
28pub 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 None => Err(SchemaError(format!("no schema for '{action}'"))),
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum Anchor {
60 Key,
62 Value,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum Values {
70 Shown,
72 Withheld,
75}
76
77impl Violation {
78 #[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 #[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 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 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 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 #[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 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 fn rule(&self, keyword: &Keyword, keyword_at: &Pointer, field: &str) -> String {
282 let options = match keyword {
283 Keyword::Enum { options } => Some(options.clone()),
284 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 Keyword::Enum { .. }
327 | Keyword::Required { .. }
328 | Keyword::AdditionalProperties { .. } => format!("'{field}' is not accepted"),
329 }
330 }
331
332 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 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
355fn 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
374fn 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
402fn 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
412fn 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
445fn shown(value: &Value) -> String {
448 match value {
449 Value::String(text) => format!("'{text}'"),
450 other => other.to_string(),
451 }
452}
453
454fn 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 #[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 #[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 (
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 (
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 #[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 #[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 #[test]
831 fn a_wrong_type_is_one_message_in_plain_words() {
832 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 #[test]
854 fn one_value_gets_the_message_that_says_most() {
855 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 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 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 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 #[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}