1use serde_json::Value;
16use unicode_segmentation::UnicodeSegmentation;
17
18use super::{Catalog, Def, IntegerSchema, ObjectSchema, Schema, StringSchema};
19use crate::format;
20
21pub const MAX_SCHEMA_DEPTH: usize = 32;
28
29const ROOT: &str = "<record>";
31
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("`{path}` {reason}")]
40pub struct InvalidRecord {
41 pub path: String,
43 pub reason: String,
45}
46
47impl InvalidRecord {
48 pub fn at(path: &str, reason: impl Into<String>) -> Self {
50 Self {
51 path: path.to_owned(),
52 reason: reason.into(),
53 }
54 }
55
56 #[must_use]
59 pub fn under(self, parent: &str) -> Self {
60 let path = if self.path.is_empty() {
61 parent.to_owned()
62 } else {
63 format!("{parent}.{}", self.path)
64 };
65 Self {
66 path,
67 reason: self.reason,
68 }
69 }
70
71 pub(super) fn no_such_record(collection: &str) -> Self {
73 Self::at(
74 ROOT,
75 format!("`{collection}` has no record definition in this deployment's lexicons"),
76 )
77 }
78}
79
80fn kind_of(value: &Value) -> &'static str {
82 match value {
83 Value::Null => "null",
84 Value::Bool(_) => "a boolean",
85 Value::Number(n) if n.is_f64() => "a fractional number",
86 Value::Number(_) => "an integer",
87 Value::String(_) => "a string",
88 Value::Array(_) => "an array",
89 Value::Object(_) => "an object",
90 }
91}
92
93fn child(path: &str, name: &str) -> String {
95 if path == ROOT {
96 name.to_owned()
97 } else {
98 format!("{path}.{name}")
99 }
100}
101
102fn index(path: &str, at: usize) -> String {
104 format!("{path}[{at}]")
105}
106
107pub(super) fn check(
109 catalog: &Catalog,
110 document: &str,
111 schema: &Schema,
112 record: &Value,
113) -> Result<(), InvalidRecord> {
114 check_value(catalog, document, schema, record, ROOT, 0)
115}
116
117fn check_value(
119 catalog: &Catalog,
120 document: &str,
121 schema: &Schema,
122 value: &Value,
123 path: &str,
124 depth: usize,
125) -> Result<(), InvalidRecord> {
126 if depth > MAX_SCHEMA_DEPTH {
127 return Err(InvalidRecord::at(
128 path,
129 format!("schema nests deeper than {MAX_SCHEMA_DEPTH} levels"),
130 ));
131 }
132 match schema {
133 Schema::Boolean { constant } => {
134 let held = value.as_bool().ok_or_else(|| {
135 InvalidRecord::at(
136 path,
137 format!("expected a boolean, found {}", kind_of(value)),
138 )
139 })?;
140 if let Some(required) = constant {
141 if held != *required {
142 return Err(InvalidRecord::at(
143 path,
144 format!("must be `{required}`, found `{held}`"),
145 ));
146 }
147 }
148 Ok(())
149 }
150 Schema::Integer(rules) => check_integer(rules, value, path),
151 Schema::String(rules) => check_string(rules, value, path),
152 Schema::Bytes {
153 min_length,
154 max_length,
155 } => check_bytes(*min_length, *max_length, value, path),
156 Schema::CidLink => check_cid_link(value, path),
157 Schema::Blob { accept, max_size } => check_blob(accept, *max_size, value, path),
158 Schema::Array {
159 items,
160 min_length,
161 max_length,
162 } => {
163 let held = value.as_array().ok_or_else(|| {
164 InvalidRecord::at(path, format!("expected an array, found {}", kind_of(value)))
165 })?;
166 if let Some(least) = min_length {
167 if held.len() < *least {
168 return Err(InvalidRecord::at(
169 path,
170 format!(
171 "holds {} items, fewer than the minimum of {least}",
172 held.len()
173 ),
174 ));
175 }
176 }
177 if let Some(most) = max_length {
178 if held.len() > *most {
179 return Err(InvalidRecord::at(
180 path,
181 format!("holds {} items, over the maximum of {most}", held.len()),
182 ));
183 }
184 }
185 if let Some(element) = items {
186 for (at, item) in held.iter().enumerate() {
187 check_value(
188 catalog,
189 document,
190 element,
191 item,
192 &index(path, at),
193 depth + 1,
194 )?;
195 }
196 }
197 Ok(())
198 }
199 Schema::Object(rules) => check_object(catalog, document, rules, value, path, depth),
200 Schema::Ref(reference) => {
201 let Some((resolved, def)) = catalog.resolve(document, reference) else {
202 return Err(InvalidRecord::at(
203 path,
204 format!("schema refers to `{reference}`, which this deployment cannot resolve"),
205 ));
206 };
207 let target_document = resolved
208 .split_once('#')
209 .map(|(doc, _)| doc.to_owned())
210 .unwrap_or_else(|| resolved.clone());
211 match def {
212 Def::Data(inner) => {
213 check_value(catalog, &target_document, inner, value, path, depth + 1)
214 }
215 Def::Record { record, .. } => {
216 check_value(catalog, &target_document, record, value, path, depth + 1)
217 }
218 Def::Token => match value.as_str() {
221 Some(held) if held == resolved => Ok(()),
222 Some(held) => Err(InvalidRecord::at(
223 path,
224 format!("must be the token `{resolved}`, found `{held}`"),
225 )),
226 None => Err(InvalidRecord::at(
227 path,
228 format!(
229 "expected the token `{resolved}` as a string, found {}",
230 kind_of(value)
231 ),
232 )),
233 },
234 Def::Method(method) => Err(InvalidRecord::at(
235 path,
236 format!(
237 "schema refers to `{resolved}`, which is a {} and holds no record data",
238 method.kind
239 ),
240 )),
241 }
242 }
243 Schema::Union { refs, closed } => {
244 check_union(catalog, document, refs, *closed, value, path, depth)
245 }
246 Schema::Unknown => check_unknown(value, path),
247 Schema::Null => {
248 if value.is_null() {
249 Ok(())
250 } else {
251 Err(InvalidRecord::at(
252 path,
253 format!("expected null, found {}", kind_of(value)),
254 ))
255 }
256 }
257 Schema::Token => {
258 if value.is_string() {
259 Ok(())
260 } else {
261 Err(InvalidRecord::at(
262 path,
263 format!(
264 "expected a token identifier as a string, found {}",
265 kind_of(value)
266 ),
267 ))
268 }
269 }
270 Schema::Unrecognised(_) => Ok(()),
273 }
274}
275
276fn check_integer(rules: &IntegerSchema, value: &Value, path: &str) -> Result<(), InvalidRecord> {
278 let held = value.as_i64().ok_or_else(|| {
283 InvalidRecord::at(
284 path,
285 format!("expected an integer, found {}", kind_of(value)),
286 )
287 })?;
288 if let Some(required) = rules.constant {
289 if held != required {
290 return Err(InvalidRecord::at(
291 path,
292 format!("must be `{required}`, found `{held}`"),
293 ));
294 }
295 }
296 if let Some(choices) = &rules.choices {
297 if !choices.contains(&held) {
298 return Err(InvalidRecord::at(
299 path,
300 format!("`{held}` is not one of the permitted values {choices:?}"),
301 ));
302 }
303 }
304 if let Some(least) = rules.minimum {
305 if held < least {
306 return Err(InvalidRecord::at(
307 path,
308 format!("`{held}` is below the minimum of {least}"),
309 ));
310 }
311 }
312 if let Some(most) = rules.maximum {
313 if held > most {
314 return Err(InvalidRecord::at(
315 path,
316 format!("`{held}` is above the maximum of {most}"),
317 ));
318 }
319 }
320 Ok(())
321}
322
323fn check_string(rules: &StringSchema, value: &Value, path: &str) -> Result<(), InvalidRecord> {
325 let held = value.as_str().ok_or_else(|| {
326 InvalidRecord::at(path, format!("expected a string, found {}", kind_of(value)))
327 })?;
328 if let Some(required) = &rules.constant {
329 if held != required {
330 return Err(InvalidRecord::at(
331 path,
332 format!("must be `{required}`, found `{held}`"),
333 ));
334 }
335 }
336 if let Some(choices) = &rules.choices {
337 if !choices.iter().any(|choice| choice == held) {
338 return Err(InvalidRecord::at(
339 path,
340 format!("`{held}` is not one of the permitted values {choices:?}"),
341 ));
342 }
343 }
344 let bytes = held.len();
349 if let Some(least) = rules.min_length {
350 if bytes < least {
351 return Err(InvalidRecord::at(
352 path,
353 format!("is {bytes} bytes, under the minimum of {least}"),
354 ));
355 }
356 }
357 if let Some(most) = rules.max_length {
358 if bytes > most {
359 return Err(InvalidRecord::at(
360 path,
361 format!("is {bytes} bytes, over the maximum of {most}"),
362 ));
363 }
364 }
365 if rules.min_graphemes.is_some() || rules.max_graphemes.is_some() {
366 let graphemes = held.graphemes(true).count();
369 if let Some(least) = rules.min_graphemes {
370 if graphemes < least {
371 return Err(InvalidRecord::at(
372 path,
373 format!("is {graphemes} graphemes, under the minimum of {least}"),
374 ));
375 }
376 }
377 if let Some(most) = rules.max_graphemes {
378 if graphemes > most {
379 return Err(InvalidRecord::at(
380 path,
381 format!("is {graphemes} graphemes, over the maximum of {most}"),
382 ));
383 }
384 }
385 }
386 if let Some(declared) = rules.format {
387 format::check(declared, held).map_err(|reason| {
388 InvalidRecord::at(path, format!("is not a valid {declared}: {reason}"))
389 })?;
390 }
391 Ok(())
392}
393
394fn decoded_length(encoded: &str) -> usize {
402 let significant = encoded.trim_end_matches('=').len();
403 significant * 3 / 4
404}
405
406fn check_bytes(
408 min_length: Option<usize>,
409 max_length: Option<usize>,
410 value: &Value,
411 path: &str,
412) -> Result<(), InvalidRecord> {
413 let encoded = value
414 .as_object()
415 .and_then(|object| object.get("$bytes"))
416 .and_then(Value::as_str)
417 .ok_or_else(|| {
418 InvalidRecord::at(
419 path,
420 format!(
421 "expected a bytes object `{{\"$bytes\": \"…\"}}`, found {}",
422 kind_of(value)
423 ),
424 )
425 })?;
426 let length = decoded_length(encoded);
427 if let Some(least) = min_length {
428 if length < least {
429 return Err(InvalidRecord::at(
430 path,
431 format!("decodes to {length} bytes, under the minimum of {least}"),
432 ));
433 }
434 }
435 if let Some(most) = max_length {
436 if length > most {
437 return Err(InvalidRecord::at(
438 path,
439 format!("decodes to {length} bytes, over the maximum of {most}"),
440 ));
441 }
442 }
443 Ok(())
444}
445
446fn check_cid_link(value: &Value, path: &str) -> Result<(), InvalidRecord> {
448 let link = value
449 .as_object()
450 .and_then(|object| object.get("$link"))
451 .and_then(Value::as_str)
452 .ok_or_else(|| {
453 InvalidRecord::at(
454 path,
455 format!(
456 "expected a cid-link object `{{\"$link\": \"…\"}}`, found {}",
457 kind_of(value)
458 ),
459 )
460 })?;
461 format::cid::validate(link).map_err(|reason| {
462 InvalidRecord::at(path, format!("holds a link that is not a cid: {reason}"))
463 })
464}
465
466fn mime_matches(pattern: &str, mime: &str) -> bool {
472 let bare = mime.split(';').next().unwrap_or(mime).trim();
473 if pattern == "*/*" {
474 return true;
475 }
476 match pattern.split_once("/*") {
477 Some((prefix, "")) => bare
478 .split_once('/')
479 .is_some_and(|(actual, _)| actual.eq_ignore_ascii_case(prefix)),
480 _ => pattern.eq_ignore_ascii_case(bare),
481 }
482}
483
484fn check_blob(
486 accept: &[String],
487 max_size: Option<u64>,
488 value: &Value,
489 path: &str,
490) -> Result<(), InvalidRecord> {
491 let object = value.as_object().ok_or_else(|| {
492 InvalidRecord::at(
493 path,
494 format!("expected a blob object, found {}", kind_of(value)),
495 )
496 })?;
497 if object.get("$type").and_then(Value::as_str) != Some("blob") {
498 return Err(InvalidRecord::at(
499 path,
500 "expected a blob object with `\"$type\": \"blob\"`",
501 ));
502 }
503 let mime = object
504 .get("mimeType")
505 .and_then(Value::as_str)
506 .ok_or_else(|| InvalidRecord::at(path, "blob has no `mimeType` string"))?;
507 let size = object
508 .get("size")
509 .and_then(Value::as_u64)
510 .ok_or_else(|| InvalidRecord::at(path, "blob has no non-negative `size`"))?;
511 let reference = object
512 .get("ref")
513 .ok_or_else(|| InvalidRecord::at(path, "blob has no `ref`"))?;
514 check_cid_link(reference, &child(path, "ref"))?;
515 if let Some(most) = max_size {
516 if size > most {
517 return Err(InvalidRecord::at(
518 path,
519 format!("blob is {size} bytes, over the maximum of {most}"),
520 ));
521 }
522 }
523 if !accept.is_empty() && !accept.iter().any(|pattern| mime_matches(pattern, mime)) {
524 return Err(InvalidRecord::at(
525 path,
526 format!("blob type `{mime}` is not one of the accepted types {accept:?}"),
527 ));
528 }
529 Ok(())
530}
531
532fn check_unknown(value: &Value, path: &str) -> Result<(), InvalidRecord> {
540 let object = value.as_object().ok_or_else(|| {
541 InvalidRecord::at(
542 path,
543 format!("expected an object, found {}", kind_of(value)),
544 )
545 })?;
546 let container = if object.contains_key("$bytes") {
547 Some("bytes")
548 } else if object.contains_key("$link") {
549 Some("a cid-link")
550 } else if object.get("$type").and_then(Value::as_str) == Some("blob") {
551 Some("a blob")
552 } else {
553 None
554 };
555 match container {
556 None => Ok(()),
557 Some(what) => Err(InvalidRecord::at(
558 path,
559 format!("holds {what}, which is not permitted where the schema says `unknown`"),
560 )),
561 }
562}
563
564fn qualify(document: &str, reference: &str) -> String {
566 match reference.split_once('#') {
567 Some(("", name)) => format!("{document}#{name}"),
568 Some(_) => reference.to_owned(),
569 None => format!("{reference}#main"),
570 }
571}
572
573fn check_union(
575 catalog: &Catalog,
576 document: &str,
577 refs: &[String],
578 closed: bool,
579 value: &Value,
580 path: &str,
581 depth: usize,
582) -> Result<(), InvalidRecord> {
583 let object = value.as_object().ok_or_else(|| {
584 InvalidRecord::at(
585 path,
586 format!("expected an object, found {}", kind_of(value)),
587 )
588 })?;
589 let declared = object
590 .get("$type")
591 .and_then(Value::as_str)
592 .ok_or_else(|| InvalidRecord::at(path, "a union member must carry a `$type`"))?;
593 let wanted = qualify(document, declared);
594 let chosen = refs
595 .iter()
596 .find(|reference| qualify(document, reference) == wanted);
597 match chosen {
598 Some(reference) => check_value(
599 catalog,
600 document,
601 &Schema::Ref(reference.clone()),
602 value,
603 path,
604 depth + 1,
605 ),
606 None if !closed => Ok(()),
610 None => Err(InvalidRecord::at(
611 path,
612 format!("`{declared}` is not one of this closed union's types {refs:?}"),
613 )),
614 }
615}
616
617fn check_object(
619 catalog: &Catalog,
620 document: &str,
621 rules: &ObjectSchema,
622 value: &Value,
623 path: &str,
624 depth: usize,
625) -> Result<(), InvalidRecord> {
626 let object = value.as_object().ok_or_else(|| {
627 InvalidRecord::at(
628 path,
629 format!("expected an object, found {}", kind_of(value)),
630 )
631 })?;
632
633 for name in &rules.required {
634 if !object.contains_key(name) {
635 return Err(InvalidRecord::at(
636 &child(path, name),
637 "is required and is missing",
638 ));
639 }
640 }
641
642 for (name, held) in object {
643 let Some(schema) = rules.properties.get(name) else {
644 continue;
648 };
649 let here = child(path, name);
650 if held.is_null() {
651 if rules.nullable.contains(name) {
652 continue;
653 }
654 return Err(InvalidRecord::at(
655 &here,
656 "is null, and the schema does not declare it nullable",
657 ));
658 }
659 check_value(catalog, document, schema, held, &here, depth + 1)?;
660 }
661 Ok(())
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use serde_json::json;
668
669 fn catalog(document: Value) -> Catalog {
670 let doc: didbot_lexicon::lexicon::LexiconDoc =
671 serde_json::from_value(document).expect("the test document parses");
672 Catalog::from_documents(std::iter::once(&doc)).expect("the test document is a schema")
673 }
674
675 fn one_field(schema: Value) -> Catalog {
676 catalog(json!({
677 "lexicon": 1,
678 "id": "com.example.thing",
679 "defs": {"main": {"type": "record", "key": "tid", "record": {
680 "type": "object",
681 "properties": {"field": schema}
682 }}}
683 }))
684 }
685
686 fn check_field(catalog: &Catalog, value: Value) -> Result<(), InvalidRecord> {
687 catalog.validate_record("com.example.thing", &json!({"field": value}))
688 }
689
690 #[test]
691 fn an_undeclared_property_is_carried_rather_than_refused() {
692 let catalog = one_field(json!({"type": "string"}));
693 assert!(catalog
694 .validate_record(
695 "com.example.thing",
696 &json!({"field": "ok", "inventedLater": {"deeply": ["nested"]}})
697 )
698 .is_ok());
699 }
700
701 #[test]
702 fn lengths_are_bytes_and_graphemes_are_clusters() {
703 let catalog = one_field(json!({"type": "string", "maxLength": 3, "maxGraphemes": 2}));
704 assert!(check_field(&catalog, json!("éé")).is_err());
706 assert!(check_field(&catalog, json!("ab")).is_ok());
707 let catalog = one_field(json!({"type": "string", "maxGraphemes": 1}));
708 assert!(check_field(&catalog, json!("👩👩👦👦")).is_ok());
710 assert!(check_field(&catalog, json!("ab")).is_err());
711 }
712
713 #[test]
714 fn a_fractional_number_is_not_an_integer() {
715 let catalog = one_field(json!({"type": "integer"}));
716 assert!(check_field(&catalog, json!(3)).is_ok());
717 assert!(check_field(&catalog, json!(3.5)).is_err());
718 assert!(check_field(&catalog, json!(true)).is_err());
719 }
720
721 #[test]
722 fn known_values_are_advisory_and_enum_is_not() {
723 let open = one_field(json!({"type": "string", "knownValues": ["a", "b"]}));
724 assert!(check_field(&open, json!("something-else")).is_ok());
725 let closed = one_field(json!({"type": "string", "enum": ["a", "b"]}));
726 assert!(check_field(&closed, json!("something-else")).is_err());
727 }
728
729 #[test]
730 fn null_needs_permission_and_required_needs_presence() {
731 let catalog = catalog(json!({
732 "lexicon": 1,
733 "id": "com.example.thing",
734 "defs": {"main": {"type": "record", "key": "tid", "record": {
735 "type": "object",
736 "required": ["a"],
737 "nullable": ["b"],
738 "properties": {"a": {"type": "string"}, "b": {"type": "string"}}
739 }}}
740 }));
741 assert!(catalog
742 .validate_record("com.example.thing", &json!({"a": "x"}))
743 .is_ok());
744 assert!(catalog
745 .validate_record("com.example.thing", &json!({"a": "x", "b": null}))
746 .is_ok());
747 assert!(catalog
748 .validate_record("com.example.thing", &json!({"b": "y"}))
749 .is_err());
750 assert!(catalog
751 .validate_record("com.example.thing", &json!({"a": null}))
752 .is_err());
753 }
754
755 #[test]
756 fn an_open_union_carries_a_type_it_has_never_seen_and_a_closed_one_does_not() {
757 let document = |closed: bool| {
758 catalog(json!({
759 "lexicon": 1,
760 "id": "com.example.thing",
761 "defs": {
762 "main": {"type": "record", "key": "tid", "record": {
763 "type": "object",
764 "properties": {"field": {
765 "type": "union",
766 "refs": ["#known"],
767 "closed": closed
768 }}
769 }},
770 "known": {"type": "object", "properties": {"a": {"type": "integer"}}}
771 }
772 }))
773 };
774 let novel = json!({"$type": "com.example.other#variant", "a": 1});
775 assert!(check_field(&document(false), novel.clone()).is_ok());
776 assert!(check_field(&document(true), novel).is_err());
777 assert!(check_field(
779 &document(false),
780 json!({"$type": "com.example.thing#known", "a": "not a number"})
781 )
782 .is_err());
783 assert!(check_field(&document(false), json!({"a": 1})).is_err());
785 }
786
787 #[test]
788 fn a_ref_cycle_stops_at_the_depth_guard_rather_than_the_stack() {
789 let catalog = catalog(json!({
790 "lexicon": 1,
791 "id": "com.example.thing",
792 "defs": {
793 "main": {"type": "record", "key": "tid", "record": {
794 "type": "object", "properties": {"field": {"type": "ref", "ref": "#loop"}}
795 }},
796 "loop": {"type": "object", "properties": {"field": {"type": "ref", "ref": "#loop"}}}
797 }
798 }));
799 let mut nested = json!({});
800 for _ in 0..64 {
801 nested = json!({"field": nested});
802 }
803 let error = check_field(&catalog, nested).expect_err("the guard fires");
804 assert!(error.reason.contains("nests deeper"), "{error}");
805 }
806
807 #[test]
808 fn mime_patterns_wildcard_only_the_subtype() {
809 assert!(mime_matches("image/*", "image/png"));
810 assert!(mime_matches("*/*", "anything/at-all"));
811 assert!(mime_matches("text/plain", "text/plain; charset=utf-8"));
812 assert!(!mime_matches("image/*", "text/plain"));
813 assert!(!mime_matches("image/png", "image/jpeg"));
814 }
815
816 #[test]
817 fn base64_lengths_are_counted_without_decoding() {
818 assert_eq!(decoded_length("b25l"), 3);
819 assert_eq!(decoded_length("asdfasdfasdfasdf"), 12);
820 assert_eq!(decoded_length("b25lb25l=="), 6);
821 }
822
823 #[test]
824 fn the_path_names_the_field_that_failed() {
825 let catalog = one_field(json!({
826 "type": "array",
827 "items": {"type": "string", "maxLength": 2}
828 }));
829 let error = check_field(&catalog, json!(["ok", "ok", "too long"])).expect_err("refused");
830 assert_eq!(error.path, "field[2]");
831 }
832}