Skip to main content

hayson_engine/schema/
validate.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3
4use serde_json::{Number, Value};
5
6use crate::errors::{HaysonValidationError, ValidationIssue};
7
8use super::content_hash::{is_physical_path, physical_path_of};
9use super::timestamp::parse_iso_timestamp;
10use super::types::{DefaultValue, FieldSpec, FieldType, TableSchema, META_PREFIX};
11
12fn virtual_path_of(source: &serde_json::Map<String, Value>) -> Result<String, String> {
13    match source.get("virtualPath") {
14        Some(Value::String(s)) if s.starts_with('/') && s.len() > 1 => Ok(s.clone()),
15        _ => Err("expected \"virtualPath\" to be an absolute path string".into()),
16    }
17}
18
19/// A markdown field value. `addressable` is the field's own declaration: an addressable field
20/// must carry a `virtualPath`, a non-addressable one may leave it out — and keeps it, still shape
21/// checked, when it is there, so flipping an existing field to `addressable: false` does not make
22/// the records already on disk unreadable.
23fn coerce_markdown(value: &Value, addressable: bool) -> Result<Value, String> {
24    let source = value
25        .as_object()
26        .ok_or("expected an object with \"content\" and \"virtualPath\"")?;
27    for key in source.keys() {
28        if key != "content" && key != "virtualPath" {
29            return Err(format!("has an unknown key \"{key}\""));
30        }
31    }
32    let content = match source.get("content") {
33        Some(Value::String(s)) => s.clone(),
34        _ => return Err("expected \"content\" to be a string".into()),
35    };
36    let mut coerced = serde_json::Map::new();
37    coerced.insert("content".into(), Value::String(content));
38    if addressable || source.contains_key("virtualPath") {
39        coerced.insert("virtualPath".into(), Value::String(virtual_path_of(source)?));
40    }
41    Ok(Value::Object(coerced))
42}
43
44fn coerce_image(value: &Value, image_key: Option<&[u8; 32]>) -> Result<Value, String> {
45    let source = value
46        .as_object()
47        .ok_or("expected an object with \"content\" and \"virtualPath\"")?;
48    // NOTE: also accepts this field's own coerced shape {physicalPath, virtualPath} unchanged, so
49    // re-validating an untouched image field (e.g. during patch's merge-then-revalidate) round-trips.
50    if source.contains_key("physicalPath") {
51        for key in source.keys() {
52            if key != "physicalPath" && key != "virtualPath" {
53                return Err(format!("has an unknown key \"{key}\""));
54            }
55        }
56        let physical_path = match source.get("physicalPath") {
57            Some(Value::String(s)) if is_physical_path(s) => s.clone(),
58            Some(Value::String(_)) => {
59                return Err(
60                    "expected \"physicalPath\" to be a content-hash file name this engine wrote (\"<16 hex digits>.bin\") — pass the image bytes as \"content\" instead of naming a file"
61                        .into(),
62                )
63            }
64            _ => return Err("expected \"physicalPath\" to be a string".into()),
65        };
66        let virtual_path = virtual_path_of(source)?;
67        let mut coerced = serde_json::Map::new();
68        coerced.insert("physicalPath".into(), Value::String(physical_path));
69        coerced.insert("virtualPath".into(), Value::String(virtual_path));
70        return Ok(Value::Object(coerced));
71    }
72    for key in source.keys() {
73        if key != "content" && key != "virtualPath" {
74            return Err(format!("has an unknown key \"{key}\""));
75        }
76    }
77    let content = match source.get("content") {
78        Some(Value::String(s)) => s.clone(),
79        _ => return Err("expected \"content\" to be a string".into()),
80    };
81    let virtual_path = virtual_path_of(source)?;
82    let decoded = super::content_hash::decode_image_content(&content)?;
83    let physical_path = match image_key {
84        Some(key) => crate::crypto::keyed_physical_path(key, &decoded),
85        None => physical_path_of(&decoded),
86    };
87    let mut coerced = serde_json::Map::new();
88    coerced.insert("physicalPath".into(), Value::String(physical_path));
89    coerced.insert("virtualPath".into(), Value::String(virtual_path));
90    Ok(Value::Object(coerced))
91}
92
93pub type HaysonRecord = BTreeMap<String, Value>;
94
95#[derive(Debug, Clone, Copy, Default)]
96pub struct ValidateOptions {
97    pub partial: bool,
98    pub now: Option<i64>,
99    /// Set for a table of an encrypted database: image file names are keyed with it instead of being a
100    /// plain content hash.
101    pub image_key: Option<[u8; 32]>,
102}
103
104fn is_json_safe(value: &Value, depth: u32) -> bool {
105    if depth > 32 {
106        return false;
107    }
108    match value {
109        Value::Null | Value::String(_) | Value::Bool(_) => true,
110        Value::Number(n) => n.as_f64().is_some_and(f64::is_finite),
111        Value::Array(items) => items.iter().all(|item| is_json_safe(item, depth + 1)),
112        Value::Object(map) => map.values().all(|item| is_json_safe(item, depth + 1)),
113    }
114}
115
116fn coerce_scalar(
117    field_type: FieldType,
118    values: Option<&[String]>,
119    value: &Value,
120    image_key: Option<&[u8; 32]>,
121) -> Result<Value, String> {
122    match field_type {
123        FieldType::String => match value {
124            Value::String(_) => Ok(value.clone()),
125            _ => Err("expected a string".into()),
126        },
127        FieldType::Int => match value {
128            Value::Number(n) if n.as_f64().is_some_and(|f| f.is_finite() && f.fract() == 0.0) => Ok(value.clone()),
129            _ => Err("expected an integer".into()),
130        },
131        FieldType::Float => match value.as_f64() {
132            Some(n) if n.is_finite() => Ok(value.clone()),
133            _ => Err("expected a finite number".into()),
134        },
135        FieldType::Bool => match value {
136            Value::Bool(_) => Ok(value.clone()),
137            _ => Err("expected a boolean".into()),
138        },
139        FieldType::Timestamp => match value {
140            Value::Number(n) if n.as_f64().is_some_and(f64::is_finite) => {
141                Ok(Value::Number(Number::from(n.as_f64().unwrap().trunc() as i64)))
142            }
143            Value::String(s) => match parse_iso_timestamp(s) {
144                Some(ms) => Ok(Value::Number(Number::from(ms))),
145                None => Err("expected a valid date".into()),
146            },
147            _ => Err("expected epoch milliseconds or an ISO date string".into()),
148        },
149        FieldType::Enum => match value {
150            Value::String(s) if values.is_some_and(|v| v.iter().any(|item| item == s)) => Ok(value.clone()),
151            _ => Err(format!(
152                "expected one of {}",
153                values.map(|v| v.join(", ")).unwrap_or_default()
154            )),
155        },
156        FieldType::Json => {
157            if is_json_safe(value, 0) {
158                Ok(value.clone())
159            } else {
160                Err("expected JSON data".into())
161            }
162        }
163        FieldType::Image => coerce_image(value, image_key),
164        _ => Err(format!("unsupported type {}", field_type.as_str())),
165    }
166}
167
168fn coerce_field(spec: &FieldSpec, value: &Value, image_key: Option<&[u8; 32]>) -> Result<Value, String> {
169    if value.is_null() {
170        return if spec.required {
171            Err("is required and must not be null".into())
172        } else {
173            Ok(Value::Null)
174        };
175    }
176    // NOTE: markdown is coerced here, not in `coerce_scalar` — only the spec knows `addressable`.
177    if spec.field_type == FieldType::Markdown {
178        return coerce_markdown(value, spec.addressable);
179    }
180    if !spec.field_type.is_array() {
181        return coerce_scalar(spec.field_type, spec.values.as_deref(), value, image_key);
182    }
183
184    let items = value.as_array().ok_or_else(|| {
185        format!(
186            "expected an array of {}",
187            spec.field_type.element_type().unwrap().as_str()
188        )
189    })?;
190    let element = spec.field_type.element_type().expect("array type has an element");
191    let mut coerced = Vec::with_capacity(items.len());
192    for (position, item) in items.iter().enumerate() {
193        let value =
194            coerce_scalar(element, None, item, image_key).map_err(|reason| format!("item {position} {reason}"))?;
195        coerced.push(value);
196    }
197    Ok(Value::Array(coerced))
198}
199
200fn resolve_default(spec: &FieldSpec, now: i64) -> Value {
201    match &spec.default {
202        Some(DefaultValue::Now) => Value::Number(Number::from(now)),
203        Some(DefaultValue::Value(value)) => value.clone(),
204        None => Value::Null,
205    }
206}
207
208pub fn validate_record(
209    schema: &TableSchema,
210    input: &Value,
211    options: ValidateOptions,
212) -> Result<HaysonRecord, HaysonValidationError> {
213    let Some(source) = input.as_object() else {
214        return Err(HaysonValidationError {
215            table: schema.table.clone(),
216            issues: vec![ValidationIssue {
217                field: "(record)".into(),
218                reason: "expected an object".into(),
219            }],
220        });
221    };
222
223    let mut issues = Vec::new();
224    for name in source.keys() {
225        if name.starts_with(META_PREFIX) {
226            issues.push(ValidationIssue {
227                field: name.clone(),
228                reason: "uses the reserved \"_\" prefix".into(),
229            });
230        } else if !schema.fields.contains_key(name) {
231            issues.push(ValidationIssue {
232                field: name.clone(),
233                reason: "is not declared in the schema".into(),
234            });
235        }
236    }
237
238    let now = options.now.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
239    let mut record = HaysonRecord::new();
240    for (name, spec) in &schema.fields {
241        match source.get(name) {
242            None => {
243                if options.partial {
244                    continue;
245                }
246                if spec.default.is_some() {
247                    record.insert(name.clone(), resolve_default(spec, now));
248                } else if spec.required {
249                    issues.push(ValidationIssue {
250                        field: name.clone(),
251                        reason: "is required".into(),
252                    });
253                }
254            }
255            Some(value) => match coerce_field(spec, value, options.image_key.as_ref()) {
256                Ok(coerced) => {
257                    record.insert(name.clone(), coerced);
258                }
259                Err(reason) => issues.push(ValidationIssue {
260                    field: name.clone(),
261                    reason,
262                }),
263            },
264        }
265    }
266
267    if !issues.is_empty() {
268        return Err(HaysonValidationError {
269            table: schema.table.clone(),
270            issues,
271        });
272    }
273    Ok(record)
274}
275
276/// Whether `name` is a field of `schema` or a reserved `_` key. A reserved key is kept here on
277/// purpose: `validate_record` refuses it whatever the table's `validation` says, so it is never
278/// one of the fields a `warn` table drops.
279fn is_writable_field(schema: &TableSchema, name: &str) -> bool {
280    name.starts_with(META_PREFIX) || schema.fields.contains_key(name)
281}
282
283/// The fields `input` names that `schema` does not declare — what a `validation: "warn"` table
284/// drops from a write and what a `strict` one refuses it for.
285pub fn undeclared_fields(schema: &TableSchema, input: &Value) -> Vec<String> {
286    let Some(source) = input.as_object() else {
287        return Vec::new();
288    };
289    source
290        .keys()
291        .filter(|name| !is_writable_field(schema, name))
292        .cloned()
293        .collect()
294}
295
296/// `input` without the fields [`undeclared_fields`] names — the whole of what `validation: "warn"`
297/// does to a write, which is then validated exactly as a `strict` one. Borrowed unchanged when
298/// there is nothing to drop, so a table nobody writes an undeclared field to copies no document.
299pub fn without_undeclared<'a>(schema: &TableSchema, input: &'a Value) -> Cow<'a, Value> {
300    let Some(source) = input.as_object() else {
301        return Cow::Borrowed(input);
302    };
303    if source.keys().all(|name| is_writable_field(schema, name)) {
304        return Cow::Borrowed(input);
305    }
306    let kept: serde_json::Map<String, Value> = source
307        .iter()
308        .filter(|(name, _)| is_writable_field(schema, name))
309        .map(|(name, value)| (name.clone(), value.clone()))
310        .collect();
311    Cow::Owned(Value::Object(kept))
312}
313
314#[allow(dead_code)]
315pub fn project_to_schema(schema: &TableSchema, record: &HaysonRecord) -> HaysonRecord {
316    record
317        .iter()
318        .filter(|(name, _)| schema.fields.contains_key(*name))
319        .map(|(name, value)| (name.clone(), value.clone()))
320        .collect()
321}
322
323pub fn primary_key_of(schema: &TableSchema, record: &HaysonRecord) -> Result<String, HaysonValidationError> {
324    match record.get(&schema.primary_key) {
325        Some(Value::String(s)) => Ok(s.clone()),
326        Some(Value::Number(n)) => Ok(n.to_string()),
327        _ => Err(HaysonValidationError {
328            table: schema.table.clone(),
329            issues: vec![ValidationIssue {
330                field: schema.primary_key.clone(),
331                reason: "is required as the primary key".into(),
332            }],
333        }),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use serde_json::json;
340
341    use super::*;
342    use crate::schema::parse::parse_schema;
343
344    fn users_schema() -> TableSchema {
345        parse_schema(&json!({
346            "schemaVersion": 1,
347            "table": "users",
348            "primaryKey": "id",
349            "fields": {
350                "id": { "type": "string" },
351                "name": { "type": "string", "required": true },
352                "age": { "type": "int" },
353                "createdAt": { "type": "timestamp", "default": "now" },
354                "role": { "type": "enum", "values": ["admin", "user"] },
355                "tags": { "type": "string[]" },
356            },
357        }))
358        .unwrap()
359    }
360
361    #[test]
362    fn fills_in_defaults_and_the_now_sentinel() {
363        let schema = users_schema();
364        let record = validate_record(
365            &schema,
366            &json!({ "id": "u1", "name": "Ania" }),
367            ValidateOptions {
368                partial: false,
369                now: Some(1000),
370                ..ValidateOptions::default()
371            },
372        )
373        .unwrap();
374        assert_eq!(record.get("createdAt"), Some(&Value::from(1000)));
375    }
376
377    #[test]
378    fn rejects_a_required_field_missing_without_a_default() {
379        let schema = users_schema();
380        let err = validate_record(&schema, &json!({ "id": "u1" }), ValidateOptions::default()).unwrap_err();
381        assert!(err.issues.iter().any(|i| i.field == "name"));
382    }
383
384    #[test]
385    fn rejects_a_field_not_declared_in_the_schema() {
386        let schema = users_schema();
387        let err = validate_record(
388            &schema,
389            &json!({ "id": "u1", "name": "Ania", "unknown": 1 }),
390            ValidateOptions::default(),
391        )
392        .unwrap_err();
393        assert!(err.issues.iter().any(|i| i.field == "unknown"));
394    }
395
396    #[test]
397    fn rejects_a_field_using_the_reserved_meta_prefix() {
398        let schema = users_schema();
399        let err = validate_record(
400            &schema,
401            &json!({ "id": "u1", "name": "Ania", "_seq": 1 }),
402            ValidateOptions::default(),
403        )
404        .unwrap_err();
405        assert!(err.issues.iter().any(|i| i.field == "_seq"));
406    }
407
408    #[test]
409    fn rejects_a_non_integer_for_an_int_field() {
410        let schema = users_schema();
411        let err = validate_record(
412            &schema,
413            &json!({ "id": "u1", "name": "Ania", "age": 1.5 }),
414            ValidateOptions::default(),
415        )
416        .unwrap_err();
417        assert!(err.issues.iter().any(|i| i.field == "age"));
418    }
419
420    #[test]
421    fn accepts_an_iso_timestamp_and_converts_it_to_epoch_millis() {
422        let schema = users_schema();
423        let record = validate_record(
424            &schema,
425            &json!({ "id": "u1", "name": "Ania", "createdAt": "2025-01-01T00:00:00Z" }),
426            ValidateOptions::default(),
427        )
428        .unwrap();
429        assert_eq!(record.get("createdAt"), Some(&Value::from(1735689600000i64)));
430    }
431
432    #[test]
433    fn rejects_an_enum_value_outside_its_declared_values() {
434        let schema = users_schema();
435        let err = validate_record(
436            &schema,
437            &json!({ "id": "u1", "name": "Ania", "role": "superadmin" }),
438            ValidateOptions::default(),
439        )
440        .unwrap_err();
441        assert!(err.issues.iter().any(|i| i.field == "role"));
442    }
443
444    #[test]
445    fn accepts_a_typed_array_field() {
446        let schema = users_schema();
447        let record = validate_record(
448            &schema,
449            &json!({ "id": "u1", "name": "Ania", "tags": ["a", "b"] }),
450            ValidateOptions::default(),
451        )
452        .unwrap();
453        assert_eq!(record.get("tags"), Some(&json!(["a", "b"])));
454    }
455
456    #[test]
457    fn partial_mode_skips_absent_fields_instead_of_defaulting_or_requiring() {
458        let schema = users_schema();
459        let record = validate_record(
460            &schema,
461            &json!({ "age": 5 }),
462            ValidateOptions {
463                partial: true,
464                now: Some(1),
465                ..ValidateOptions::default()
466            },
467        )
468        .unwrap();
469        assert!(!record.contains_key("name"));
470        assert!(!record.contains_key("createdAt"));
471        assert_eq!(record.get("age"), Some(&Value::from(5)));
472    }
473
474    #[test]
475    fn projects_out_fields_the_schema_no_longer_declares() {
476        let schema = users_schema();
477        let mut record = HaysonRecord::new();
478        record.insert("id".into(), Value::String("u1".into()));
479        record.insert("dropped".into(), Value::from(1));
480        let projected = project_to_schema(&schema, &record);
481        assert!(!projected.contains_key("dropped"));
482        assert!(projected.contains_key("id"));
483    }
484
485    #[test]
486    fn undeclared_fields_names_the_stray_keys_and_leaves_a_reserved_one_alone() {
487        let schema = users_schema();
488        let input = json!({ "id": "u1", "nickname": "Anka", "_seq": 7, "shoeSize": 39 });
489
490        assert_eq!(
491            undeclared_fields(&schema, &input),
492            vec!["nickname".to_string(), "shoeSize".to_string()]
493        );
494    }
495
496    #[test]
497    fn without_undeclared_keeps_a_clean_document_borrowed_and_copies_only_a_dirty_one() {
498        let schema = users_schema();
499        let clean = json!({ "id": "u1", "name": "Ania" });
500        let dirty = json!({ "id": "u1", "name": "Ania", "nickname": "Anka", "_seq": 7 });
501
502        assert!(matches!(without_undeclared(&schema, &clean), Cow::Borrowed(_)));
503        let kept = without_undeclared(&schema, &dirty);
504        assert!(matches!(kept, Cow::Owned(_)));
505        assert_eq!(kept.get("name"), Some(&json!("Ania")));
506        assert_eq!(kept.get("nickname"), None);
507        assert_eq!(kept.get("_seq"), Some(&json!(7)));
508    }
509
510    #[test]
511    fn a_document_stripped_of_its_stray_key_validates_while_the_original_does_not() {
512        let schema = users_schema();
513        let dirty = json!({ "id": "u1", "name": "Ania", "nickname": "Anka" });
514
515        assert!(validate_record(&schema, &dirty, ValidateOptions::default()).is_err());
516        let record = validate_record(
517            &schema,
518            without_undeclared(&schema, &dirty).as_ref(),
519            ValidateOptions::default(),
520        )
521        .unwrap();
522        assert_eq!(record.get("name"), Some(&json!("Ania")));
523        assert!(!record.contains_key("nickname"));
524    }
525
526    fn docs_schema() -> TableSchema {
527        parse_schema(&json!({
528            "schemaVersion": 1,
529            "table": "docs",
530            "primaryKey": "id",
531            "fields": {
532                "id": { "type": "string" },
533                "page": { "type": "markdown" },
534                "cover": { "type": "image" },
535            },
536        }))
537        .unwrap()
538    }
539
540    #[test]
541    fn coerces_a_markdown_field_into_content_and_virtual_path() {
542        let schema = docs_schema();
543        let record = validate_record(
544            &schema,
545            &json!({
546                "id": "d1",
547                "page": { "content": "# Hi", "virtualPath": "/docs/hi.md" },
548            }),
549            ValidateOptions::default(),
550        )
551        .unwrap();
552        assert_eq!(
553            record.get("page"),
554            Some(&json!({ "content": "# Hi", "virtualPath": "/docs/hi.md" }))
555        );
556    }
557
558    #[test]
559    fn rejects_a_markdown_value_missing_its_virtual_path() {
560        let schema = docs_schema();
561        let err = validate_record(
562            &schema,
563            &json!({ "id": "d1", "page": { "content": "# Hi" } }),
564            ValidateOptions::default(),
565        )
566        .unwrap_err();
567        assert!(err.issues.iter().any(|i| i.field == "page"));
568    }
569
570    fn notes_schema() -> TableSchema {
571        parse_schema(&json!({
572            "schemaVersion": 1,
573            "table": "notes",
574            "primaryKey": "id",
575            "fields": {
576                "id": { "type": "string" },
577                "body": { "type": "markdown", "addressable": false },
578            },
579        }))
580        .unwrap()
581    }
582
583    #[test]
584    fn a_non_addressable_markdown_field_validates_on_content_alone() {
585        let record = validate_record(
586            &notes_schema(),
587            &json!({ "id": "n1", "body": { "content": "[a](/docs/a.md)" } }),
588            ValidateOptions::default(),
589        )
590        .unwrap();
591
592        assert_eq!(record.get("body"), Some(&json!({ "content": "[a](/docs/a.md)" })));
593    }
594
595    #[test]
596    fn a_non_addressable_markdown_field_still_shape_checks_a_path_it_was_handed() {
597        let kept = validate_record(
598            &notes_schema(),
599            &json!({ "id": "n1", "body": { "content": "x", "virtualPath": "/notes/n1.md" } }),
600            ValidateOptions::default(),
601        )
602        .unwrap();
603        let refused = validate_record(
604            &notes_schema(),
605            &json!({ "id": "n1", "body": { "content": "x", "virtualPath": "notes/n1.md" } }),
606            ValidateOptions::default(),
607        )
608        .unwrap_err();
609
610        assert_eq!(
611            kept.get("body"),
612            Some(&json!({ "content": "x", "virtualPath": "/notes/n1.md" })),
613            "a record written before the field opted out has to keep reading back"
614        );
615        assert!(refused.issues.iter().any(|issue| issue.field == "body"));
616    }
617
618    #[test]
619    fn coerces_an_image_field_into_a_content_addressed_physical_path() {
620        let schema = docs_schema();
621        let record = validate_record(
622            &schema,
623            &json!({
624                "id": "d1",
625                "cover": { "content": "Ynl0ZXMtb2YtdGhlLWxvZ28=", "virtualPath": "/images/logo.png" },
626            }),
627            ValidateOptions::default(),
628        )
629        .unwrap();
630        let cover = record.get("cover").unwrap().as_object().unwrap();
631        assert_eq!(cover.get("virtualPath"), Some(&json!("/images/logo.png")));
632        assert!(cover.get("physicalPath").unwrap().as_str().unwrap().ends_with(".bin"));
633        assert!(!cover.contains_key("content"));
634    }
635
636    #[test]
637    fn rejects_an_image_content_that_is_not_valid_base64() {
638        let schema = docs_schema();
639        let err = validate_record(
640            &schema,
641            &json!({ "id": "d1", "cover": { "content": "not base64!!", "virtualPath": "/images/a.png" } }),
642            ValidateOptions::default(),
643        )
644        .unwrap_err();
645        assert!(err.issues.iter().any(|issue| issue.reason.contains("base64")));
646    }
647
648    #[test]
649    fn refuses_a_caller_supplied_physical_path_that_is_not_a_content_hash_file_name() {
650        let schema = docs_schema();
651        let err = validate_record(
652            &schema,
653            &json!({
654                "id": "d1",
655                "cover": { "physicalPath": "../../victim/secret.txt", "virtualPath": "/images/a.png" },
656            }),
657            ValidateOptions::default(),
658        )
659        .unwrap_err();
660        assert!(
661            err.issues
662                .iter()
663                .any(|issue| issue.field == "cover" && issue.reason.contains("content-hash file name")),
664            "{:?}",
665            err.issues
666        );
667    }
668
669    #[test]
670    fn round_trips_an_image_field_it_coerced_itself() {
671        let schema = docs_schema();
672        let coerced = validate_record(
673            &schema,
674            &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
675            ValidateOptions::default(),
676        )
677        .unwrap();
678
679        let again = validate_record(
680            &schema,
681            &json!({ "id": "d1", "cover": coerced.get("cover").unwrap() }),
682            ValidateOptions::default(),
683        )
684        .unwrap();
685
686        assert_eq!(again.get("cover"), coerced.get("cover"));
687    }
688
689    #[test]
690    fn hashes_identical_image_content_to_the_same_physical_path() {
691        let schema = docs_schema();
692        let one = validate_record(
693            &schema,
694            &json!({ "id": "d1", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/a.png" } }),
695            ValidateOptions::default(),
696        )
697        .unwrap();
698        let two = validate_record(
699            &schema,
700            &json!({ "id": "d2", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/b.png" } }),
701            ValidateOptions::default(),
702        )
703        .unwrap();
704        assert_eq!(
705            one.get("cover").unwrap().get("physicalPath"),
706            two.get("cover").unwrap().get("physicalPath")
707        );
708    }
709}