Skip to main content

didbot_schema/
check.rs

1//! Checking one JSON value against one [`Schema`].
2//!
3//! The walk is ordinary; the parts worth reading are the three places where
4//! the specification says to be *permissive* and it would be easy to be
5//! strict instead. An object property no schema declares is kept. A `$type`
6//! outside an open union's `refs` is kept. A `knownValues` list is not a
7//! constraint at all. Each of those is a record written by a client that
8//! knows a newer version of a lexicon than this server does, and refusing any
9//! of them turns a schema addition into an outage.
10//!
11//! Errors carry a path — `artifacts[2]`, `object.a` — because the whole
12//! value of validating on write is that the client is told which field is
13//! wrong while it still has the field in hand.
14
15use serde_json::Value;
16use unicode_segmentation::UnicodeSegmentation;
17
18use super::{Catalog, Def, IntegerSchema, ObjectSchema, Schema, StringSchema};
19use crate::format;
20
21/// How deep a schema may nest before the walk gives up.
22///
23/// Refs can point at each other, so a cycle is reachable from a lexicon
24/// document alone and does not need a malicious record to trigger it. Real
25/// records are three or four levels deep; this is a stack guard, not a
26/// modelling limit.
27pub const MAX_SCHEMA_DEPTH: usize = 32;
28
29/// The root of an error path, for a failure about the record as a whole.
30const ROOT: &str = "<record>";
31
32/// Why a record does not satisfy its lexicon.
33///
34/// One flat error rather than an enum per constraint. The caller is an HTTP
35/// layer turning this into a 400 and the reader is the client that wrote the
36/// record, so the field that matters is the path, and a message it can print
37/// beats a variant it would have to match on.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("`{path}` {reason}")]
40pub struct InvalidRecord {
41    /// Where in the record the failure is, as a dotted and indexed path.
42    pub path: String,
43    /// What was wrong, in a sentence.
44    pub reason: String,
45}
46
47impl InvalidRecord {
48    /// A failure at a path.
49    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    /// The same failure, with its path placed under `parent`. The root of a
57    /// nested object is `""` until its caller places it.
58    #[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    /// The collection has no record definition in this catalog.
72    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
80/// Names the JSON kind of a value, for an error message.
81fn 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
93/// Extends an error path with a property name.
94fn child(path: &str, name: &str) -> String {
95    if path == ROOT {
96        name.to_owned()
97    } else {
98        format!("{path}.{name}")
99    }
100}
101
102/// Extends an error path with an array index.
103fn index(path: &str, at: usize) -> String {
104    format!("{path}[{at}]")
105}
106
107/// Checks a record body against a record definition's schema.
108pub(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
117/// The recursive step.
118fn 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                // A token carries no data: the value *is* the token's
219                // identifier, written out in full.
220                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        // A type this build has never heard of. Accepting the value is the
271        // forward-compatible answer; see this module's header.
272        Schema::Unrecognised(_) => Ok(()),
273    }
274}
275
276/// Checks an integer and its bounds.
277fn check_integer(rules: &IntegerSchema, value: &Value, path: &str) -> Result<(), InvalidRecord> {
278    // `as_i64` is deliberately the whole type test. It says no to a boolean,
279    // to a string of digits, and to `1.5` — atproto's data model has no
280    // floats, so a fractional number is a different type and not a rounding
281    // question.
282    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
323/// Checks a string, its lengths, its enumeration and its format.
324fn 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    // Lengths are in UTF-8 bytes, which is what the specification says and is
345    // not what a naive implementation counts. The two differ for every
346    // non-ASCII string, and an emoji field with a byte limit read as a
347    // character limit would be four times too permissive.
348    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        // Counted only when asked, because segmentation is the expensive part
367        // of validating a record and most fields do not declare it.
368        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
394/// How many bytes a base64 string of this many characters decodes to.
395///
396/// Computed rather than decoded. The length bound is the only thing anything
397/// here does with a `bytes` field — nothing in this server interprets the
398/// payload — and arithmetic over the character count answers it without
399/// pulling in a base64 decoder or deciding which padding dialect a client
400/// used.
401fn decoded_length(encoded: &str) -> usize {
402    let significant = encoded.trim_end_matches('=').len();
403    significant * 3 / 4
404}
405
406/// Checks a `bytes` value: `{"$bytes": "<base64>"}`.
407fn 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
446/// Checks a `cid-link` value: `{"$link": "<cid>"}`.
447fn 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
466/// Whether a MIME type matches one `accept` pattern.
467///
468/// Patterns are exact (`image/png`) or a wildcarded subtype (`image/*`), and
469/// `*/*` accepts everything. Parameters after a `;` are ignored, because
470/// `text/plain; charset=utf-8` is a `text/plain`.
471fn 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
484/// Checks a `blob` value.
485fn 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
532/// Checks an `unknown` value.
533///
534/// `unknown` is not "anything". atproto's data model says the value is an
535/// open object, and the three container shapes — bytes, cid-link and blob —
536/// are excluded because each of them is a distinct node in the data model
537/// rather than a map, and a consumer that re-encoded one as a plain object
538/// would change what it is.
539fn 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
564/// Rewrites a ref into the `document#def` form a `$type` would be written in.
565fn 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
573/// Checks a union: an object whose `$type` chooses the schema.
574fn 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        // An open union is open: a variant added to the lexicon after this
607        // binary was built arrives here, and carrying it is the entire point
608        // of the distinction from a closed one.
609        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
617/// Checks an object: required, nullable, declared properties, and the rest.
618fn 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            // The unknown-field rule. A property no schema declares is
645            // carried through untouched, so a client written against a newer
646            // lexicon does not lose data by writing to an older server.
647            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        // Two graphemes, four bytes: over the byte limit, under the cluster one.
705        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        // One family emoji: eleven code points, one grapheme cluster.
709        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        // A member matching a ref is still checked through it.
778        assert!(check_field(
779            &document(false),
780            json!({"$type": "com.example.thing#known", "a": "not a number"})
781        )
782        .is_err());
783        // And a member with no `$type` cannot be dispatched at all.
784        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}