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