1use core::{error, fmt, ops::Deref};
54
55use alloc::{
56 string::{String, ToString},
57 vec,
58 vec::Vec,
59};
60
61use crate::{
62 param::{VcardParam, VcardParamKind},
63 prop::{
64 VcardProp, VcardPropKind, VcardPropName,
65 cardinality::VcardPropCardinality,
66 spec::{VcardPropSpecFns, prop_spec},
67 },
68 value::VcardValueKind,
69 vcard::Vcard,
70 version::VcardVersion,
71};
72
73#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum VcardValidateError {
76 PropVersion {
78 prop: VcardPropKind,
80 version: VcardVersion,
82 },
83 ValueKind {
86 prop: VcardPropKind,
88 found: Option<VcardValueKind>,
90 },
91 Param {
93 prop: VcardPropKind,
95 param: VcardParamKind,
97 },
98 Value {
105 prop: VcardPropKind,
107 found: String,
109 },
110 ParamValue {
115 param: VcardParamKind,
117 found: String,
119 },
120 Cardinality {
122 prop: VcardPropKind,
124 cardinality: VcardPropCardinality,
126 count: usize,
128 },
129}
130
131impl fmt::Display for VcardValidateError {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::PropVersion {
135 prop: p,
136 version: v,
137 } => {
138 write!(f, "Property `{}` is not defined in vCard {}", &**p, &**v)
139 }
140 Self::ValueKind {
141 prop: p,
142 found: Some(k),
143 } => {
144 write!(f, "Value kind `{}` is not allowed for `{}`", &**k, &**p)
145 }
146 Self::ValueKind {
147 prop: p,
148 found: None,
149 } => {
150 write!(f, "An undecoded value is not allowed for `{}`", &**p)
151 }
152 Self::Param {
153 prop: pp,
154 param: pm,
155 } => {
156 write!(f, "Parameter `{}` is not allowed for `{}`", &**pm, &**pp)
157 }
158 Self::Value { prop: p, found } => {
159 write!(f, "Value `{found}` is not allowed for `{}`", &**p)
160 }
161 Self::ParamValue { param: p, found } => {
162 write!(f, "Value `{found}` is not allowed for parameter `{}`", &**p)
163 }
164 Self::Cardinality {
165 prop: p,
166 cardinality: cd,
167 count: cn,
168 } => {
169 write!(f, "Property `{}` appears {cn} times but is {cd:?}", &**p)
170 }
171 }
172 }
173}
174
175impl error::Error for VcardValidateError {}
176
177impl<'a> Vcard<'a> {
178 pub fn validate(self) -> Result<VcardValid<Vcard<'a>>, Vec<VcardValidateError>> {
184 let mut errors = Vec::new();
185 let mut counts: Vec<(VcardPropKind, usize)> = Vec::new();
186
187 for prop in &self.properties {
188 validate_prop(prop, self.version, &mut errors);
189 if let VcardPropName::Kind(kind) = &prop.name {
190 match counts.iter_mut().find(|(seen, _)| *seen == *kind) {
191 Some((_, count)) => *count += 1,
192 None => counts.push((*kind, 1)),
193 }
194 }
195 }
196
197 for prop in VcardPropKind::ALL {
201 let spec = prop_spec(prop);
202 if !(spec.allowed_versions)().contains(&self.version) {
203 continue;
204 }
205 let count = counts
206 .iter()
207 .find(|(seen, _)| *seen == prop)
208 .map_or(0, |(_, count)| *count);
209 let cardinality = (spec.cardinality)(self.version);
210 if !cardinality_ok(cardinality, count) {
211 errors.push(VcardValidateError::Cardinality {
212 prop,
213 cardinality,
214 count,
215 });
216 }
217 }
218
219 if errors.is_empty() {
220 Ok(VcardValid(self))
221 } else {
222 Err(errors)
223 }
224 }
225}
226
227pub(crate) fn validate_prop(
232 prop: &VcardProp<'_>,
233 version: VcardVersion,
234 errors: &mut Vec<VcardValidateError>,
235) {
236 let VcardPropName::Kind(kind) = &prop.name else {
237 return;
238 };
239
240 let spec = prop_spec(*kind);
241
242 if !(spec.allowed_versions)().contains(&version) {
243 errors.push(VcardValidateError::PropVersion {
244 prop: *kind,
245 version,
246 });
247 }
248
249 let value_kind = prop.value.kind();
250 if !value_kind.is_some_and(|kind| (spec.allowed_values)(version).contains(&kind)) {
251 errors.push(VcardValidateError::ValueKind {
252 prop: *kind,
253 found: value_kind,
254 });
255 }
256
257 if let Some(found) = (spec.invalid_value)(&prop.value, version) {
258 errors.push(VcardValidateError::Value { prop: *kind, found });
259 }
260
261 for param in &prop.params {
262 let Some(param_kind) = param.kind() else {
263 continue;
264 };
265
266 if !param_allowed(&spec, version, param_kind) {
267 errors.push(VcardValidateError::Param {
268 prop: *kind,
269 param: param_kind,
270 });
271 }
272
273 for found in invalid_param_values(param) {
274 errors.push(VcardValidateError::ParamValue {
275 param: param_kind,
276 found,
277 });
278 }
279 }
280}
281
282fn invalid_param_values(param: &VcardParam<'_>) -> Vec<String> {
293 match param {
294 VcardParam::Pref(value) => {
295 let pref = value.parse::<u8>();
296 offending(pref.is_ok_and(|pref| (1..=100).contains(&pref)), value)
297 }
298 VcardParam::Derived(value) => {
299 let boolean = value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false");
300 offending(boolean, value)
301 }
302 VcardParam::Pid(values) => values
303 .iter()
304 .filter(|value| !is_pid(value))
305 .map(|value| value.to_string())
306 .collect(),
307 _ => Vec::new(),
308 }
309}
310
311fn offending(allowed: bool, value: &str) -> Vec<String> {
314 match allowed {
315 true => Vec::new(),
316 false => vec![value.to_string()],
317 }
318}
319
320fn is_pid(value: &str) -> bool {
322 let digits = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
323
324 match value.split_once('.') {
325 Some((source, client)) => digits(source) && digits(client),
326 None => digits(value),
327 }
328}
329
330fn param_allowed(spec: &VcardPropSpecFns, version: VcardVersion, kind: VcardParamKind) -> bool {
334 let allowed = (spec.allowed_params)(version);
335 match version {
336 VcardVersion::V4_0 => allowed.contains(&kind) || is_universal(kind),
337 _ => {
338 matches!(kind, VcardParamKind::Charset | VcardParamKind::Encoding)
339 || (allowed.contains(&kind) && !is_v4_only(kind))
340 }
341 }
342}
343
344fn is_universal(kind: VcardParamKind) -> bool {
347 use VcardParamKind::*;
348
349 matches!(kind, Author | AuthorName | Created | Derived | PropId)
350}
351
352fn is_v4_only(kind: VcardParamKind) -> bool {
355 use VcardParamKind::*;
356
357 matches!(
358 kind,
359 Pid | Pref
360 | AltId
361 | MediaType
362 | CalScale
363 | SortAs
364 | Geo
365 | Tz
366 | Label
367 | Author
368 | AuthorName
369 | Created
370 | Derived
371 | Jsptr
372 | Phonetic
373 | PropId
374 | Script
375 | ServiceType
376 | Username
377 )
378}
379
380fn cardinality_ok(cardinality: VcardPropCardinality, count: usize) -> bool {
382 match cardinality {
383 VcardPropCardinality::ExactlyOne => count == 1,
384 VcardPropCardinality::AtMostOne => count <= 1,
385 VcardPropCardinality::OneOrMore => count >= 1,
386 VcardPropCardinality::Any => true,
387 }
388}
389
390#[derive(Clone, Debug, PartialEq, Eq)]
396pub struct VcardValid<T>(T);
397
398impl<T> VcardValid<T> {
399 pub fn into_inner(self) -> T {
401 self.0
402 }
403}
404
405impl<T> Deref for VcardValid<T> {
406 type Target = T;
407
408 fn deref(&self) -> &T {
409 &self.0
410 }
411}
412
413impl<'a> TryFrom<Vcard<'a>> for VcardValid<Vcard<'a>> {
414 type Error = Vec<VcardValidateError>;
415
416 fn try_from(card: Vcard<'a>) -> Result<Self, Self::Error> {
417 card.validate()
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use alloc::{borrow::Cow, string::ToString, vec, vec::Vec};
424
425 use crate::{
426 param::{VcardParam, VcardParamKind},
427 prop::{VcardProp, VcardPropKind, cardinality::VcardPropCardinality},
428 validator::{VcardValid, VcardValidateError},
429 value::{
430 VcardValue, VcardValueKind, client_pid_map::VcardClientPidMap, gender::VcardGender,
431 n::VcardN, text::VcardText, uri::VcardUri,
432 },
433 vcard::Vcard,
434 version::VcardVersion,
435 };
436
437 fn prop(
438 name: &'static str,
439 params: Vec<VcardParam<'static>>,
440 value: VcardValue<'static>,
441 ) -> VcardProp<'static> {
442 VcardProp {
443 name: name.into(),
444 params,
445 value,
446 }
447 }
448
449 fn card(version: VcardVersion, properties: Vec<VcardProp<'static>>) -> Vcard<'static> {
450 Vcard {
451 version,
452 properties,
453 }
454 }
455
456 #[test]
457 fn accepts_a_conformant_card_and_extensions() {
458 let vcard = card(
459 VcardVersion::V4_0,
460 vec![
461 prop(
462 "FN",
463 vec![],
464 VcardValue::Text(VcardText(Cow::Borrowed("John"))),
465 ),
466 prop("X-FOO", vec![], VcardValue::Unknown(Default::default())),
468 ],
469 );
470 assert!(vcard.validate().is_ok());
471 }
472
473 fn card_with(other: VcardProp<'static>) -> Vcard<'static> {
476 card(
477 VcardVersion::V4_0,
478 vec![
479 prop(
480 "FN",
481 vec![],
482 VcardValue::Text(VcardText(Cow::Borrowed("John"))),
483 ),
484 other,
485 ],
486 )
487 }
488
489 fn gender(sex: &'static str, identity: &'static str) -> VcardProp<'static> {
490 prop(
491 "GENDER",
492 vec![],
493 VcardValue::Gender(VcardGender {
494 sex: Cow::Borrowed(sex),
495 identity: Cow::Borrowed(identity),
496 }),
497 )
498 }
499
500 fn with_param(param: VcardParam<'static>) -> VcardProp<'static> {
502 prop(
503 "NICKNAME",
504 vec![param],
505 VcardValue::TextList(Default::default()),
506 )
507 }
508
509 #[test]
510 fn accepts_every_sex_code_the_rfc_defines() {
511 for sex in ["", "M", "F", "O", "N", "U", "m", "f"] {
514 assert!(
515 card_with(gender(sex, "")).validate().is_ok(),
516 "rejected the sex code {sex:?}",
517 );
518 }
519 }
520
521 #[test]
522 fn flags_a_sex_code_outside_the_vocabulary() {
523 let errors = card_with(gender("X", "")).validate().unwrap_err();
524
525 assert!(errors.contains(&VcardValidateError::Value {
526 prop: VcardPropKind::Gender,
527 found: "X".to_string(),
528 }));
529 }
530
531 #[test]
532 fn a_gender_outside_the_vocabulary_belongs_in_the_identity() {
533 assert!(card_with(gender("", "it's complicated")).validate().is_ok());
535 }
536
537 #[test]
538 fn flags_a_profile_that_is_not_vcard() {
539 let profile = |value| {
540 card(
541 VcardVersion::V3_0,
542 vec![
543 prop(
544 "FN",
545 vec![],
546 VcardValue::Text(VcardText(Cow::Borrowed("John"))),
547 ),
548 prop("N", vec![], VcardValue::N(VcardN::default())),
549 prop("PROFILE", vec![], VcardValue::Text(VcardText(value))),
550 ],
551 )
552 };
553
554 assert!(profile(Cow::Borrowed("VCARD")).validate().is_ok());
555 assert!(profile(Cow::Borrowed("vcard")).validate().is_ok());
556 assert!(profile(Cow::Borrowed("ICAL")).validate().is_err());
557 }
558
559 #[test]
560 fn flags_a_client_pid_map_identifier_that_is_not_an_integer() {
561 let map = |id| {
562 card_with(prop(
563 "CLIENTPIDMAP",
564 vec![],
565 VcardValue::ClientPidMap(VcardClientPidMap {
566 id,
567 uri: Cow::Borrowed("urn:uuid:1f"),
568 }),
569 ))
570 };
571
572 assert!(map(Cow::Borrowed("1")).validate().is_ok());
573 assert!(map(Cow::Borrowed("")).validate().is_err());
574 assert!(map(Cow::Borrowed("one")).validate().is_err());
575 }
576
577 #[test]
578 fn flags_a_pref_outside_one_to_a_hundred() {
579 let pref = |value| card_with(with_param(VcardParam::Pref(Cow::Borrowed(value))));
580
581 assert!(pref("1").validate().is_ok());
582 assert!(pref("100").validate().is_ok());
583 assert!(pref("0").validate().is_err());
584 assert!(pref("101").validate().is_err());
585 assert!(pref("high").validate().is_err());
586 }
587
588 #[test]
589 fn flags_a_pid_that_is_not_a_small_integer_pair() {
590 let pid = |values| card_with(with_param(VcardParam::Pid(values)));
591
592 assert!(pid(vec![Cow::Borrowed("1")]).validate().is_ok());
593 assert!(pid(vec![Cow::Borrowed("1.1")]).validate().is_ok());
594 assert!(pid(vec![Cow::Borrowed("1.")]).validate().is_err());
595 assert!(pid(vec![Cow::Borrowed("a")]).validate().is_err());
596 }
597
598 #[test]
599 fn flags_a_derived_that_is_not_a_boolean() {
600 let derived = |value| card_with(with_param(VcardParam::Derived(Cow::Borrowed(value))));
601
602 assert!(derived("true").validate().is_ok());
603 assert!(derived("FALSE").validate().is_ok());
604 assert!(derived("yes").validate().is_err());
605 }
606
607 #[test]
608 fn an_open_vocabulary_is_not_checked() {
609 let kind = prop(
612 "KIND",
613 vec![],
614 VcardValue::Text(VcardText(Cow::Borrowed("x-android-custom"))),
615 );
616
617 assert!(card_with(kind).validate().is_ok());
618 }
619
620 #[test]
621 fn flags_a_value_kind_the_property_forbids() {
622 let vcard = card(
623 VcardVersion::V4_0,
624 vec![prop(
625 "FN",
626 vec![],
627 VcardValue::Uri(VcardUri(Cow::Borrowed("x"))),
628 )],
629 );
630 let errors = vcard.validate().unwrap_err();
631 assert!(matches!(errors[0], VcardValidateError::ValueKind { .. }));
632 }
633
634 #[test]
637 fn allows_charset_in_2_1_but_not_4_0() {
638 let with_charset = |version| {
639 card(
640 version,
641 vec![
642 prop("N", vec![], VcardValue::N(VcardN::default())),
643 prop(
644 "FN",
645 vec![],
646 VcardValue::Text(VcardText(Cow::Borrowed("X"))),
647 ),
648 prop(
649 "NOTE",
650 vec![VcardParam::Charset(Cow::Borrowed("UTF-8"))],
651 VcardValue::Text(VcardText(Cow::Borrowed("hi"))),
652 ),
653 ],
654 )
655 .validate()
656 };
657
658 assert!(with_charset(VcardVersion::V2_1).is_ok());
659 assert!(with_charset(VcardVersion::V4_0).is_err());
660 }
661
662 #[test]
664 fn flags_a_required_property_that_is_absent() {
665 let errors = card(VcardVersion::V4_0, vec![]).validate().unwrap_err();
666 assert!(errors.iter().any(|error| matches!(
667 error,
668 VcardValidateError::Cardinality {
669 prop: VcardPropKind::Fn,
670 ..
671 },
672 )));
673 }
674
675 #[test]
677 fn flags_a_property_absent_from_the_version() {
678 let errors = card(
679 VcardVersion::V4_0,
680 vec![
681 prop(
682 "FN",
683 vec![],
684 VcardValue::Text(VcardText(Cow::Borrowed("X"))),
685 ),
686 prop(
687 "AGENT",
688 vec![],
689 VcardValue::Text(VcardText(Cow::Borrowed("a"))),
690 ),
691 ],
692 )
693 .validate()
694 .unwrap_err();
695 assert!(errors.iter().any(|error| matches!(
696 error,
697 VcardValidateError::PropVersion {
698 prop: VcardPropKind::Agent,
699 ..
700 },
701 )));
702 }
703
704 #[test]
706 fn flags_a_disallowed_parameter() {
707 let errors = card(
708 VcardVersion::V4_0,
709 vec![prop(
710 "FN",
711 vec![VcardParam::MediaType(Cow::Borrowed("text/plain"))],
712 VcardValue::Text(VcardText(Cow::Borrowed("X"))),
713 )],
714 )
715 .validate()
716 .unwrap_err();
717 assert!(
718 errors
719 .iter()
720 .any(|error| matches!(error, VcardValidateError::Param { .. },))
721 );
722 }
723
724 #[test]
726 fn flags_a_property_that_appears_too_often() {
727 let errors = card(
728 VcardVersion::V4_0,
729 vec![
730 prop(
731 "FN",
732 vec![],
733 VcardValue::Text(VcardText(Cow::Borrowed("X"))),
734 ),
735 prop("N", vec![], VcardValue::N(VcardN::default())),
736 prop("N", vec![], VcardValue::N(VcardN::default())),
737 ],
738 )
739 .validate()
740 .unwrap_err();
741 assert!(errors.iter().any(|error| matches!(
742 error,
743 VcardValidateError::Cardinality {
744 prop: VcardPropKind::N,
745 count: 2,
746 ..
747 },
748 )));
749 }
750
751 #[test]
753 fn rejects_a_v4_only_parameter_in_2_1() {
754 let errors = card(
755 VcardVersion::V2_1,
756 vec![
757 prop("N", vec![], VcardValue::N(VcardN::default())),
758 prop(
759 "FN",
760 vec![],
761 VcardValue::Text(VcardText(Cow::Borrowed("X"))),
762 ),
763 prop(
764 "TEL",
765 vec![VcardParam::Pid(vec![Cow::Borrowed("1")])],
766 VcardValue::Text(VcardText(Cow::Borrowed("123"))),
767 ),
768 ],
769 )
770 .validate()
771 .unwrap_err();
772 assert!(errors.iter().any(|error| matches!(
773 error,
774 VcardValidateError::Param {
775 param: VcardParamKind::Pid,
776 ..
777 },
778 )));
779 }
780
781 #[test]
782 fn displays_every_validate_error_variant() {
783 let errors = [
784 VcardValidateError::PropVersion {
785 prop: VcardPropKind::Agent,
786 version: VcardVersion::V4_0,
787 },
788 VcardValidateError::ValueKind {
789 prop: VcardPropKind::Fn,
790 found: Some(VcardValueKind::Uri),
791 },
792 VcardValidateError::ValueKind {
793 prop: VcardPropKind::Fn,
794 found: None,
795 },
796 VcardValidateError::Param {
797 prop: VcardPropKind::Fn,
798 param: VcardParamKind::MediaType,
799 },
800 VcardValidateError::Cardinality {
801 prop: VcardPropKind::N,
802 cardinality: VcardPropCardinality::AtMostOne,
803 count: 2,
804 },
805 ];
806 for error in errors {
807 assert!(!error.to_string().is_empty());
808 }
809 }
810
811 #[test]
812 fn valid_proof_derefs_and_unwraps() {
813 let vcard = card(
814 VcardVersion::V4_0,
815 vec![prop(
816 "FN",
817 vec![],
818 VcardValue::Text(VcardText(Cow::Borrowed("John"))),
819 )],
820 );
821
822 let valid = VcardValid::try_from(vcard.clone()).expect("a conformant card");
823 assert_eq!(valid.version, VcardVersion::V4_0);
824
825 let inner = vcard.validate().unwrap().into_inner();
826 assert_eq!(inner.version, VcardVersion::V4_0);
827 }
828
829 #[cfg(feature = "parser")]
832 #[test]
833 fn valid_proof_converts_into_a_byte_tree() {
834 use crate::tree::cst::VcardCst;
835
836 let vcard = card(
837 VcardVersion::V4_0,
838 vec![prop(
839 "FN",
840 vec![],
841 VcardValue::Text(VcardText(Cow::Borrowed("John"))),
842 )],
843 );
844
845 let valid = VcardValid::try_from(vcard).expect("a conformant card");
846 let cst = VcardCst::from(valid);
847
848 assert!(cst.to_string().contains("FN:John"));
849 }
850}