From cc04f38c14c40b72c8efdfc26fd920cfd260f168 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Tue, 30 Sep 2025 01:10:58 -0400 Subject: [PATCH] boon now included --- .DS_Store | Bin 6148 -> 6148 bytes Cargo.lock | 442 ++++- Cargo.toml | 10 +- GEMINI.md | 33 + src/lib.rs | 44 +- src/schemas.rs | 46 + src/tests.rs | 19 + validator/CHANGELOG.md | 81 + validator/Cargo.lock | 1441 +++++++++++++++++ validator/Cargo.toml | 37 + validator/LICENSE-APACHE | 177 ++ validator/LICENSE-MIT | 18 + validator/README.md | 88 + validator/benches/bench.rs | 26 + validator/cli/Cargo.lock | 1156 +++++++++++++ validator/cli/Cargo.toml | 25 + validator/cli/src/main.rs | 316 ++++ validator/src/compiler.rs | 985 +++++++++++ validator/src/content.rs | 82 + validator/src/draft.rs | 576 +++++++ validator/src/ecma.rs | 197 +++ validator/src/formats.rs | 838 ++++++++++ validator/src/lib.rs | 716 ++++++++ validator/src/loader.rs | 243 +++ validator/src/metaschemas/draft-04/schema | 151 ++ validator/src/metaschemas/draft-06/schema | 151 ++ validator/src/metaschemas/draft-07/schema | 172 ++ .../metaschemas/draft/2019-09/meta/applicator | 55 + .../metaschemas/draft/2019-09/meta/content | 15 + .../src/metaschemas/draft/2019-09/meta/core | 56 + .../src/metaschemas/draft/2019-09/meta/format | 13 + .../metaschemas/draft/2019-09/meta/meta-data | 35 + .../metaschemas/draft/2019-09/meta/validation | 97 ++ .../src/metaschemas/draft/2019-09/schema | 41 + .../metaschemas/draft/2020-12/meta/applicator | 47 + .../metaschemas/draft/2020-12/meta/content | 15 + .../src/metaschemas/draft/2020-12/meta/core | 50 + .../draft/2020-12/meta/format-annotation | 13 + .../draft/2020-12/meta/format-assertion | 13 + .../metaschemas/draft/2020-12/meta/meta-data | 35 + .../draft/2020-12/meta/unevaluated | 14 + .../metaschemas/draft/2020-12/meta/validation | 97 ++ .../src/metaschemas/draft/2020-12/schema | 57 + validator/src/output.rs | 622 +++++++ validator/src/root.rs | 128 ++ validator/src/roots.rs | 107 ++ validator/src/util.rs | 545 +++++++ validator/src/validator.rs | 1169 +++++++++++++ .../tests/draft2020-12/const.json | 21 + .../draft2020-12/infinite-loop-detection.json | 26 + .../draft2020-12/optional/contentSchema.json | 143 ++ .../draft2020-12/optional/format/date.json | 16 + .../optional/format/duration.json | 16 + .../draft2020-12/optional/format/email.json | 31 + .../draft2020-12/optional/format/time.json | 23 + .../tests/draft2020-12/properties.json | 26 + .../tests/draft2020-12/ref.json | 74 + .../draft2020-12/unevaluatedProperties.json | 57 + .../tests/draft2020-12/uniqueItems.json | 21 + .../tests/draft4/dependencies.json | 27 + .../tests/draft7/if-then-else.json | 50 + .../tests/draft7/optional/format/period.json | 98 ++ validator/tests/compiler.rs | 87 + validator/tests/debug.json | 33 + validator/tests/debug.rs | 41 + validator/tests/examples.rs | 230 +++ validator/tests/examples/dog.json | 7 + validator/tests/examples/instance.json | 4 + validator/tests/examples/instance.yml | 2 + validator/tests/examples/sample schema.json | 12 + validator/tests/examples/schema.json | 12 + validator/tests/examples/schema.yml | 9 + validator/tests/filepaths.rs | 44 + validator/tests/invalid-schemas.json | 244 +++ validator/tests/invalid-schemas.rs | 67 + validator/tests/output.rs | 122 ++ validator/tests/suite.rs | 120 ++ 77 files changed, 12905 insertions(+), 52 deletions(-) create mode 100644 GEMINI.md create mode 100644 validator/CHANGELOG.md create mode 100644 validator/Cargo.lock create mode 100644 validator/Cargo.toml create mode 100644 validator/LICENSE-APACHE create mode 100644 validator/LICENSE-MIT create mode 100644 validator/README.md create mode 100644 validator/benches/bench.rs create mode 100644 validator/cli/Cargo.lock create mode 100644 validator/cli/Cargo.toml create mode 100644 validator/cli/src/main.rs create mode 100644 validator/src/compiler.rs create mode 100644 validator/src/content.rs create mode 100644 validator/src/draft.rs create mode 100644 validator/src/ecma.rs create mode 100644 validator/src/formats.rs create mode 100644 validator/src/lib.rs create mode 100644 validator/src/loader.rs create mode 100644 validator/src/metaschemas/draft-04/schema create mode 100644 validator/src/metaschemas/draft-06/schema create mode 100644 validator/src/metaschemas/draft-07/schema create mode 100644 validator/src/metaschemas/draft/2019-09/meta/applicator create mode 100644 validator/src/metaschemas/draft/2019-09/meta/content create mode 100644 validator/src/metaschemas/draft/2019-09/meta/core create mode 100644 validator/src/metaschemas/draft/2019-09/meta/format create mode 100644 validator/src/metaschemas/draft/2019-09/meta/meta-data create mode 100644 validator/src/metaschemas/draft/2019-09/meta/validation create mode 100644 validator/src/metaschemas/draft/2019-09/schema create mode 100644 validator/src/metaschemas/draft/2020-12/meta/applicator create mode 100644 validator/src/metaschemas/draft/2020-12/meta/content create mode 100644 validator/src/metaschemas/draft/2020-12/meta/core create mode 100644 validator/src/metaschemas/draft/2020-12/meta/format-annotation create mode 100644 validator/src/metaschemas/draft/2020-12/meta/format-assertion create mode 100644 validator/src/metaschemas/draft/2020-12/meta/meta-data create mode 100644 validator/src/metaschemas/draft/2020-12/meta/unevaluated create mode 100644 validator/src/metaschemas/draft/2020-12/meta/validation create mode 100644 validator/src/metaschemas/draft/2020-12/schema create mode 100644 validator/src/output.rs create mode 100644 validator/src/root.rs create mode 100644 validator/src/roots.rs create mode 100644 validator/src/util.rs create mode 100644 validator/src/validator.rs create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/const.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/infinite-loop-detection.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/contentSchema.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/date.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/duration.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/email.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/time.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/properties.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/ref.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/unevaluatedProperties.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft2020-12/uniqueItems.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft4/dependencies.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft7/if-then-else.json create mode 100644 validator/tests/Extra-Test-Suite/tests/draft7/optional/format/period.json create mode 100644 validator/tests/compiler.rs create mode 100644 validator/tests/debug.json create mode 100644 validator/tests/debug.rs create mode 100644 validator/tests/examples.rs create mode 100644 validator/tests/examples/dog.json create mode 100644 validator/tests/examples/instance.json create mode 100644 validator/tests/examples/instance.yml create mode 100644 validator/tests/examples/sample schema.json create mode 100644 validator/tests/examples/schema.json create mode 100644 validator/tests/examples/schema.yml create mode 100644 validator/tests/filepaths.rs create mode 100644 validator/tests/invalid-schemas.json create mode 100644 validator/tests/invalid-schemas.rs create mode 100644 validator/tests/output.rs create mode 100644 validator/tests/suite.rs diff --git a/.DS_Store b/.DS_Store index 9bcd3fb9eb656bc03b17081475a1847db88b5ab6..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 100644 GIT binary patch delta 70 zcmZoMXfc=|#>AjHu~2NHo+1YW5HK<@2yA}HsK&PW17jECW_AvK4xj>{$am(+{342+ UKzW7)kiy9(Jj$D6L{=~Z075el%K!iX delta 290 zcmZoMXfc=|#>B`mF;Q%yo}wrt0|NsP3os;=7Z)VuPBpKKt)QqRJW#E=h! zdB~DU1v#0;B?bo97@3$^SlQS)*g3d4VuLgC%Y#c2OG=BK5{sfiypa6-oFo`KF)1uF zwLD%x#5q5&Br!8DwFs;sGbI(MBqlsFFD1X+DZex?r5LO?7%bse#KFnI880AFU2SBk zqhM%ZP^+U*ZD?d^p`&17ZdzN*$sww&Zygk$os*lF-vx9z5HNy#1OmJe3Ql%!Ow?ur jd22H_(80hE*qHd8c{0C JsonB { let mut error_list = Vec::new(); collect_errors(&validation_error, &mut error_list); let errors = format_errors(error_list, &instance_value, schema_id); - let filtered_errors = filter_false_schema_errors(errors); - if filtered_errors.is_empty() { + if errors.is_empty() { JsonB(json!({ "response": "success" })) } else { - JsonB(json!({ "errors": filtered_errors })) + JsonB(json!({ "errors": errors })) } } } @@ -391,19 +390,6 @@ fn collect_errors(error: &ValidationError, errors_list: &mut Vec) { ErrorKind::Group | ErrorKind::AllOf | ErrorKind::AnyOf | ErrorKind::Not | ErrorKind::OneOf(_) ); - // Special handling for FalseSchema - if it has causes, use those instead - if matches!(&error.kind, ErrorKind::FalseSchema) { - if !error.causes.is_empty() { - // FalseSchema often wraps more specific errors in if/then conditionals - for cause in &error.causes { - collect_errors(cause, errors_list); - } - return; - } - // If FalseSchema has no causes, it's likely from unevaluatedProperties - // We'll handle it as a leaf error below - } - if error.causes.is_empty() && !is_structural { let base_path = error.instance_location.to_string(); @@ -892,32 +878,6 @@ fn handle_one_of_error(base_path: &str, matched: &Option<(usize, usize)>) -> Vec }] } -// Filter out FALSE_SCHEMA errors if there are other validation errors -fn filter_false_schema_errors(errors: Vec) -> Vec { - // Check if there are any non-FALSE_SCHEMA errors - let has_non_false_schema = errors.iter().any(|e| { - e.get("code") - .and_then(|c| c.as_str()) - .map(|code| code != "FALSE_SCHEMA") - .unwrap_or(false) - }); - - if has_non_false_schema { - // Filter out FALSE_SCHEMA errors - errors.into_iter() - .filter(|e| { - e.get("code") - .and_then(|c| c.as_str()) - .map(|code| code != "FALSE_SCHEMA") - .unwrap_or(true) - }) - .collect() - } else { - // Keep all errors (they're all FALSE_SCHEMA) - errors - } -} - // Formats errors according to DropError structure fn format_errors(errors: Vec, instance: &Value, schema_id: &str) -> Vec { // Deduplicate by instance_path and format as DropError diff --git a/src/schemas.rs b/src/schemas.rs index a95ef79..d467081 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -764,6 +764,52 @@ pub fn title_override_schemas() -> JsonB { cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) } +pub fn format_with_ref_schemas() -> JsonB { + let enums = json!([]); + let types = json!([ + { + "name": "entity", + "schemas": [{ + "$id": "entity", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "type": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["id", "type"] + }] + }, + { + "name": "job", + "schemas": [{ + "$id": "job", + "$ref": "entity", + "properties": { + "worker_id": { "type": "string", "format": "uuid" } + } + }] + } + ]); + + let puncs = json!([{ + "name": "save_job", + "public": true, + "schemas": [ + { + "$id": "save_job.request", + "$ref": "job" + }, + { + "$id": "save_job.response", + "$ref": "job" + } + ] + }]); + + cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) +} + pub fn type_matching_schemas() -> JsonB { let enums = json!([]); let types = json!([ diff --git a/src/tests.rs b/src/tests.rs index 9ece815..fc28daf 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -488,6 +488,25 @@ fn test_validate_format_empty_string() { assert_success(&result); } +#[pg_test] +fn test_validate_format_empty_string_with_ref() { + let cache_result = format_with_ref_schemas(); + assert_success(&cache_result); + + // Test that an optional field with a format constraint passes validation + // when the value is an empty string, even when the schema is referenced by a punc. + let instance = json!({ + "id": "123e4567-e89b-12d3-a456-426614174000", + "type": "job", + "worker_id": "" // Optional field with format, but empty string + }); + + let result = validate_json_schema("save_job.request", jsonb(instance)); + + // This should succeed because empty strings are ignored for format validation. + assert_success(&result); +} + #[pg_test] fn test_validate_property_merging() { let cache_result = property_merging_schemas(); diff --git a/validator/CHANGELOG.md b/validator/CHANGELOG.md new file mode 100644 index 0000000..45356e6 --- /dev/null +++ b/validator/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +## [Unreleased] + +### Bug Fixes +- validator: ensure `uneval` state is propagated when `$ref` validation fails + +## [0.6.1] - 2025-01-07 + +### Bug Fixes +- fix: FileLoader should not be used in wasm + +## [0.6.0] - 2024-05-30 + +### Braking Changes +- loader: Allow to replace entirely + +### Bug Fixes +- seperate doc loading from root creation +- validator: if contentEncoding fails, skip contentMediaType +- loader: should load latest from metaschemas dir +- fix: hash for json numbers with zero fractions +- fix: resources/anchors in non-std schema loc not supported + +### Changes +- boon binary artificats under github release +- boon binary `--cacert` option +- boon binary `--insecure` flag + +## [0.5.3] - 2024-01-27 + +### Changes +- updated dependencies + +## [0.5.2] - 2024-01-27 + +### Bug Fixes + +- Error message for failed const validation is wrong + +## [0.5.1] - 2023-07-13 + +### Changes + +- WASM compatibility +- minor performance improvements + +## [0.5.0] - 2023-03-29 + +### Breaking Changes +- chages to error api + +### Performance +- minor improvements in validation + +## [0.4.0] - 2023-03-24 + +### Breaking Changes +- chages to error api + +### Fixed +- Compler.add_resource should not check file exists + +### Added +- implement `contentSchema` keyword +- ECMA-262 regex compatibility +- add example_custom_content_encoding +- add example_custom_content_media_type + +### Performance +- significant improvement in validation + +## [0.3.1] - 2023-03-07 + +### Added +- add example_from_yaml_files +- cli: support yaml files + +### Fixed +- ensure fragment decoded before use +- $dynamicRef w/o anchor is same as $ref \ No newline at end of file diff --git a/validator/Cargo.lock b/validator/Cargo.lock new file mode 100644 index 0000000..9454abd --- /dev/null +++ b/validator/Cargo.lock @@ -0,0 +1,1441 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "appendlist" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "aws-lc-rs" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47bb8cc16b669d267eeccf585aea077d0882f4777b1c1f740217885d6e6e5a3" +dependencies = [ + "aws-lc-sys", + "paste", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2101df3813227bbaaaa0b04cd61c534c7954b22bd68d399b440be937dc63ff7" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", + "libc", + "paste", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "boon" +version = "0.6.1" +dependencies = [ + "ahash", + "appendlist", + "base64", + "criterion", + "fluent-uri", + "idna", + "once_cell", + "percent-encoding", + "regex", + "regex-syntax", + "rustls", + "serde", + "serde_json", + "serde_yaml", + "ureq", + "url", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "cmake" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e" +dependencies = [ + "cc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.168" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "oorandom" +version = "11.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ref-cast" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "web-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/validator/Cargo.toml b/validator/Cargo.toml new file mode 100644 index 0000000..66d8bbb --- /dev/null +++ b/validator/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "boon" +version = "0.6.1" +edition = "2021" +description = "JSONSchema (draft 2020-12, draft 2019-09, draft-7, draft-6, draft-4) Validation" +readme = "README.md" +repository = "https://github.com/santhosh-tekuri/boon" +authors = ["santhosh kumar tekuri "] +keywords = ["jsonschema", "validation"] +license = "MIT OR Apache-2.0" +categories = ["web-programming"] +exclude = [ "tests", ".github", ".gitmodules" ] + +[dependencies] +serde = "1" +serde_json = "1" +regex = "1.10.3" +regex-syntax = "0.8.2" +url = "2" +fluent-uri = "0.3.2" +idna = "1.0" +percent-encoding = "2" +once_cell = "1" +base64 = "0.22" +ahash = "0.8.3" +appendlist = "1.4" + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +ureq = "2.12" +rustls = "0.23" +criterion = "0.5" + +[[bench]] +name = "bench" +harness = false diff --git a/validator/LICENSE-APACHE b/validator/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/validator/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/validator/LICENSE-MIT b/validator/LICENSE-MIT new file mode 100644 index 0000000..80f8d35 --- /dev/null +++ b/validator/LICENSE-MIT @@ -0,0 +1,18 @@ +Copyright 2023 Santhosh Kumar Tekuri + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/validator/README.md b/validator/README.md new file mode 100644 index 0000000..9b1ef67 --- /dev/null +++ b/validator/README.md @@ -0,0 +1,88 @@ +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Crates.io](https://img.shields.io/crates/v/boon.svg)](https://crates.io/crates/boon) +[![docs.rs](https://docs.rs/boon/badge.svg)](https://docs.rs/boon/) +[![Build Status](https://github.com/santhosh-tekuri/boon/actions/workflows/rust.yml/badge.svg?branch=main)](https://github.com/santhosh-tekuri/boon/actions/workflows/rust.yml) +[![codecov](https://codecov.io/gh/santhosh-tekuri/boon/branch/main/graph/badge.svg?token=A2YC4A0BLG)](https://codecov.io/gh/santhosh-tekuri/boon) +[![dependency status](https://deps.rs/repo/github/Santhosh-tekuri/boon/status.svg?refresh)](https://deps.rs/repo/github/Santhosh-tekuri/boon) + +[Examples](https://github.com/santhosh-tekuri/boon/blob/main/tests/examples.rs) +[Changelog](https://github.com/santhosh-tekuri/boon/blob/main/CHANGELOG.md) + +## Library Features + +- [x] pass [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) excluding optional(compare with other impls at [bowtie](https://bowtie-json-schema.github.io/bowtie/#)) + - [x] [![draft-04](https://img.shields.io/endpoint?url=https://bowtie.report/badges/rust-boon/compliance/draft4.json)](https://bowtie.report/#/dialects/draft4) + - [x] [![draft-06](https://img.shields.io/endpoint?url=https://bowtie.report/badges/rust-boon/compliance/draft6.json)](https://bowtie.report/#/dialects/draft6) + - [x] [![draft-07](https://img.shields.io/endpoint?url=https://bowtie.report/badges/rust-boon/compliance/draft7.json)](https://bowtie.report/#/dialects/draft7) + - [x] [![draft/2019-09](https://img.shields.io/endpoint?url=https://bowtie.report/badges/rust-boon/compliance/draft2019-09.json)](https://bowtie.report/#/dialects/draft2019-09) + - [x] [![draft/2020-12](https://img.shields.io/endpoint?url=https://bowtie.report/badges/rust-boon/compliance/draft2020-12.json)](https://bowtie.report/#/dialects/draft2020-12) +- [x] detect infinite loop traps + - [x] `$schema` cycle + - [x] validation cycle +- [x] custom `$schema` url +- [x] vocabulary based validation +- [x] ECMA-262 regex compatibility (pass tests from `optional/ecmascript-regex.json`) +- [x] format assertions + - [x] flag to enable in draft >= 2019-09 + - [x] custom format registration + - [x] built-in formats + - [x] regex, uuid + - [x] ipv4, ipv6 + - [x] hostname, email + - [x] idn-hostname, idn-email + - [x] date, time, date-time, duration + - [x] json-pointer, relative-json-pointer + - [x] uri, uri-reference, uri-template + - [x] iri, iri-reference + - [x] period +- [x] content assertions + - [x] flag to enable in draft >= 7 + - [x] contentEncoding + - [x] base64 + - [x] custom + - [x] contentMediaType + - [x] application/json + - [x] custom + - [x] contentSchema +- [x] errors + - [x] introspectable + - [x] hierarchy + - [x] alternative display with `#` + - [x] output + - [x] flag + - [x] basic + - [x] detailed +- [ ] custom vocabulary + +## CLI + +to install: `cargo install boon-cli --locked` + +or download it from [releases](https://github.com/santhosh-tekuri/boon/releases) + +``` +Usage: boon [OPTIONS] SCHEMA [INSTANCE...] + +Options: + -h, --help Print help information + -q, --quiet Do not print errors + -d, --draft Draft used when '$schema' is missing. Valid values 4, + 6, 7, 2019, 2020 (default 2020) + -o, --output Output format. Valid values simple, alt, flag, basic, + detailed (default simple) + -f, --assert-format + Enable format assertions with draft >= 2019 + -c, --assert-content + Enable content assertions with draft >= 7 + --cacert Use the specified PEM certificate file to verify the + peer. The file may contain multiple CA certificates + -k, --insecure Use insecure TLS connection +``` + +This cli can validate both schema and multiple instances. + +It support both json and yaml files + +exit code is: +- `1` if command line arguments are invalid. +- `2` if there are errors diff --git a/validator/benches/bench.rs b/validator/benches/bench.rs new file mode 100644 index 0000000..561e1f3 --- /dev/null +++ b/validator/benches/bench.rs @@ -0,0 +1,26 @@ +use std::{env, fs::File}; + +use boon::{Compiler, Schemas}; +use criterion::{criterion_group, criterion_main, Criterion}; +use serde_json::Value; + +pub fn validate(c: &mut Criterion) { + let (Ok(schema), Ok(instance)) = (env::var("SCHEMA"), env::var("INSTANCE")) else { + panic!("SCHEMA, INSTANCE environment variables not set"); + }; + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_format_assertions(); + let sch = compiler.compile(&schema, &mut schemas).unwrap(); + let rdr = File::open(&instance).unwrap(); + let inst: Value = if instance.ends_with(".yaml") || instance.ends_with(".yml") { + serde_yaml::from_reader(rdr).unwrap() + } else { + serde_json::from_reader(rdr).unwrap() + }; + c.bench_function("boon", |b| b.iter(|| schemas.validate(&inst, sch).unwrap())); +} + +criterion_group!(benches, validate); +criterion_main!(benches); diff --git a/validator/cli/Cargo.lock b/validator/cli/Cargo.lock new file mode 100644 index 0000000..9b468e8 --- /dev/null +++ b/validator/cli/Cargo.lock @@ -0,0 +1,1156 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "appendlist" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2" + +[[package]] +name = "aws-lc-rs" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f409eb70b561706bf8abba8ca9c112729c481595893fd06a2dd9af8ed8441148" +dependencies = [ + "aws-lc-sys", + "paste", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8478a5c29ead3f3be14aff8a202ad965cf7da6856860041bfca271becf8ba48b" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", + "libc", + "paste", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "boon" +version = "0.6.1" +dependencies = [ + "ahash", + "appendlist", + "base64", + "fluent-uri", + "idna 1.0.3", + "once_cell", + "percent-encoding", + "regex", + "regex-syntax", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "boon-cli" +version = "0.6.2" +dependencies = [ + "boon", + "getopts", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yaml", + "ureq", + "url", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" + +[[package]] +name = "cc" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e" +dependencies = [ + "cc", +] + +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "prettyplease" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "serde" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "2.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna 0.5.0", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "webpki-roots" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/validator/cli/Cargo.toml b/validator/cli/Cargo.toml new file mode 100644 index 0000000..539b100 --- /dev/null +++ b/validator/cli/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "boon-cli" +version = "0.6.2" +edition = "2021" +description = "cli for JSONSchema (draft 2020-12, draft 2019-09, draft-7, draft-6, draft-4) Validation" +repository = "https://github.com/santhosh-tekuri/boon/cli" +authors = ["santhosh kumar tekuri "] +keywords = ["jsonschema", "validation"] +categories = ["web-programming"] +license = "MIT OR Apache-2.0" + +[dependencies] +boon = { version = "0.6.1", path = ".."} +url = "2" +getopts = "0.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +ureq = "2.12" +rustls = { version = "0.23", features = ["ring"] } +rustls-pemfile = "2.1" + +[[bin]] +name = "boon" +path = "src/main.rs" diff --git a/validator/cli/src/main.rs b/validator/cli/src/main.rs new file mode 100644 index 0000000..1d7dcf1 --- /dev/null +++ b/validator/cli/src/main.rs @@ -0,0 +1,316 @@ +use core::panic; +use std::{env, error::Error, fs::File, io::BufReader, process, str::FromStr, sync::Arc}; + +use boon::{Compiler, Draft, Schemas, SchemeUrlLoader, UrlLoader}; +use getopts::Options; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use serde_json::Value; +use ureq::Agent; +use url::Url; + +fn main() { + let opts = options(); + let matches = match opts.parse(env::args().skip(1)) { + Ok(m) => m, + Err(f) => { + eprintln!("{f}"); + eprintln!(); + eprintln!("{}", opts.usage(BRIEF)); + process::exit(1) + } + }; + + if matches.opt_present("version") { + println!("{}", env!("CARGO_PKG_VERSION")); + process::exit(0); + } + + if matches.opt_present("help") { + println!("{}", opts.usage(BRIEF)); + process::exit(0); + } + + // draft -- + let mut draft = Draft::default(); + if let Some(v) = matches.opt_str("draft") { + let Ok(v) = usize::from_str(&v) else { + eprintln!("invalid draft: {v}"); + eprintln!(); + eprintln!("{}", opts.usage(BRIEF)); + process::exit(1); + }; + draft = match v { + 4 => Draft::V4, + 6 => Draft::V6, + 7 => Draft::V7, + 2019 => Draft::V2019_09, + 2020 => Draft::V2020_12, + _ => { + eprintln!("invalid draft: {v}"); + eprintln!(); + eprintln!("{}", opts.usage(BRIEF)); + process::exit(1); + } + }; + } + + // output -- + let output = matches.opt_str("output"); + if let Some(o) = &output { + if !matches!(o.as_str(), "simple" | "alt" | "flag" | "basic" | "detailed") { + eprintln!("invalid output: {o}"); + eprintln!(); + eprintln!("{}", opts.usage(BRIEF)); + process::exit(1); + } + } + + // flags -- + let quiet = matches.opt_present("quiet"); + let assert_format = matches.opt_present("assert-format"); + let assert_content = matches.opt_present("assert-content"); + let insecure = matches.opt_present("insecure"); + + // schema -- + let Some(schema) = matches.free.first() else { + eprintln!("missing SCHEMA"); + eprintln!(); + eprintln!("{}", opts.usage(BRIEF)); + process::exit(1); + }; + + // compile -- + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + let mut loader = SchemeUrlLoader::new(); + loader.register("file", Box::new(FileUrlLoader)); + let cacert = matches.opt_str("cacert"); + let cacert = cacert.as_deref(); + loader.register("http", Box::new(HttpUrlLoader::new(cacert, insecure))); + loader.register("https", Box::new(HttpUrlLoader::new(cacert, insecure))); + compiler.use_loader(Box::new(loader)); + compiler.set_default_draft(draft); + if assert_format { + compiler.enable_format_assertions(); + } + if assert_content { + compiler.enable_content_assertions(); + } + let sch = match compiler.compile(schema, &mut schemas) { + Ok(sch) => { + println!("schema {schema}: ok"); + sch + } + Err(e) => { + println!("schema {schema}: failed"); + if !quiet { + println!("{e:#}"); + } + process::exit(2); + } + }; + + // validate -- + let mut all_valid = true; + for instance in &matches.free[1..] { + if !quiet { + println!(); + } + let rdr = match File::open(instance) { + Ok(rdr) => BufReader::new(rdr), + Err(e) => { + println!("instance {instance}: failed"); + if !quiet { + println!("error reading file {instance}: {e}"); + } + all_valid = false; + continue; + } + }; + let value: Result = + if instance.ends_with(".yaml") || instance.ends_with(".yml") { + serde_yaml::from_reader(rdr).map_err(|e| e.to_string()) + } else { + serde_json::from_reader(rdr).map_err(|e| e.to_string()) + }; + let value = match value { + Ok(v) => v, + Err(e) => { + println!("instance {instance}: failed"); + if !quiet { + println!("error parsing file {instance}: {e}"); + } + all_valid = false; + continue; + } + }; + match schemas.validate(&value, sch) { + Ok(_) => println!("instance {instance}: ok"), + Err(e) => { + println!("instance {instance}: failed"); + if !quiet { + match &output { + Some(out) => match out.as_str() { + "simple" => println!("{e}"), + "alt" => println!("{e:#}"), + "flag" => println!("{:#}", e.flag_output()), + "basic" => println!("{:#}", e.basic_output()), + "detailed" => println!("{:#}", e.detailed_output()), + _ => (), + }, + None => println!("{e}"), + } + } + all_valid = false; + continue; + } + }; + } + if !all_valid { + process::exit(2); + } +} + +const BRIEF: &str = "Usage: boon [OPTIONS] SCHEMA [INSTANCE...]"; + +fn options() -> Options { + let mut opts = Options::new(); + opts.optflag("v", "version", "Print version and exit"); + opts.optflag("h", "help", "Print help information"); + opts.optflag("q", "quiet", "Do not print errors"); + opts.optopt( + "d", + "draft", + "Draft used when '$schema' is missing. Valid values 4, 6, 7, 2019, 2020 (default 2020)", + "", + ); + opts.optopt( + "o", + "output", + "Output format. Valid values simple, alt, flag, basic, detailed (default simple)", + "", + ); + opts.optflag( + "f", + "assert-format", + "Enable format assertions with draft >= 2019", + ); + opts.optflag( + "c", + "assert-content", + "Enable content assertions with draft >= 7", + ); + opts.optopt( + "", + "cacert", + "Use the specified PEM certificate file to verify the peer. The file may contain multiple CA certificates", + "", + ); + opts.optflag("k", "insecure", "Use insecure TLS connection"); + opts +} + +struct FileUrlLoader; +impl UrlLoader for FileUrlLoader { + fn load(&self, url: &str) -> Result> { + let url = Url::parse(url)?; + let path = url.to_file_path().map_err(|_| "invalid file path")?; + let file = File::open(&path)?; + if path + .extension() + .filter(|&ext| ext == "yaml" || ext == "yml") + .is_some() + { + Ok(serde_yaml::from_reader(file)?) + } else { + Ok(serde_json::from_reader(file)?) + } + } +} + +struct HttpUrlLoader(Agent); + +impl HttpUrlLoader { + fn new(cacert: Option<&str>, insecure: bool) -> Self { + let mut builder = ureq::builder(); + if let Some(cacert) = cacert { + let file = File::open(cacert).unwrap_or_else(|e| panic!("error opening {cacert}: {e}")); + let certs: Result, _> = + rustls_pemfile::certs(&mut BufReader::new(file)).collect(); + let certs = certs.unwrap_or_else(|e| panic!("error reading cacert: {e}")); + assert!(!certs.is_empty(), "no certs in cacert"); + let mut store = rustls::RootCertStore::empty(); + for cert in certs { + store + .add(cert) + .unwrap_or_else(|e| panic!("error adding cert: {e}")) + } + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(store) + .with_no_client_auth(); + builder = builder.tls_config(tls_config.into()); + } else if insecure { + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) + .with_no_client_auth(); + builder = builder.tls_config(tls_config.into()); + } + Self(builder.build()) + } +} + +impl UrlLoader for HttpUrlLoader { + fn load(&self, url: &str) -> Result> { + let response = self.0.get(url).call()?; + let is_yaml = url.ends_with(".yaml") || url.ends_with(".yml") || { + let ctype = response.content_type(); + ctype.ends_with("/yaml") || ctype.ends_with("-yaml") + }; + if is_yaml { + Ok(serde_yaml::from_reader(response.into_reader())?) + } else { + Ok(serde_json::from_reader(response.into_reader())?) + } + } +} + +#[derive(Debug)] +struct InsecureVerifier; + +impl ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} diff --git a/validator/src/compiler.rs b/validator/src/compiler.rs new file mode 100644 index 0000000..ca30eee --- /dev/null +++ b/validator/src/compiler.rs @@ -0,0 +1,985 @@ +use std::{cmp::Ordering, collections::HashMap, error::Error, fmt::Display}; + +use regex::Regex; +use serde_json::{Map, Value}; +use url::Url; + +use crate::{content::*, draft::*, ecma, formats::*, root::*, roots::*, util::*, *}; + +/// Supported draft versions +#[non_exhaustive] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Draft { + /// Draft for `http://json-schema.org/draft-04/schema` + V4, + /// Draft for `http://json-schema.org/draft-06/schema` + V6, + /// Draft for `http://json-schema.org/draft-07/schema` + V7, + /// Draft for `https://json-schema.org/draft/2019-09/schema` + V2019_09, + /// Draft for `https://json-schema.org/draft/2020-12/schema` + V2020_12, +} + +impl Draft { + /** + Get [`Draft`] for given `url` + + # Arguments + + * `url` - accepts both `http` and `https` and ignores any fragments in url + + # Examples + + ``` + # use boon::*; + assert_eq!(Draft::from_url("https://json-schema.org/draft/2020-12/schema"), Some(Draft::V2020_12)); + assert_eq!(Draft::from_url("http://json-schema.org/draft-07/schema#"), Some(Draft::V7)); + ``` + */ + pub fn from_url(url: &str) -> Option { + match crate::draft::Draft::from_url(url) { + Some(draft) => match draft.version { + 4 => Some(Draft::V4), + 6 => Some(Draft::V6), + 7 => Some(Draft::V7), + 2019 => Some(Draft::V2019_09), + 2020 => Some(Draft::V2020_12), + _ => None, + }, + None => None, + } + } + + pub(crate) fn internal(&self) -> &'static crate::draft::Draft { + match self { + Draft::V4 => &DRAFT4, + Draft::V6 => &DRAFT6, + Draft::V7 => &DRAFT7, + Draft::V2019_09 => &DRAFT2019, + Draft::V2020_12 => &DRAFT2020, + } + } +} + +/// Returns latest draft supported +impl Default for Draft { + fn default() -> Self { + Draft::V2020_12 + } +} + +/// JsonSchema compiler. +#[derive(Default)] +pub struct Compiler { + roots: Roots, + assert_format: bool, + assert_content: bool, + formats: HashMap<&'static str, Format>, + decoders: HashMap<&'static str, Decoder>, + media_types: HashMap<&'static str, MediaType>, +} + +impl Compiler { + pub fn new() -> Self { + Self::default() + } + + /** + Overrides the draft used to compile schemas without + explicit `$schema` field. + + By default this library uses latest draft supported. + + The use of this option is HIGHLY encouraged to ensure + continued correct operation of your schema. The current + default value will not stay the same over time. + */ + pub fn set_default_draft(&mut self, d: Draft) { + self.roots.default_draft = d.internal() + } + + /** + Always enable format assertions. + + # Default Behavior + + - for draft-07 and earlier: enabled + - for draft/2019-09: disabled, unless + metaschema says `format` vocabulary is required + - for draft/2020-12: disabled, unless + metaschema says `format-assertion` vocabulary is required + */ + pub fn enable_format_assertions(&mut self) { + self.assert_format = true; + } + + /** + Always enable content assertions. + + content assertions include keywords: + - contentEncoding + - contentMediaType + - contentSchema + + Default Behavior is always disabled. + */ + pub fn enable_content_assertions(&mut self) { + self.assert_content = true; + } + + /// Overrides default [`UrlLoader`] used to load schema resources + pub fn use_loader(&mut self, url_loader: Box) { + self.roots.loader.use_loader(url_loader); + } + + /** + Registers custom `format` + + # Note + + - `regex` format cannot be overridden + - format assertions are disabled for draft >= 2019-09. + see [`Compiler::enable_format_assertions`] + */ + pub fn register_format(&mut self, format: Format) { + if format.name != "regex" { + self.formats.insert(format.name, format); + } + } + + /** + Registers custom `contentEncoding` + + Note that content assertions are disabled by default. + see [`Compiler::enable_content_assertions`] + */ + pub fn register_content_encoding(&mut self, decoder: Decoder) { + self.decoders.insert(decoder.name, decoder); + } + + /** + Registers custom `contentMediaType` + + Note that content assertions are disabled by default. + see [`Compiler::enable_content_assertions`] + */ + pub fn register_content_media_type(&mut self, media_type: MediaType) { + self.media_types.insert(media_type.name, media_type); + } + + /** + Adds schema resource which used later in reference resoltion + If you do not know which schema resources required, then use [`UrlLoader`]. + + The argument `loc` can be file path or url. any fragment in `loc` is ignored. + + # Errors + + returns [`CompileError`] if url parsing failed. + */ + pub fn add_resource(&mut self, loc: &str, json: Value) -> Result<(), CompileError> { + let uf = UrlFrag::absolute(loc)?; + self.roots.loader.add_doc(uf.url, json); + Ok(()) + } + + /** + Compile given `loc` into `target` and return an identifier to the compiled + schema. + + the argument `loc` can be file path or url with optional fragment. + examples: `http://example.com/schema.json#/defs/address`, + `samples/schema_file.json#defs/address` + + if `loc` is already compiled, it simply returns the same [`SchemaIndex`] + */ + pub fn compile( + &mut self, + loc: &str, + target: &mut Schemas, + ) -> Result { + let uf = UrlFrag::absolute(loc)?; + // resolve anchor + let up = self.roots.resolve_fragment(uf)?; + + let result = self.do_compile(up, target); + if let Err(bug @ CompileError::Bug(_)) = &result { + debug_assert!(false, "{bug}"); + } + result + } + + fn do_compile( + &mut self, + up: UrlPtr, + target: &mut Schemas, + ) -> Result { + let mut queue = Queue::new(); + let mut compiled = Vec::new(); + + let index = queue.enqueue_schema(target, up); + if queue.schemas.is_empty() { + // already got compiled + return Ok(index); + } + + while queue.schemas.len() > compiled.len() { + let up = &queue.schemas[compiled.len()]; + self.roots.ensure_subschema(up)?; + let Some(root) = self.roots.get(&up.url) else { + return Err(CompileError::Bug("or_load didn't add".into())); + }; + let doc = self.roots.loader.load(&root.url)?; + let v = up.lookup(doc)?; + let sch = self.compile_value(target, v, &up.clone(), root, &mut queue)?; + compiled.push(sch); + self.roots.insert(&mut queue.roots); + } + + target.insert(queue.schemas, compiled); + Ok(index) + } + + fn compile_value( + &self, + schemas: &Schemas, + v: &Value, + up: &UrlPtr, + root: &Root, + queue: &mut Queue, + ) -> Result { + let mut s = Schema::new(up.to_string()); + s.draft_version = root.draft.version; + + // we know it is already in queue, we just want to get its index + let len = queue.schemas.len(); + s.idx = queue.enqueue_schema(schemas, up.to_owned()); + debug_assert_eq!(queue.schemas.len(), len, "{up} should already be in queue"); + + s.resource = { + let base = UrlPtr { + url: up.url.clone(), + ptr: root.resource(&up.ptr).ptr.clone(), + }; + queue.enqueue_schema(schemas, base) + }; + + // if resource, enqueue dynamicAnchors for compilation + if s.idx == s.resource && root.draft.version >= 2020 { + let res = root.resource(&up.ptr); + for (anchor, anchor_ptr) in &res.anchors { + if res.dynamic_anchors.contains(anchor) { + let up = UrlPtr { + url: up.url.clone(), + ptr: anchor_ptr.clone(), + }; + let danchor_sch = queue.enqueue_schema(schemas, up); + s.dynamic_anchors.insert(anchor.to_string(), danchor_sch); + } + } + } + + match v { + Value::Object(obj) => { + if obj.is_empty() { + s.boolean = Some(true); + } else { + ObjCompiler { + c: self, + obj, + up, + schemas, + root, + queue, + } + .compile_obj(&mut s)?; + } + } + Value::Bool(b) => s.boolean = Some(*b), + _ => {} + } + + s.all_props_evaluated = s.additional_properties.is_some(); + s.all_items_evaluated = if s.draft_version < 2020 { + s.additional_items.is_some() || matches!(s.items, Some(Items::SchemaRef(_))) + } else { + s.items2020.is_some() + }; + s.num_items_evaluated = if let Some(Items::SchemaRefs(list)) = &s.items { + list.len() + } else { + s.prefix_items.len() + }; + + Ok(s) + } +} + +struct ObjCompiler<'c, 'v, 'l, 's, 'r, 'q> { + c: &'c Compiler, + obj: &'v Map, + up: &'l UrlPtr, + schemas: &'s Schemas, + root: &'r Root, + queue: &'q mut Queue, +} + +// compile supported drafts +impl ObjCompiler<'_, '_, '_, '_, '_, '_> { + fn compile_obj(&mut self, s: &mut Schema) -> Result<(), CompileError> { + self.compile_draft4(s)?; + if self.draft_version() >= 6 { + self.compile_draft6(s)?; + } + if self.draft_version() >= 7 { + self.compile_draft7(s)?; + } + if self.draft_version() >= 2019 { + self.compile_draft2019(s)?; + } + if self.draft_version() >= 2020 { + self.compile_draft2020(s)?; + } + Ok(()) + } + + fn compile_draft4(&mut self, s: &mut Schema) -> Result<(), CompileError> { + if self.has_vocab("core") { + s.ref_ = self.enqueue_ref("$ref")?; + if s.ref_.is_some() && self.draft_version() < 2019 { + // All other properties in a "$ref" object MUST be ignored + return Ok(()); + } + } + + if self.has_vocab("applicator") { + s.all_of = self.enqueue_arr("allOf"); + s.any_of = self.enqueue_arr("anyOf"); + s.one_of = self.enqueue_arr("oneOf"); + s.not = self.enqueue_prop("not"); + + if self.draft_version() < 2020 { + match self.value("items") { + Some(Value::Array(_)) => { + s.items = Some(Items::SchemaRefs(self.enqueue_arr("items"))); + s.additional_items = self.enquue_additional("additionalItems"); + } + _ => s.items = self.enqueue_prop("items").map(Items::SchemaRef), + } + } + + s.properties = self.enqueue_map("properties"); + s.pattern_properties = { + let mut v = vec![]; + if let Some(Value::Object(obj)) = self.value("patternProperties") { + for pname in obj.keys() { + let ecma = + ecma::convert(pname).map_err(|src| CompileError::InvalidRegex { + url: self.up.format("patternProperties"), + regex: pname.to_owned(), + src, + })?; + let regex = + Regex::new(ecma.as_ref()).map_err(|e| CompileError::InvalidRegex { + url: self.up.format("patternProperties"), + regex: ecma.into_owned(), + src: e.into(), + })?; + let ptr = self.up.ptr.append2("patternProperties", pname); + let sch = self.enqueue_schema(ptr); + v.push((regex, sch)); + } + } + v + }; + + s.additional_properties = self.enquue_additional("additionalProperties"); + + if let Some(Value::Object(deps)) = self.value("dependencies") { + s.dependencies = deps + .iter() + .filter_map(|(k, v)| { + let v = match v { + Value::Array(_) => Some(Dependency::Props(to_strings(v))), + _ => { + let ptr = self.up.ptr.append2("dependencies", k); + Some(Dependency::SchemaRef(self.enqueue_schema(ptr))) + } + }; + v.map(|v| (k.clone(), v)) + }) + .collect(); + } + } + + if self.has_vocab("validation") { + match self.value("type") { + Some(Value::String(t)) => { + if let Some(t) = Type::from_str(t) { + s.types.add(t) + } + } + Some(Value::Array(arr)) => { + for t in arr { + if let Value::String(t) = t { + if let Some(t) = Type::from_str(t) { + s.types.add(t) + } + } + } + } + _ => {} + } + + if let Some(Value::Array(e)) = self.value("enum") { + let mut types = Types::default(); + for item in e { + types.add(Type::of(item)); + } + s.enum_ = Some(Enum { + types, + values: e.clone(), + }); + } + + s.multiple_of = self.num("multipleOf"); + + s.maximum = self.num("maximum"); + if let Some(Value::Bool(exclusive)) = self.value("exclusiveMaximum") { + if *exclusive { + s.exclusive_maximum = s.maximum.take(); + } + } else { + s.exclusive_maximum = self.num("exclusiveMaximum"); + } + + s.minimum = self.num("minimum"); + if let Some(Value::Bool(exclusive)) = self.value("exclusiveMinimum") { + if *exclusive { + s.exclusive_minimum = s.minimum.take(); + } + } else { + s.exclusive_minimum = self.num("exclusiveMinimum"); + } + + s.max_length = self.usize("maxLength"); + s.min_length = self.usize("minLength"); + + if let Some(Value::String(p)) = self.value("pattern") { + let p = ecma::convert(p).map_err(CompileError::Bug)?; + s.pattern = Some(Regex::new(p.as_ref()).map_err(|e| CompileError::Bug(e.into()))?); + } + + s.max_items = self.usize("maxItems"); + s.min_items = self.usize("minItems"); + s.unique_items = self.bool("uniqueItems"); + + s.max_properties = self.usize("maxProperties"); + s.min_properties = self.usize("minProperties"); + + if let Some(req) = self.value("required") { + s.required = to_strings(req); + } + } + + // format -- + if self.c.assert_format + || self.has_vocab(match self.draft_version().cmp(&2019) { + Ordering::Less => "core", + Ordering::Equal => "format", + Ordering::Greater => "format-assertion", + }) + { + if let Some(Value::String(format)) = self.value("format") { + s.format = self + .c + .formats + .get(format.as_str()) + .or_else(|| FORMATS.get(format.as_str())) + .cloned(); + } + } + + Ok(()) + } + + fn compile_draft6(&mut self, s: &mut Schema) -> Result<(), CompileError> { + if self.has_vocab("applicator") { + s.contains = self.enqueue_prop("contains"); + s.property_names = self.enqueue_prop("propertyNames"); + } + + if self.has_vocab("validation") { + s.constant = self.value("const").cloned(); + } + + Ok(()) + } + + fn compile_draft7(&mut self, s: &mut Schema) -> Result<(), CompileError> { + if self.has_vocab("applicator") { + s.if_ = self.enqueue_prop("if"); + if s.if_.is_some() { + if !self.bool_schema("if", false) { + s.then = self.enqueue_prop("then"); + } + if !self.bool_schema("if", true) { + s.else_ = self.enqueue_prop("else"); + } + } + } + + if self.c.assert_content { + if let Some(Value::String(encoding)) = self.value("contentEncoding") { + s.content_encoding = self + .c + .decoders + .get(encoding.as_str()) + .or_else(|| DECODERS.get(encoding.as_str())) + .cloned(); + } + + if let Some(Value::String(media_type)) = self.value("contentMediaType") { + s.content_media_type = self + .c + .media_types + .get(media_type.as_str()) + .or_else(|| MEDIA_TYPES.get(media_type.as_str())) + .cloned(); + } + } + + Ok(()) + } + + fn compile_draft2019(&mut self, s: &mut Schema) -> Result<(), CompileError> { + if self.has_vocab("core") { + s.recursive_ref = self.enqueue_ref("$recursiveRef")?; + s.recursive_anchor = self.bool("$recursiveAnchor"); + } + + if self.has_vocab("validation") { + if s.contains.is_some() { + s.max_contains = self.usize("maxContains"); + s.min_contains = self.usize("minContains"); + } + + if let Some(Value::Object(dep_req)) = self.value("dependentRequired") { + for (pname, pvalue) in dep_req { + s.dependent_required + .push((pname.clone(), to_strings(pvalue))); + } + } + } + + if self.has_vocab("applicator") { + s.dependent_schemas = self.enqueue_map("dependentSchemas"); + } + + if self.has_vocab(match self.draft_version() { + 2019 => "applicator", + _ => "unevaluated", + }) { + s.unevaluated_items = self.enqueue_prop("unevaluatedItems"); + s.unevaluated_properties = self.enqueue_prop("unevaluatedProperties"); + } + + if self.c.assert_content + && s.content_media_type + .map(|mt| mt.json_compatible) + .unwrap_or(false) + { + s.content_schema = self.enqueue_prop("contentSchema"); + } + + Ok(()) + } + + fn compile_draft2020(&mut self, s: &mut Schema) -> Result<(), CompileError> { + if self.has_vocab("core") { + if let Some(sch) = self.enqueue_ref("$dynamicRef")? { + if let Some(Value::String(dref)) = self.value("$dynamicRef") { + let Ok((_, frag)) = Fragment::split(dref) else { + let loc = self.up.format("$dynamicRef"); + return Err(CompileError::ParseAnchorError { loc }); + }; + let anchor = match frag { + Fragment::Anchor(Anchor(s)) => Some(s), + Fragment::JsonPointer(_) => None, + }; + s.dynamic_ref = Some(DynamicRef { sch, anchor }); + } + }; + + if let Some(Value::String(anchor)) = self.value("$dynamicAnchor") { + s.dynamic_anchor = Some(anchor.to_owned()); + } + } + + if self.has_vocab("applicator") { + s.prefix_items = self.enqueue_arr("prefixItems"); + s.items2020 = self.enqueue_prop("items"); + } + + Ok(()) + } +} + +// enqueue helpers +impl ObjCompiler<'_, '_, '_, '_, '_, '_> { + fn enqueue_schema(&mut self, ptr: JsonPointer) -> SchemaIndex { + let up = UrlPtr { + url: self.up.url.clone(), + ptr, + }; + self.queue.enqueue_schema(self.schemas, up) + } + + fn enqueue_prop(&mut self, pname: &'static str) -> Option { + if self.obj.contains_key(pname) { + let ptr = self.up.ptr.append(pname); + Some(self.enqueue_schema(ptr)) + } else { + None + } + } + + fn enqueue_arr(&mut self, pname: &'static str) -> Vec { + if let Some(Value::Array(arr)) = self.obj.get(pname) { + (0..arr.len()) + .map(|i| { + let ptr = self.up.ptr.append2(pname, &i.to_string()); + self.enqueue_schema(ptr) + }) + .collect() + } else { + Vec::new() + } + } + + fn enqueue_map(&mut self, pname: &'static str) -> T + where + T: Default, + T: FromIterator<(String, SchemaIndex)>, + { + if let Some(Value::Object(obj)) = self.obj.get(pname) { + obj.keys() + .map(|k| { + let ptr = self.up.ptr.append2(pname, k); + (k.clone(), self.enqueue_schema(ptr)) + }) + .collect() + } else { + T::default() + } + } + + fn enqueue_ref(&mut self, pname: &str) -> Result, CompileError> { + let Some(Value::String(ref_)) = self.obj.get(pname) else { + return Ok(None); + }; + let base_url = self.root.base_url(&self.up.ptr); + let abs_ref = UrlFrag::join(base_url, ref_)?; + if let Some(resolved_ref) = self.root.resolve(&abs_ref)? { + // local ref + return Ok(Some(self.enqueue_schema(resolved_ref.ptr))); + } + // remote ref + let up = self.queue.resolve_anchor(abs_ref, &self.c.roots)?; + Ok(Some(self.queue.enqueue_schema(self.schemas, up))) + } + + fn enquue_additional(&mut self, pname: &'static str) -> Option { + if let Some(Value::Bool(b)) = self.obj.get(pname) { + Some(Additional::Bool(*b)) + } else { + self.enqueue_prop(pname).map(Additional::SchemaRef) + } + } +} + +// query helpers +impl<'v> ObjCompiler<'_, 'v, '_, '_, '_, '_> { + fn draft_version(&self) -> usize { + self.root.draft.version + } + + fn has_vocab(&self, name: &str) -> bool { + self.root.has_vocab(name) + } + + fn value(&self, pname: &str) -> Option<&'v Value> { + self.obj.get(pname) + } + + fn bool(&self, pname: &str) -> bool { + matches!(self.obj.get(pname), Some(Value::Bool(true))) + } + + fn usize(&self, pname: &str) -> Option { + let Some(Value::Number(n)) = self.obj.get(pname) else { + return None; + }; + if n.is_u64() { + n.as_u64().map(|n| n as usize) + } else { + n.as_f64() + .filter(|n| n.is_sign_positive() && n.fract() == 0.0) + .map(|n| n as usize) + } + } + + fn num(&self, pname: &str) -> Option { + if let Some(Value::Number(n)) = self.obj.get(pname) { + Some(n.clone()) + } else { + None + } + } + + fn bool_schema(&self, pname: &str, b: bool) -> bool { + if let Some(Value::Bool(v)) = self.obj.get(pname) { + return *v == b; + } + false + } +} + +/// Error type for compilation failures. +#[derive(Debug)] +pub enum CompileError { + /// Error in parsing `url`. + ParseUrlError { url: String, src: Box }, + + /// Failed loading `url`. + LoadUrlError { url: String, src: Box }, + + /// no [`UrlLoader`] registered for the `url` + UnsupportedUrlScheme { url: String }, + + /// Error in parsing `$schema` url. + InvalidMetaSchemaUrl { url: String, src: Box }, + + /// draft `url` is not supported + UnsupportedDraft { url: String }, + + /// Cycle in resolving `$schema` in `url`. + MetaSchemaCycle { url: String }, + + /// `url` is not valid against metaschema. + ValidationError { + url: String, + src: ValidationError<'static, 'static>, + }, + + /// Error in parsing id at `loc` + ParseIdError { loc: String }, + + /// Error in parsing anchor at `loc` + ParseAnchorError { loc: String }, + + /// Duplicate id `id` in `url` at `ptr1` and `ptr2`. + DuplicateId { + url: String, + id: String, + ptr1: String, + ptr2: String, + }, + + /// Duplicate anchor `anchor` in `url` at `ptr1` and `ptr2`. + DuplicateAnchor { + anchor: String, + url: String, + ptr1: String, + ptr2: String, + }, + + /// Not a valid json pointer. + InvalidJsonPointer(String), + + /// JsonPointer evaluated to nothing. + JsonPointerNotFound(String), + + /// anchor in `reference` not found in `url`. + AnchorNotFound { url: String, reference: String }, + + /// Unsupported vocabulary `vocabulary` in `url`. + UnsupportedVocabulary { url: String, vocabulary: String }, + + /// Invalid Regex `regex` at `url`. + InvalidRegex { + url: String, + regex: String, + src: Box, + }, + + /// Encountered bug in compiler implementation. Please report + /// this as an issue for this crate. + Bug(Box), +} + +impl Error for CompileError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ParseUrlError { src, .. } => Some(src.as_ref()), + Self::LoadUrlError { src, .. } => Some(src.as_ref()), + Self::InvalidMetaSchemaUrl { src, .. } => Some(src.as_ref()), + Self::ValidationError { src, .. } => Some(src), + Self::Bug(src) => Some(src.as_ref()), + _ => None, + } + } +} + +impl Display for CompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ParseUrlError { url, src } => { + if f.alternate() { + write!(f, "error parsing url {url}: {src}") + } else { + write!(f, "error parsing {url}") + } + } + Self::LoadUrlError { url, src } => { + if f.alternate() { + write!(f, "error loading {url}: {src}") + } else { + write!(f, "error loading {url}") + } + } + Self::UnsupportedUrlScheme { url } => write!(f, "unsupported scheme in {url}"), + Self::InvalidMetaSchemaUrl { url, src } => { + if f.alternate() { + write!(f, "invalid $schema in {url}: {src}") + } else { + write!(f, "invalid $schema in {url}") + } + } + Self::UnsupportedDraft { url } => write!(f, "draft {url} is not supported"), + Self::MetaSchemaCycle { url } => { + write!(f, "cycle in resolving $schema in {url}") + } + Self::ValidationError { url, src } => { + if f.alternate() { + write!(f, "{url} is not valid against metaschema: {src}") + } else { + write!(f, "{url} is not valid against metaschema") + } + } + Self::ParseIdError { loc } => write!(f, "error in parsing id at {loc}"), + Self::ParseAnchorError { loc } => write!(f, "error in parsing anchor at {loc}"), + Self::DuplicateId { + url, + id, + ptr1, + ptr2, + } => write!(f, "duplicate $id {id} in {url} at {ptr1:?} and {ptr2:?}"), + Self::DuplicateAnchor { + anchor, + url, + ptr1, + ptr2, + } => { + write!( + f, + "duplicate anchor {anchor:?} in {url} at {ptr1:?} and {ptr2:?}" + ) + } + Self::InvalidJsonPointer(loc) => write!(f, "invalid json-pointer {loc}"), + Self::JsonPointerNotFound(loc) => write!(f, "json-pointer in {loc} not found"), + Self::AnchorNotFound { url, reference } => { + write!( + f, + "anchor in reference {reference} is not found in schema {url}" + ) + } + Self::UnsupportedVocabulary { url, vocabulary } => { + write!(f, "unsupported vocabulary {vocabulary} in {url}") + } + Self::InvalidRegex { url, regex, src } => { + if f.alternate() { + write!(f, "invalid regex {} at {url}: {src}", quote(regex)) + } else { + write!(f, "invalid regex {} at {url}", quote(regex)) + } + } + Self::Bug(src) => { + write!( + f, + "encountered bug in jsonschema compiler. please report: {src}" + ) + } + } + } +} + +// helpers -- + +fn to_strings(v: &Value) -> Vec { + if let Value::Array(a) = v { + a.iter() + .filter_map(|t| { + if let Value::String(t) = t { + Some(t.clone()) + } else { + None + } + }) + .collect() + } else { + vec![] + } +} + +pub(crate) struct Queue { + pub(crate) schemas: Vec, + pub(crate) roots: HashMap, +} + +impl Queue { + fn new() -> Self { + Self { + schemas: vec![], + roots: HashMap::new(), + } + } + + pub(crate) fn resolve_anchor( + &mut self, + uf: UrlFrag, + roots: &Roots, + ) -> Result { + match uf.frag { + Fragment::JsonPointer(ptr) => Ok(UrlPtr { url: uf.url, ptr }), + Fragment::Anchor(_) => { + let root = match roots.get(&uf.url).or_else(|| self.roots.get(&uf.url)) { + Some(root) => root, + None => { + let doc = roots.loader.load(&uf.url)?; + let r = roots.create_root(uf.url.clone(), doc)?; + self.roots.entry(uf.url).or_insert(r) + } + }; + root.resolve_fragment(&uf.frag) + } + } + } + + pub(crate) fn enqueue_schema(&mut self, schemas: &Schemas, up: UrlPtr) -> SchemaIndex { + if let Some(sch) = schemas.get_by_loc(&up) { + // already got compiled + return sch.idx; + } + if let Some(qindex) = self.schemas.iter().position(|e| *e == up) { + // already queued for compilation + return SchemaIndex(schemas.size() + qindex); + } + + // new compilation request + self.schemas.push(up); + SchemaIndex(schemas.size() + self.schemas.len() - 1) + } +} diff --git a/validator/src/content.rs b/validator/src/content.rs new file mode 100644 index 0000000..58ccdd7 --- /dev/null +++ b/validator/src/content.rs @@ -0,0 +1,82 @@ +use std::{collections::HashMap, error::Error}; + +use base64::Engine; +use once_cell::sync::Lazy; +use serde::de::IgnoredAny; +use serde_json::Value; + +// decoders -- + +/// Defines Decoder for `contentEncoding`. +#[derive(Clone, Copy)] +pub struct Decoder { + /// Name of the encoding + pub name: &'static str, + + /// Decodes given string to bytes + #[allow(clippy::type_complexity)] + pub func: fn(s: &str) -> Result, Box>, +} + +pub(crate) static DECODERS: Lazy> = Lazy::new(|| { + let mut m = HashMap::<&'static str, Decoder>::new(); + m.insert( + "base64", + Decoder { + name: "base64", + func: decode_base64, + }, + ); + m +}); + +fn decode_base64(s: &str) -> Result, Box> { + Ok(base64::engine::general_purpose::STANDARD.decode(s)?) +} + +// mediatypes -- + +/// Defines Mediatype for `contentMediaType`. +#[derive(Clone, Copy)] +pub struct MediaType { + /// Name of this media-type as defined in RFC 2046. + /// Example: `application/json` + pub name: &'static str, + + /// whether this media type can be deserialized to json. If so it can + /// be validated by `contentSchema` keyword. + pub json_compatible: bool, + + /** + Check whether `bytes` conforms to this media-type. + + Should return `Ok(Some(Value))` if `deserialize` is `true`, otherwise it can return `Ok(None)`. + Ideally you could deserialize to `serde::de::IgnoredAny` if `deserialize` is `false` to gain + some performance. + + `deserialize` is always `false` if `json_compatible` is `false`. + */ + #[allow(clippy::type_complexity)] + pub func: fn(bytes: &[u8], deserialize: bool) -> Result, Box>, +} + +pub(crate) static MEDIA_TYPES: Lazy> = Lazy::new(|| { + let mut m = HashMap::<&'static str, MediaType>::new(); + m.insert( + "application/json", + MediaType { + name: "application/json", + json_compatible: true, + func: check_json, + }, + ); + m +}); + +fn check_json(bytes: &[u8], deserialize: bool) -> Result, Box> { + if deserialize { + return Ok(Some(serde_json::from_slice(bytes)?)); + } + serde_json::from_slice::(bytes)?; + Ok(None) +} diff --git a/validator/src/draft.rs b/validator/src/draft.rs new file mode 100644 index 0000000..91b73d2 --- /dev/null +++ b/validator/src/draft.rs @@ -0,0 +1,576 @@ +use std::{ + collections::{hash_map::Entry, HashMap}, + str::FromStr, +}; + +use once_cell::sync::Lazy; +use serde_json::{Map, Value}; +use url::Url; + +use crate::{compiler::*, root::Resource, util::*, SchemaIndex, Schemas}; + +const POS_SELF: u8 = 1 << 0; +const POS_PROP: u8 = 1 << 1; +const POS_ITEM: u8 = 1 << 2; + +pub(crate) static DRAFT4: Lazy = Lazy::new(|| Draft { + version: 4, + id: "id", + url: "http://json-schema.org/draft-04/schema", + subschemas: HashMap::from([ + // type agnostic + ("definitions", POS_PROP), + ("not", POS_SELF), + ("allOf", POS_ITEM), + ("anyOf", POS_ITEM), + ("oneOf", POS_ITEM), + // object + ("properties", POS_PROP), + ("additionalProperties", POS_SELF), + ("patternProperties", POS_PROP), + // array + ("items", POS_SELF | POS_ITEM), + ("additionalItems", POS_SELF), + ("dependencies", POS_PROP), + ]), + vocab_prefix: "", + all_vocabs: vec![], + default_vocabs: vec![], +}); + +pub(crate) static DRAFT6: Lazy = Lazy::new(|| { + let mut subschemas = DRAFT4.subschemas.clone(); + subschemas.extend([("propertyNames", POS_SELF), ("contains", POS_SELF)]); + Draft { + version: 6, + id: "$id", + url: "http://json-schema.org/draft-06/schema", + subschemas, + vocab_prefix: "", + all_vocabs: vec![], + default_vocabs: vec![], + } +}); + +pub(crate) static DRAFT7: Lazy = Lazy::new(|| { + let mut subschemas = DRAFT6.subschemas.clone(); + subschemas.extend([("if", POS_SELF), ("then", POS_SELF), ("else", POS_SELF)]); + Draft { + version: 7, + id: "$id", + url: "http://json-schema.org/draft-07/schema", + subschemas, + vocab_prefix: "", + all_vocabs: vec![], + default_vocabs: vec![], + } +}); + +pub(crate) static DRAFT2019: Lazy = Lazy::new(|| { + let mut subschemas = DRAFT7.subschemas.clone(); + subschemas.extend([ + ("$defs", POS_PROP), + ("dependentSchemas", POS_PROP), + ("unevaluatedProperties", POS_SELF), + ("unevaluatedItems", POS_SELF), + ("contentSchema", POS_SELF), + ]); + Draft { + version: 2019, + id: "$id", + url: "https://json-schema.org/draft/2019-09/schema", + subschemas, + vocab_prefix: "https://json-schema.org/draft/2019-09/vocab/", + all_vocabs: vec![ + "core", + "applicator", + "validation", + "meta-data", + "format", + "content", + ], + default_vocabs: vec!["core", "applicator", "validation"], + } +}); + +pub(crate) static DRAFT2020: Lazy = Lazy::new(|| { + let mut subschemas = DRAFT2019.subschemas.clone(); + subschemas.extend([("prefixItems", POS_ITEM)]); + Draft { + version: 2020, + id: "$id", + url: "https://json-schema.org/draft/2020-12/schema", + subschemas, + vocab_prefix: "https://json-schema.org/draft/2020-12/vocab/", + all_vocabs: vec![ + "core", + "applicator", + "unevaluated", + "validation", + "meta-data", + "format-annotation", + "format-assertion", + "content", + ], + default_vocabs: vec!["core", "applicator", "unevaluated", "validation"], + } +}); + +pub(crate) static STD_METASCHEMAS: Lazy = + Lazy::new(|| load_std_metaschemas().expect("std metaschemas must be compilable")); + +pub(crate) fn latest() -> &'static Draft { + crate::Draft::default().internal() +} + +// -- + +pub(crate) struct Draft { + pub(crate) version: usize, + pub(crate) url: &'static str, + id: &'static str, // property name used to represent id + subschemas: HashMap<&'static str, u8>, // location of subschemas + pub(crate) vocab_prefix: &'static str, // prefix used for vocabulary + pub(crate) all_vocabs: Vec<&'static str>, // names of supported vocabs + pub(crate) default_vocabs: Vec<&'static str>, // names of default vocabs +} + +impl Draft { + pub(crate) fn from_url(url: &str) -> Option<&'static Draft> { + let (mut url, frag) = split(url); + if !frag.is_empty() { + return None; + } + if let Some(s) = url.strip_prefix("http://") { + url = s; + } + if let Some(s) = url.strip_prefix("https://") { + url = s; + } + match url { + "json-schema.org/schema" => Some(latest()), + "json-schema.org/draft/2020-12/schema" => Some(&DRAFT2020), + "json-schema.org/draft/2019-09/schema" => Some(&DRAFT2019), + "json-schema.org/draft-07/schema" => Some(&DRAFT7), + "json-schema.org/draft-06/schema" => Some(&DRAFT6), + "json-schema.org/draft-04/schema" => Some(&DRAFT4), + _ => None, + } + } + + fn get_schema(&self) -> Option { + let url = match self.version { + 2020 => "https://json-schema.org/draft/2020-12/schema", + 2019 => "https://json-schema.org/draft/2019-09/schema", + 7 => "http://json-schema.org/draft-07/schema", + 6 => "http://json-schema.org/draft-06/schema", + 4 => "http://json-schema.org/draft-04/schema", + _ => return None, + }; + let up = UrlPtr { + url: Url::parse(url).unwrap_or_else(|_| panic!("{url} should be valid url")), + ptr: "".into(), + }; + STD_METASCHEMAS.get_by_loc(&up).map(|s| s.idx) + } + + pub(crate) fn validate(&self, up: &UrlPtr, v: &Value) -> Result<(), CompileError> { + let Some(sch) = self.get_schema() else { + return Err(CompileError::Bug( + format!("no metaschema preloaded for draft {}", self.version).into(), + )); + }; + STD_METASCHEMAS + .validate(v, sch) + .map_err(|src| CompileError::ValidationError { + url: up.to_string(), + src: src.clone_static(), + }) + } + + fn get_id<'a>(&self, obj: &'a Map) -> Option<&'a str> { + if self.version < 2019 && obj.contains_key("$ref") { + return None; // All other properties in a "$ref" object MUST be ignored + } + let Some(Value::String(id)) = obj.get(self.id) else { + return None; + }; + let (id, _) = split(id); // ignore fragment + Some(id).filter(|id| !id.is_empty()) + } + + pub(crate) fn get_vocabs( + &self, + url: &Url, + doc: &Value, + ) -> Result>, CompileError> { + if self.version < 2019 { + return Ok(None); + } + let Value::Object(obj) = doc else { + return Ok(None); + }; + + let Some(Value::Object(obj)) = obj.get("$vocabulary") else { + return Ok(None); + }; + + let mut vocabs = vec![]; + for (vocab, reqd) in obj { + if let Value::Bool(true) = reqd { + let name = vocab + .strip_prefix(self.vocab_prefix) + .filter(|name| self.all_vocabs.contains(name)); + if let Some(name) = name { + vocabs.push(name.to_owned()); // todo: avoid alloc + } else { + return Err(CompileError::UnsupportedVocabulary { + url: url.as_str().to_owned(), + vocabulary: vocab.to_owned(), + }); + } + } + } + Ok(Some(vocabs)) + } + + // collects anchors/dynamic_achors from `sch` into `res`. + // note this does not collect from subschemas in sch. + pub(crate) fn collect_anchors( + &self, + sch: &Value, + sch_ptr: &JsonPointer, + res: &mut Resource, + url: &Url, + ) -> Result<(), CompileError> { + let Value::Object(obj) = sch else { + return Ok(()); + }; + + let mut add_anchor = |anchor: Anchor| match res.anchors.entry(anchor) { + Entry::Occupied(entry) => { + if entry.get() == sch_ptr { + // anchor with same root_ptr already exists + return Ok(()); + } + Err(CompileError::DuplicateAnchor { + url: url.as_str().to_owned(), + anchor: entry.key().to_string(), + ptr1: entry.get().to_string(), + ptr2: sch_ptr.to_string(), + }) + } + entry => { + entry.or_insert(sch_ptr.to_owned()); + Ok(()) + } + }; + + if self.version < 2019 { + if obj.contains_key("$ref") { + return Ok(()); // All other properties in a "$ref" object MUST be ignored + } + // anchor is specified in id + if let Some(Value::String(id)) = obj.get(self.id) { + let Ok((_, frag)) = Fragment::split(id) else { + let loc = UrlFrag::format(url, sch_ptr.as_str()); + return Err(CompileError::ParseAnchorError { loc }); + }; + if let Fragment::Anchor(anchor) = frag { + add_anchor(anchor)?; + }; + return Ok(()); + } + } + if self.version >= 2019 { + if let Some(Value::String(anchor)) = obj.get("$anchor") { + add_anchor(anchor.as_str().into())?; + } + } + if self.version >= 2020 { + if let Some(Value::String(anchor)) = obj.get("$dynamicAnchor") { + add_anchor(anchor.as_str().into())?; + res.dynamic_anchors.insert(anchor.as_str().into()); + } + } + Ok(()) + } + + // error is json-ptr to invalid id + pub(crate) fn collect_resources( + &self, + sch: &Value, + base: &Url, // base of json + sch_ptr: JsonPointer, // ptr of json + url: &Url, + resources: &mut HashMap, + ) -> Result<(), CompileError> { + if resources.contains_key(&sch_ptr) { + // resources are already collected + return Ok(()); + } + if let Value::Bool(_) = sch { + if sch_ptr.is_empty() { + // root resource + resources.insert(sch_ptr.clone(), Resource::new(sch_ptr, base.clone())); + } + return Ok(()); + } + + let Value::Object(obj) = sch else { + return Ok(()); + }; + + let mut base = base; + let tmp; + let res = if let Some(id) = self.get_id(obj) { + let Ok(id) = UrlFrag::join(base, id) else { + let loc = UrlFrag::format(url, sch_ptr.as_str()); + return Err(CompileError::ParseIdError { loc }); + }; + tmp = id.url; + base = &tmp; + Some(Resource::new(sch_ptr.clone(), base.clone())) + } else if sch_ptr.is_empty() { + // root resource + Some(Resource::new(sch_ptr.clone(), base.clone())) + } else { + None + }; + if let Some(res) = res { + if let Some(dup) = resources.values_mut().find(|res| res.id == *base) { + return Err(CompileError::DuplicateId { + url: url.to_string(), + id: base.to_string(), + ptr1: res.ptr.to_string(), + ptr2: dup.ptr.to_string(), + }); + } + resources.insert(sch_ptr.clone(), res); + } + + // collect anchors into base resource + if let Some(res) = resources.values_mut().find(|res| res.id == *base) { + self.collect_anchors(sch, &sch_ptr, res, url)?; + } else { + debug_assert!(false, "base resource must exist"); + } + + for (&kw, &pos) in &self.subschemas { + let Some(v) = obj.get(kw) else { + continue; + }; + if pos & POS_SELF != 0 { + let ptr = sch_ptr.append(kw); + self.collect_resources(v, base, ptr, url, resources)?; + } + if pos & POS_ITEM != 0 { + if let Value::Array(arr) = v { + for (i, item) in arr.iter().enumerate() { + let ptr = sch_ptr.append2(kw, &i.to_string()); + self.collect_resources(item, base, ptr, url, resources)?; + } + } + } + if pos & POS_PROP != 0 { + if let Value::Object(obj) = v { + for (pname, pvalue) in obj { + let ptr = sch_ptr.append2(kw, pname); + self.collect_resources(pvalue, base, ptr, url, resources)?; + } + } + } + } + Ok(()) + } + + pub(crate) fn is_subschema(&self, ptr: &str) -> bool { + if ptr.is_empty() { + return true; + } + + fn split(mut ptr: &str) -> (&str, &str) { + ptr = &ptr[1..]; // rm `/` prefix + if let Some(i) = ptr.find('/') { + (&ptr[..i], &ptr[i..]) + } else { + (ptr, "") + } + } + + let (tok, ptr) = split(ptr); + + if let Some(&pos) = self.subschemas.get(tok) { + if pos & POS_SELF != 0 && self.is_subschema(ptr) { + return true; + } + if !ptr.is_empty() { + if pos & POS_PROP != 0 { + let (_, ptr) = split(ptr); + if self.is_subschema(ptr) { + return true; + } + } + if pos & POS_ITEM != 0 { + let (tok, ptr) = split(ptr); + if usize::from_str(tok).is_ok() && self.is_subschema(ptr) { + return true; + } + } + } + } + + false + } +} + +fn load_std_metaschemas() -> Result { + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_format_assertions(); + compiler.compile("https://json-schema.org/draft/2020-12/schema", &mut schemas)?; + compiler.compile("https://json-schema.org/draft/2019-09/schema", &mut schemas)?; + compiler.compile("http://json-schema.org/draft-07/schema", &mut schemas)?; + compiler.compile("http://json-schema.org/draft-06/schema", &mut schemas)?; + compiler.compile("http://json-schema.org/draft-04/schema", &mut schemas)?; + Ok(schemas) +} + +#[cfg(test)] +mod tests { + use crate::{Compiler, Schemas}; + + use super::*; + + #[test] + fn test_meta() { + let mut schemas = Schemas::default(); + let mut compiler = Compiler::default(); + let v: Value = serde_json::from_str(include_str!("metaschemas/draft-04/schema")).unwrap(); + let url = "https://json-schema.org/draft-04/schema"; + compiler.add_resource(url, v).unwrap(); + compiler.compile(url, &mut schemas).unwrap(); + } + + #[test] + fn test_from_url() { + let tests = [ + ("http://json-schema.org/draft/2020-12/schema", Some(2020)), // http url + ("https://json-schema.org/draft/2020-12/schema", Some(2020)), // https url + ("https://json-schema.org/schema", Some(latest().version)), // latest + ("https://json-schema.org/draft-04/schema", Some(4)), + ]; + for (url, version) in tests { + let got = Draft::from_url(url).map(|d| d.version); + assert_eq!(got, version, "for {url}"); + } + } + + #[test] + fn test_collect_ids() { + let url = Url::parse("http://a.com/schema.json").unwrap(); + let json: Value = serde_json::from_str( + r#"{ + "id": "http://a.com/schemas/schema.json", + "definitions": { + "s1": { "id": "http://a.com/definitions/s1" }, + "s2": { + "id": "../s2", + "items": [ + { "id": "http://c.com/item" }, + { "id": "http://d.com/item" } + ] + }, + "s3": { + "definitions": { + "s1": { + "id": "s3", + "items": { + "id": "http://b.com/item" + } + } + } + }, + "s4": { "id": "http://e.com/def#abcd" } + } + }"#, + ) + .unwrap(); + + let want = { + let mut m = HashMap::new(); + m.insert("", "http://a.com/schemas/schema.json"); // root with id + m.insert("/definitions/s1", "http://a.com/definitions/s1"); + m.insert("/definitions/s2", "http://a.com/s2"); // relative id + m.insert("/definitions/s3/definitions/s1", "http://a.com/schemas/s3"); + m.insert("/definitions/s3/definitions/s1/items", "http://b.com/item"); + m.insert("/definitions/s2/items/0", "http://c.com/item"); + m.insert("/definitions/s2/items/1", "http://d.com/item"); + m.insert("/definitions/s4", "http://e.com/def"); // id with fragments + m + }; + let mut got = HashMap::new(); + DRAFT4 + .collect_resources(&json, &url, "".into(), &url, &mut got) + .unwrap(); + let got = got + .iter() + .map(|(k, v)| (k.as_str(), v.id.as_str())) + .collect::>(); + assert_eq!(got, want); + } + + #[test] + fn test_collect_anchors() { + let url = Url::parse("http://a.com/schema.json").unwrap(); + let json: Value = serde_json::from_str( + r#"{ + "$defs": { + "s2": { + "$id": "http://b.com", + "$anchor": "b1", + "items": [ + { "$anchor": "b2" }, + { + "$id": "http//c.com", + "items": [ + {"$anchor": "c1"}, + {"$dynamicAnchor": "c2"} + ] + }, + { "$dynamicAnchor": "b3" } + ] + } + } + }"#, + ) + .unwrap(); + let mut resources = HashMap::new(); + DRAFT2020 + .collect_resources(&json, &url, "".into(), &url, &mut resources) + .unwrap(); + assert!(resources.get("").unwrap().anchors.is_empty()); + assert_eq!(resources.get("/$defs/s2").unwrap().anchors, { + let mut want = HashMap::new(); + want.insert("b1".into(), "/$defs/s2".into()); + want.insert("b2".into(), "/$defs/s2/items/0".into()); + want.insert("b3".into(), "/$defs/s2/items/2".into()); + want + }); + assert_eq!(resources.get("/$defs/s2/items/1").unwrap().anchors, { + let mut want = HashMap::new(); + want.insert("c1".into(), "/$defs/s2/items/1/items/0".into()); + want.insert("c2".into(), "/$defs/s2/items/1/items/1".into()); + want + }); + } + + #[test] + fn test_is_subschema() { + let tests = vec![("/allOf/0", true), ("/allOf/$defs", false)]; + for test in tests { + let got = DRAFT2020.is_subschema(test.0); + assert_eq!(got, test.1, "{}", test.0); + } + } +} diff --git a/validator/src/ecma.rs b/validator/src/ecma.rs new file mode 100644 index 0000000..78112f6 --- /dev/null +++ b/validator/src/ecma.rs @@ -0,0 +1,197 @@ +use std::borrow::Cow; + +use regex_syntax::ast::parse::Parser; +use regex_syntax::ast::{self, *}; + +// covert ecma regex to rust regex if possible +// see https://262.ecma-international.org/11.0/#sec-regexp-regular-expression-objects +pub(crate) fn convert(pattern: &str) -> Result, Box> { + let mut pattern = Cow::Borrowed(pattern); + + let mut ast = loop { + match Parser::new().parse(pattern.as_ref()) { + Ok(ast) => break ast, + Err(e) => { + if let Some(s) = fix_error(&e) { + pattern = Cow::Owned(s); + } else { + Err(e)?; + } + } + } + }; + + loop { + let translator = Translator { + pat: pattern.as_ref(), + out: None, + }; + if let Some(updated_pattern) = ast::visit(&ast, translator)? { + match Parser::new().parse(&updated_pattern) { + Ok(updated_ast) => { + pattern = Cow::Owned(updated_pattern); + ast = updated_ast; + } + Err(e) => { + debug_assert!( + false, + "ecma::translate changed {:?} to {:?}: {e}", + pattern, updated_pattern + ); + break; + } + } + } else { + break; + } + } + Ok(pattern) +} + +fn fix_error(e: &Error) -> Option { + if let ErrorKind::EscapeUnrecognized = e.kind() { + let (start, end) = (e.span().start.offset, e.span().end.offset); + let s = &e.pattern()[start..end]; + if let r"\c" = s { + // handle \c{control_letter} + if let Some(control_letter) = e.pattern()[end..].chars().next() { + if control_letter.is_ascii_alphabetic() { + return Some(format!( + "{}{}{}", + &e.pattern()[..start], + ((control_letter as u8) % 32) as char, + &e.pattern()[end + 1..], + )); + } + } + } + } + None +} + +/** +handles following translations: +- \d should ascii digits only. so replace with [0-9] +- \D should match everything but ascii digits. so replace with [^0-9] +- \w should match ascii letters only. so replace with [a-zA-Z0-9_] +- \W should match everything but ascii letters. so replace with [^a-zA-Z0-9_] +- \s and \S differences +- \a is not an ECMA 262 control escape +*/ +struct Translator<'a> { + pat: &'a str, + out: Option, +} + +impl Translator<'_> { + fn replace(&mut self, span: &Span, with: &str) { + let (start, end) = (span.start.offset, span.end.offset); + self.out = Some(format!("{}{with}{}", &self.pat[..start], &self.pat[end..])); + } + + fn replace_class_class(&mut self, perl: &ClassPerl) { + match perl.kind { + ClassPerlKind::Digit => { + self.replace(&perl.span, if perl.negated { "[^0-9]" } else { "[0-9]" }); + } + ClassPerlKind::Word => { + let with = &if perl.negated { + "[^A-Za-z0-9_]" + } else { + "[A-Za-z0-9_]" + }; + self.replace(&perl.span, with); + } + ClassPerlKind::Space => { + let with = &if perl.negated { + "[^ \t\n\r\u{000b}\u{000c}\u{00a0}\u{feff}\u{2003}\u{2029}]" + } else { + "[ \t\n\r\u{000b}\u{000c}\u{00a0}\u{feff}\u{2003}\u{2029}]" + }; + self.replace(&perl.span, with); + } + } + } +} + +impl Visitor for Translator<'_> { + type Output = Option; + type Err = &'static str; + + fn finish(self) -> Result { + Ok(self.out) + } + + fn visit_class_set_item_pre(&mut self, ast: &ast::ClassSetItem) -> Result<(), Self::Err> { + if let ClassSetItem::Perl(perl) = ast { + self.replace_class_class(perl); + } + Ok(()) + } + + fn visit_post(&mut self, ast: &Ast) -> Result<(), Self::Err> { + if self.out.is_some() { + return Ok(()); + } + match ast { + Ast::ClassPerl(perl) => { + self.replace_class_class(perl); + } + Ast::Literal(ref literal) => { + if let Literal { + kind: LiteralKind::Special(SpecialLiteralKind::Bell), + .. + } = literal.as_ref() + { + return Err("\\a is not an ECMA 262 control escape"); + } + } + _ => (), + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ecma_compat_valid() { + // println!("{:#?}", Parser::new().parse(r#"a\a"#)); + let tests = [ + (r"ab\cAcde\cBfg", "ab\u{1}cde\u{2}fg"), // \c{control_letter} + (r"\\comment", r"\\comment"), // there is no \c + (r"ab\def", r#"ab[0-9]ef"#), // \d + (r"ab[a-z\d]ef", r#"ab[a-z[0-9]]ef"#), // \d inside classSet + (r"ab\Def", r#"ab[^0-9]ef"#), // \d + (r"ab[a-z\D]ef", r#"ab[a-z[^0-9]]ef"#), // \D inside classSet + ]; + for (input, want) in tests { + match convert(input) { + Ok(got) => { + if got.as_ref() != want { + panic!("convert({input:?}): got: {got:?}, want: {want:?}"); + } + } + Err(e) => { + panic!("convert({input:?}) failed: {e}"); + } + } + } + } + + #[test] + fn test_ecma_compat_invalid() { + // println!("{:#?}", Parser::new().parse(r#"a\a"#)); + let tests = [ + r"\c\n", // \c{invalid_char} + r"abc\adef", // \a is not valid + ]; + for input in tests { + if convert(input).is_ok() { + panic!("convert({input:?}) mut fail"); + } + } + } +} diff --git a/validator/src/formats.rs b/validator/src/formats.rs new file mode 100644 index 0000000..f6ac288 --- /dev/null +++ b/validator/src/formats.rs @@ -0,0 +1,838 @@ +use std::{ + collections::HashMap, + error::Error, + net::{Ipv4Addr, Ipv6Addr}, +}; + +use once_cell::sync::Lazy; +use percent_encoding::percent_decode_str; +use serde_json::Value; +use url::Url; + +use crate::ecma; + +/// Defines format for `format` keyword. +#[derive(Clone, Copy)] +pub struct Format { + /// Name of the format + pub name: &'static str, + + /// validates given value. + pub func: fn(v: &Value) -> Result<(), Box>, +} + +pub(crate) static FORMATS: Lazy> = Lazy::new(|| { + let mut m = HashMap::<&'static str, Format>::new(); + let mut register = |name, func| m.insert(name, Format { name, func }); + register("regex", validate_regex); + register("ipv4", validate_ipv4); + register("ipv6", validate_ipv6); + register("hostname", validate_hostname); + register("idn-hostname", validate_idn_hostname); + register("email", validate_email); + register("idn-email", validate_idn_email); + register("date", validate_date); + register("time", validate_time); + register("date-time", validate_date_time); + register("duration", validate_duration); + register("period", validate_period); + register("json-pointer", validate_json_pointer); + register("relative-json-pointer", validate_relative_json_pointer); + register("uuid", validate_uuid); + register("uri", validate_uri); + register("iri", validate_iri); + register("uri-reference", validate_uri_reference); + register("iri-reference", validate_iri_reference); + register("uri-template", validate_uri_template); + m +}); + +fn validate_regex(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + ecma::convert(s).map(|_| ()) +} + +fn validate_ipv4(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + s.parse::()?; + Ok(()) +} + +fn validate_ipv6(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + s.parse::()?; + Ok(()) +} + +fn validate_date(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_date(s) +} + +fn matches_char(s: &str, index: usize, ch: char) -> bool { + s.is_char_boundary(index) && s[index..].starts_with(ch) +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 +fn check_date(s: &str) -> Result<(), Box> { + // yyyy-mm-dd + if s.len() != 10 { + Err("must be 10 characters long")?; + } + if !matches_char(s, 4, '-') || !matches_char(s, 7, '-') { + Err("missing hyphen in correct place")?; + } + + let mut ymd = s.splitn(3, '-').filter_map(|t| t.parse::().ok()); + let (Some(y), Some(m), Some(d)) = (ymd.next(), ymd.next(), ymd.next()) else { + Err("non-positive year/month/day")? + }; + + if !matches!(m, 1..=12) { + Err(format!("{m} months in year"))?; + } + if !matches!(d, 1..=31) { + Err(format!("{d} days in month"))?; + } + + match m { + 2 => { + let mut feb_days = 28; + if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { + feb_days += 1; // leap year + }; + if d > feb_days { + Err(format!("february has {feb_days} days only"))?; + } + } + 4 | 6 | 9 | 11 => { + if d > 30 { + Err("month has 30 days only")?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_time(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_time(s) +} + +fn check_time(mut str: &str) -> Result<(), Box> { + // min: hh:mm:ssZ + if str.len() < 9 { + Err("less than 9 characters long")? + } + if !matches_char(str, 2, ':') || !matches_char(str, 5, ':') { + Err("missing colon in correct place")? + } + + // parse hh:mm:ss + if !str.is_char_boundary(8) { + Err("contains non-ascii char")? + } + let mut hms = (str[..8]) + .splitn(3, ':') + .filter_map(|t| t.parse::().ok()); + let (Some(mut h), Some(mut m), Some(s)) = (hms.next(), hms.next(), hms.next()) else { + Err("non-positive hour/min/sec")? + }; + if h > 23 || m > 59 || s > 60 { + Err("hour/min/sec out of range")? + } + str = &str[8..]; + + // parse sec-frac if present + if let Some(rem) = str.strip_prefix('.') { + let n_digits = rem.chars().take_while(char::is_ascii_digit).count(); + if n_digits == 0 { + Err("no digits in second fraction")?; + } + str = &rem[n_digits..]; + } + + if str != "z" && str != "Z" { + // parse time-numoffset + if str.len() != 6 { + Err("offset must be 6 characters long")?; + } + let sign: isize = match str.chars().next() { + Some('+') => -1, + Some('-') => 1, + _ => return Err("offset must begin with plus/minus")?, + }; + str = &str[1..]; + if !matches_char(str, 2, ':') { + Err("missing colon in offset at correct place")? + } + + let mut zhm = str.splitn(2, ':').filter_map(|t| t.parse::().ok()); + let (Some(zh), Some(zm)) = (zhm.next(), zhm.next()) else { + Err("non-positive hour/min in offset")? + }; + if zh > 23 || zm > 59 { + Err("hour/min in offset out of range")? + } + + // apply timezone + let mut hm = (h * 60 + m) as isize + sign * (zh * 60 + zm) as isize; + if hm < 0 { + hm += 24 * 60; + debug_assert!(hm >= 0); + } + let hm = hm as usize; + (h, m) = (hm / 60, hm % 60); + } + + // check leap second + if !(s < 60 || (h == 23 && m == 59)) { + Err("invalid leap second")? + } + Ok(()) +} + +fn validate_date_time(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_date_time(s) +} + +fn check_date_time(s: &str) -> Result<(), Box> { + // min: yyyy-mm-ddThh:mm:ssZ + if s.len() < 20 { + Err("less than 20 characters long")?; + } + if !s.is_char_boundary(10) || !s[10..].starts_with(['t', 'T']) { + Err("11th character must be t or T")?; + } + if let Err(e) = check_date(&s[..10]) { + Err(format!("invalid date element: {e}"))?; + } + if let Err(e) = check_time(&s[11..]) { + Err(format!("invalid time element: {e}"))?; + } + Ok(()) +} + +fn validate_duration(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_duration(s) +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#appendix-A +fn check_duration(s: &str) -> Result<(), Box> { + // must start with 'P' + let Some(s) = s.strip_prefix('P') else { + Err("must start with P")? + }; + if s.is_empty() { + Err("nothing after P")? + } + + // dur-week + if let Some(s) = s.strip_suffix('W') { + if s.is_empty() { + Err("no number in week")? + } + if !s.chars().all(|c| c.is_ascii_digit()) { + Err("invalid week")? + } + return Ok(()); + } + + static UNITS: [&str; 2] = ["YMD", "HMS"]; + for (i, s) in s.split('T').enumerate() { + let mut s = s; + if i != 0 && s.is_empty() { + Err("no time elements")? + } + let Some(mut units) = UNITS.get(i).cloned() else { + Err("more than one T")? + }; + while !s.is_empty() { + let digit_count = s.chars().take_while(char::is_ascii_digit).count(); + if digit_count == 0 { + Err("missing number")? + } + s = &s[digit_count..]; + let Some(unit) = s.chars().next() else { + Err("missing unit")? + }; + let Some(j) = units.find(unit) else { + if UNITS[i].contains(unit) { + Err(format!("unit {unit} out of order"))? + } + Err(format!("invalid unit {unit}"))? + }; + units = &units[j + 1..]; + s = &s[1..]; + } + } + + Ok(()) +} + +// see https://datatracker.ietf.org/doc/html/rfc3339#appendix-A +fn validate_period(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + + let Some(slash) = s.find('/') else { + Err("missing slash")? + }; + + let (start, end) = (&s[..slash], &s[slash + 1..]); + if start.starts_with('P') { + if let Err(e) = check_duration(start) { + Err(format!("invalid start duration: {e}"))? + } + if let Err(e) = check_date_time(end) { + Err(format!("invalid end date-time: {e}"))? + } + } else { + if let Err(e) = check_date_time(start) { + Err(format!("invalid start date-time: {e}"))? + } + if end.starts_with('P') { + if let Err(e) = check_duration(end) { + Err(format!("invalid end duration: {e}"))?; + } + } else if let Err(e) = check_date_time(end) { + Err(format!("invalid end date-time: {e}"))?; + } + } + Ok(()) +} + +fn validate_hostname(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_hostname(s) +} + +// see https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names +fn check_hostname(mut s: &str) -> Result<(), Box> { + // entire hostname (including the delimiting dots but not a trailing dot) has a maximum of 253 ASCII characters + s = s.strip_suffix('.').unwrap_or(s); + if s.len() > 253 { + Err("more than 253 characters long")? + } + + // Hostnames are composed of series of labels concatenated with dots, as are all domain names + for label in s.split('.') { + // Each label must be from 1 to 63 characters long + if !matches!(label.len(), 1..=63) { + Err("label must be 1 to 63 characters long")?; + } + + // labels must not start or end with a hyphen + if label.starts_with('-') { + Err("label starts with hyphen")?; + } + + if label.ends_with('-') { + Err("label ends with hyphen")?; + } + + // labels may contain only the ASCII letters 'a' through 'z' (in a case-insensitive manner), + // the digits '0' through '9', and the hyphen ('-') + if let Some(ch) = label + .chars() + .find(|c| !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-')) + { + Err(format!("invalid character {ch:?}"))?; + } + } + + Ok(()) +} + +fn validate_idn_hostname(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_idn_hostname(s) +} + +fn check_idn_hostname(s: &str) -> Result<(), Box> { + let s = idna::domain_to_ascii_strict(s)?; + let unicode = idna::domain_to_unicode(&s).0; + + // see https://www.rfc-editor.org/rfc/rfc5892#section-2.6 + { + static DISALLOWED: [char; 10] = [ + '\u{0640}', // ARABIC TATWEEL + '\u{07FA}', // NKO LAJANYALAN + '\u{302E}', // HANGUL SINGLE DOT TONE MARK + '\u{302F}', // HANGUL DOUBLE DOT TONE MARK + '\u{3031}', // VERTICAL KANA REPEAT MARK + '\u{3032}', // VERTICAL KANA REPEAT WITH VOICED SOUND MARK + '\u{3033}', // VERTICAL KANA REPEAT MARK UPPER HALF + '\u{3034}', // VERTICAL KANA REPEAT WITH VOICED SOUND MARK UPPER HA + '\u{3035}', // VERTICAL KANA REPEAT MARK LOWER HALF + '\u{303B}', // VERTICAL IDEOGRAPHIC ITERATION MARK + ]; + if unicode.contains(DISALLOWED) { + Err("contains disallowed character")?; + } + } + + // unicode string must not contain "--" in 3rd and 4th position + // and must not start and end with a '-' + // see https://www.rfc-editor.org/rfc/rfc5891#section-4.2.3.1 + { + let count: usize = unicode + .chars() + .skip(2) + .take(2) + .map(|c| if c == '-' { 1 } else { 0 }) + .sum(); + if count == 2 { + Err("unicode string must not contain '--' in 3rd and 4th position")?; + } + } + + // MIDDLE DOT is allowed between 'l' characters only + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.3 + { + let middle_dot = '\u{00b7}'; + let mut s = unicode.as_str(); + while let Some(i) = s.find(middle_dot) { + let prefix = &s[..i]; + let suffix = &s[i + middle_dot.len_utf8()..]; + if !prefix.ends_with('l') || !suffix.ends_with('l') { + Err("MIDDLE DOT is allowed between 'l' characters only")?; + } + s = suffix; + } + } + + // Greek KERAIA must be followed by Greek character + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.4 + { + let keralia = '\u{0375}'; + let greek = '\u{0370}'..='\u{03FF}'; + let mut s = unicode.as_str(); + while let Some(i) = s.find(keralia) { + let suffix = &s[i + keralia.len_utf8()..]; + if !suffix.starts_with(|c| greek.contains(&c)) { + Err("Greek KERAIA must be followed by Greek character")?; + } + s = suffix; + } + } + + // Hebrew GERESH must be preceded by Hebrew character + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.5 + // + // Hebrew GERSHAYIM must be preceded by Hebrew character + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.6 + { + let geresh = '\u{05F3}'; + let gereshayim = '\u{05F4}'; + let hebrew = '\u{0590}'..='\u{05FF}'; + for ch in [geresh, gereshayim] { + let mut s = unicode.as_str(); + while let Some(i) = s.find(ch) { + let prefix = &s[..i]; + let suffix = &s[i + ch.len_utf8()..]; + if !prefix.ends_with(|c| hebrew.contains(&c)) { + if i == 0 { + Err("Hebrew GERESH must be preceded by Hebrew character")?; + } else { + Err("Hebrew GERESHYIM must be preceded by Hebrew character")?; + } + } + s = suffix; + } + } + } + + // KATAKANA MIDDLE DOT must be with Hiragana, Katakana, or Han + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.7 + { + let katakana_middle_dot = '\u{30FB}'; + let hiragana = '\u{3040}'..='\u{309F}'; + let katakana = '\u{30A0}'..='\u{30FF}'; + let han = '\u{4E00}'..='\u{9FFF}'; // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block): is this range correct?? + if unicode.contains(katakana_middle_dot) { + if unicode.contains(|c| hiragana.contains(&c)) + || unicode.contains(|c| c != katakana_middle_dot && katakana.contains(&c)) + || unicode.contains(|c| han.contains(&c)) + { + // ok + } else { + Err("KATAKANA MIDDLE DOT must be with Hiragana, Katakana, or Han")?; + } + } + } + + // ARABIC-INDIC DIGITS and Extended Arabic-Indic Digits cannot be mixed + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.8 + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.9 + { + let arabic_indic_digits = '\u{0660}'..='\u{0669}'; + let extended_arabic_indic_digits = '\u{06F0}'..='\u{06F9}'; + if unicode.contains(|c| arabic_indic_digits.contains(&c)) + && unicode.contains(|c| extended_arabic_indic_digits.contains(&c)) + { + Err("ARABIC-INDIC DIGITS and Extended Arabic-Indic Digits cannot be mixed")?; + } + } + + // ZERO WIDTH JOINER must be preceded by Virama + // see https://www.rfc-editor.org/rfc/rfc5892#appendix-A.2 + { + let zero_width_jointer = '\u{200D}'; + static VIRAMA: [char; 61] = [ + '\u{094D}', + '\u{09CD}', + '\u{0A4D}', + '\u{0ACD}', + '\u{0B4D}', + '\u{0BCD}', + '\u{0C4D}', + '\u{0CCD}', + '\u{0D3B}', + '\u{0D3C}', + '\u{0D4D}', + '\u{0DCA}', + '\u{0E3A}', + '\u{0EBA}', + '\u{0F84}', + '\u{1039}', + '\u{103A}', + '\u{1714}', + '\u{1734}', + '\u{17D2}', + '\u{1A60}', + '\u{1B44}', + '\u{1BAA}', + '\u{1BAB}', + '\u{1BF2}', + '\u{1BF3}', + '\u{2D7F}', + '\u{A806}', + '\u{A82C}', + '\u{A8C4}', + '\u{A953}', + '\u{A9C0}', + '\u{AAF6}', + '\u{ABED}', + '\u{10A3F}', + '\u{11046}', + '\u{1107F}', + '\u{110B9}', + '\u{11133}', + '\u{11134}', + '\u{111C0}', + '\u{11235}', + '\u{112EA}', + '\u{1134D}', + '\u{11442}', + '\u{114C2}', + '\u{115BF}', + '\u{1163F}', + '\u{116B6}', + '\u{1172B}', + '\u{11839}', + '\u{1193D}', + '\u{1193E}', + '\u{119E0}', + '\u{11A34}', + '\u{11A47}', + '\u{11A99}', + '\u{11C3F}', + '\u{11D44}', + '\u{11D45}', + '\u{11D97}', + ]; // https://www.compart.com/en/unicode/combining/9 + let mut s = unicode.as_str(); + while let Some(i) = s.find(zero_width_jointer) { + let prefix = &s[..i]; + let suffix = &s[i + zero_width_jointer.len_utf8()..]; + if !prefix.ends_with(VIRAMA) { + Err("ZERO WIDTH JOINER must be preceded by Virama")?; + } + s = suffix; + } + } + + check_hostname(&s) +} + +fn validate_email(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_email(s) +} + +// see https://en.wikipedia.org/wiki/Email_address +fn check_email(s: &str) -> Result<(), Box> { + // entire email address to be no more than 254 characters long + if s.len() > 254 { + Err("more than 254 characters long")? + } + + // email address is generally recognized as having two parts joined with an at-sign + let Some(at) = s.rfind('@') else { + Err("missing @")? + }; + let (local, domain) = (&s[..at], &s[at + 1..]); + + // local part may be up to 64 characters long + if local.len() > 64 { + Err("local part more than 64 characters long")? + } + + if local.len() > 1 && local.starts_with('"') && local.ends_with('"') { + // quoted + let local = &local[1..local.len() - 1]; + if local.contains(['\\', '"']) { + Err("backslash and quote not allowed within quoted local part")? + } + } else { + // unquoted + + if local.starts_with('.') { + Err("starts with dot")? + } + if local.ends_with('.') { + Err("ends with dot")? + } + + // consecutive dots not allowed + if local.contains("..") { + Err("consecutive dots")? + } + + // check allowd chars + if let Some(ch) = local + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || ".!#$%&'*+-/=?^_`{|}~".contains(*c))) + { + Err(format!("invalid character {ch:?}"))? + } + } + + // domain if enclosed in brackets, must match an IP address + if domain.starts_with('[') && domain.ends_with(']') { + let s = &domain[1..domain.len() - 1]; + if let Some(s) = s.strip_prefix("IPv6:") { + if let Err(e) = s.parse::() { + Err(format!("invalid ipv6 address: {e}"))? + } + return Ok(()); + } + if let Err(e) = s.parse::() { + Err(format!("invalid ipv4 address: {e}"))? + } + return Ok(()); + } + + // domain must match the requirements for a hostname + if let Err(e) = check_hostname(domain) { + Err(format!("invalid domain: {e}"))? + } + + Ok(()) +} + +fn validate_idn_email(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + + let Some(at) = s.rfind('@') else { + Err("missing @")? + }; + let (local, domain) = (&s[..at], &s[at + 1..]); + + let local = idna::domain_to_ascii_strict(local)?; + let domain = idna::domain_to_ascii_strict(domain)?; + if let Err(e) = check_idn_hostname(&domain) { + Err(format!("invalid domain: {e}"))? + } + check_email(&format!("{local}@{domain}")) +} + +fn validate_json_pointer(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + check_json_pointer(s) +} + +// see https://www.rfc-editor.org/rfc/rfc6901#section-3 +fn check_json_pointer(s: &str) -> Result<(), Box> { + if s.is_empty() { + return Ok(()); + } + if !s.starts_with('/') { + Err("not starting with slash")?; + } + for token in s.split('/').skip(1) { + let mut chars = token.chars(); + while let Some(ch) = chars.next() { + if ch == '~' { + if !matches!(chars.next(), Some('0' | '1')) { + Err("~ must be followed by 0 or 1")?; + } + } else if !matches!(ch, '\x00'..='\x2E' | '\x30'..='\x7D' | '\x7F'..='\u{10FFFF}') { + Err("contains disallowed character")?; + } + } + } + Ok(()) +} + +// see https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01#section-3 +fn validate_relative_json_pointer(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + + // start with non-negative-integer + let num_digits = s.chars().take_while(char::is_ascii_digit).count(); + if num_digits == 0 { + Err("must start with non-negative integer")?; + } + if num_digits > 1 && s.starts_with('0') { + Err("starts with zero")?; + } + let s = &s[num_digits..]; + + // followed by either json-pointer or '#' + if s == "#" { + return Ok(()); + } + if let Err(e) = check_json_pointer(s) { + Err(format!("invalid json-pointer element: {e}"))?; + } + Ok(()) +} + +// see https://datatracker.ietf.org/doc/html/rfc4122#page-4 +fn validate_uuid(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + + static HEX_GROUPS: [usize; 5] = [8, 4, 4, 4, 12]; + let mut i = 0; + for group in s.split('-') { + if i >= HEX_GROUPS.len() { + Err("more than 5 elements")?; + } + if group.len() != HEX_GROUPS[i] { + Err(format!( + "element {} must be {} characters long", + i + 1, + HEX_GROUPS[i] + ))?; + } + if let Some(ch) = group.chars().find(|c| !c.is_ascii_hexdigit()) { + Err(format!("non-hex character {ch:?}"))?; + } + i += 1; + } + if i != HEX_GROUPS.len() { + Err("must have 5 elements")?; + } + Ok(()) +} + +fn validate_uri(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + if fluent_uri::UriRef::parse(s.as_str())?.scheme().is_none() { + Err("relative url")?; + }; + Ok(()) +} + +fn validate_iri(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + match Url::parse(s) { + Ok(_) => Ok(()), + Err(url::ParseError::RelativeUrlWithoutBase) => Err("relative url")?, + Err(e) => Err(e)?, + } +} + +static TEMP_URL: Lazy = Lazy::new(|| Url::parse("http://temp.com").unwrap()); + +fn parse_uri_reference(s: &str) -> Result> { + if s.contains('\\') { + Err("contains \\\\")?; + } + Ok(TEMP_URL.join(s)?) +} + +fn validate_uri_reference(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + fluent_uri::UriRef::parse(s.as_str())?; + Ok(()) +} + +fn validate_iri_reference(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + parse_uri_reference(s)?; + Ok(()) +} + +fn validate_uri_template(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); + }; + + let url = parse_uri_reference(s)?; + + let path = url.path(); + // path we got has curly bases percent encoded + let path = percent_decode_str(path).decode_utf8()?; + + // ensure curly brackets are not nested and balanced + for part in path.as_ref().split('/') { + let mut want = true; + for got in part + .chars() + .filter(|c| matches!(c, '{' | '}')) + .map(|c| c == '{') + { + if got != want { + Err("nested curly braces")?; + } + want = !want; + } + if !want { + Err("no matching closing brace")? + } + } + Ok(()) +} diff --git a/validator/src/lib.rs b/validator/src/lib.rs new file mode 100644 index 0000000..a5d68b6 --- /dev/null +++ b/validator/src/lib.rs @@ -0,0 +1,716 @@ +/*! This crate supports JsonSchema validation for drafts `2020-12`, `2019-09`, `7`, `6` and `4`. + +```rust,no_run +# use std::fs::File; +# use std::error::Error; +# use boon::*; +# use serde_json::Value; +# fn main() -> Result<(), Box>{ +let mut schemas = Schemas::new(); // container for compiled schemas +let mut compiler = Compiler::new(); +let sch_index = compiler.compile("schema.json", &mut schemas)?; +let instance: Value = serde_json::from_reader(File::open("instance.json")?)?; +let valid = schemas.validate(&instance, sch_index).is_ok(); +# Ok(()) +# } +``` + +If schema file has no `$schema`, it assumes latest draft. +You can override this: +```rust,no_run +# use boon::*; +# let mut compiler = Compiler::new(); +compiler.set_default_draft(Draft::V7); +``` + +The use of this option is HIGHLY encouraged to ensure continued +correct operation of your schema. The current default value will +not stay the same over time. + +# Examples + +- [example_from_strings]: loading schemas from Strings +- [example_from_https]: loading schemas from `http(s)` +- [example_custom_format]: registering custom format +- [example_custom_content_encoding]: registering custom contentEncoding +- [example_custom_content_media_type]: registering custom contentMediaType + +# Compile Errors + +```no_compile +println!("{compile_error}"); +println!("{compile_error:#}"); // prints cause if any +``` + +Using alterate form in display will print cause if any. +This will be useful in cases like [`CompileError::LoadUrlError`], +as it would be useful to know whether the url does not exist or +the resource at url is not a valid json document. + +# Validation Errors + +[`ValidationError`] may have multiple `causes` resulting +in tree of errors. + +`println!("{validation_error}")` prints: +```no_compile +jsonschema validation failed with file:///tmp/customer.json# + at '': missing properties 'age' + at '/billing_address': missing properties 'street_address', 'city', 'state' +``` + + +The alternate form `println!("{validation_error:#}")` prints: +```no_compile +jsonschema validation failed with file:///tmp/customer.json# + [I#] [S#/required] missing properties 'age' + [I#/billing_address] [S#/properties/billing_address/$ref] validation failed with file:///tmp/address.json# + [I#/billing_address] [S#/required] missing properties 'street_address', 'city', 'state' +``` +here `I` refers to the instance document and `S` refers to last schema document. + +for example: +- after line 1: `S` refers to `file:///tmp/customer.json` +- after line 3: `S` refers to `file://tmp/address.json` + + +# Output Formats + +[`ValidationError`] can be converted into following output formats: +- [flag] `validation_error.flag_output()` +- [basic] `validation_error.basic_output()` +- [detailed] `validation_error.detailed_output()` + +The output object implements `serde::Serialize`. + +It also implement `Display` to print json: + +```no_compile +println!("{output}"); // prints unformatted json +println!("{output:#}"); // prints indented json +``` + +[example_from_strings]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L22 +[example_from_https]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L62 +[example_from_yaml_files]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L86 +[example_custom_format]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L119 +[example_custom_content_encoding]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L153 +[example_custom_content_media_type]: https://github.com/santhosh-tekuri/boon/blob/d466730e5e5c7c663bd6739e74e39d1e2f7baae4/tests/examples.rs#L198 +[flag]: https://json-schema.org/draft/2020-12/json-schema-core.html#name-flag +[basic]: https://json-schema.org/draft/2020-12/json-schema-core.html#name-basic +[detailed]: https://json-schema.org/draft/2020-12/json-schema-core.html#name-detailed + +*/ + +mod compiler; +mod content; +mod draft; +mod ecma; +mod formats; +mod loader; +mod output; +mod root; +mod roots; +mod util; +mod validator; + +#[cfg(not(target_arch = "wasm32"))] +pub use loader::FileLoader; +pub use { + compiler::{CompileError, Compiler, Draft}, + content::{Decoder, MediaType}, + formats::Format, + loader::{SchemeUrlLoader, UrlLoader}, + output::{ + AbsoluteKeywordLocation, FlagOutput, KeywordPath, OutputError, OutputUnit, SchemaToken, + }, + validator::{InstanceLocation, InstanceToken}, +}; + +use std::{borrow::Cow, collections::HashMap, error::Error, fmt::Display}; + +use ahash::AHashMap; +use regex::Regex; +use serde_json::{Number, Value}; +use util::*; + +/// Identifier to compiled schema. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SchemaIndex(usize); + +/// Collection of compiled schemas. +#[derive(Default)] +pub struct Schemas { + list: Vec, + map: HashMap, // loc => schema-index +} + +impl Schemas { + pub fn new() -> Self { + Self::default() + } + + fn insert(&mut self, locs: Vec, compiled: Vec) { + for (up, sch) in locs.into_iter().zip(compiled.into_iter()) { + let i = self.list.len(); + self.list.push(sch); + self.map.insert(up, i); + } + } + + fn get(&self, idx: SchemaIndex) -> &Schema { + &self.list[idx.0] // todo: return bug + } + + fn get_by_loc(&self, up: &UrlPtr) -> Option<&Schema> { + self.map.get(up).and_then(|&i| self.list.get(i)) + } + + /// Returns true if `sch_index` is generated for this instance. + pub fn contains(&self, sch_index: SchemaIndex) -> bool { + self.list.get(sch_index.0).is_some() + } + + pub fn size(&self) -> usize { + self.list.len() + } + + /** + Validates `v` with schema identified by `sch_index` + + # Panics + + Panics if `sch_index` is not generated for this instance. + [`Schemas::contains`] can be used too ensure that it does not panic. + */ + pub fn validate<'s, 'v>( + &'s self, + v: &'v Value, + sch_index: SchemaIndex, + ) -> Result<(), ValidationError<'s, 'v>> { + let Some(sch) = self.list.get(sch_index.0) else { + panic!("Schemas::validate: schema index out of bounds"); + }; + validator::validate(v, sch, self) + } +} + +#[derive(Default)] +struct Schema { + draft_version: usize, + idx: SchemaIndex, + loc: String, + resource: SchemaIndex, + dynamic_anchors: HashMap, + all_props_evaluated: bool, + all_items_evaluated: bool, + num_items_evaluated: usize, + + // type agnostic -- + boolean: Option, // boolean schema + ref_: Option, + recursive_ref: Option, + recursive_anchor: bool, + dynamic_ref: Option, + dynamic_anchor: Option, + types: Types, + enum_: Option, + constant: Option, + not: Option, + all_of: Vec, + any_of: Vec, + one_of: Vec, + if_: Option, + then: Option, + else_: Option, + format: Option, + + // object -- + min_properties: Option, + max_properties: Option, + required: Vec, + properties: AHashMap, + pattern_properties: Vec<(Regex, SchemaIndex)>, + property_names: Option, + additional_properties: Option, + dependent_required: Vec<(String, Vec)>, + dependent_schemas: Vec<(String, SchemaIndex)>, + dependencies: Vec<(String, Dependency)>, + unevaluated_properties: Option, + + // array -- + min_items: Option, + max_items: Option, + unique_items: bool, + min_contains: Option, + max_contains: Option, + contains: Option, + items: Option, + additional_items: Option, + prefix_items: Vec, + items2020: Option, + unevaluated_items: Option, + + // string -- + min_length: Option, + max_length: Option, + pattern: Option, + content_encoding: Option, + content_media_type: Option, + content_schema: Option, + + // number -- + minimum: Option, + maximum: Option, + exclusive_minimum: Option, + exclusive_maximum: Option, + multiple_of: Option, +} + +#[derive(Debug)] +struct Enum { + /// types that occur in enum + types: Types, + /// values in enum + values: Vec, +} + +#[derive(Debug)] +enum Items { + SchemaRef(SchemaIndex), + SchemaRefs(Vec), +} + +#[derive(Debug)] +enum Additional { + Bool(bool), + SchemaRef(SchemaIndex), +} + +#[derive(Debug)] +enum Dependency { + Props(Vec), + SchemaRef(SchemaIndex), +} + +struct DynamicRef { + sch: SchemaIndex, + anchor: Option, +} + +impl Schema { + fn new(loc: String) -> Self { + Self { + loc, + ..Default::default() + } + } +} + +/// JSON data types for JSONSchema +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Type { + Null = 1, + Boolean = 2, + Number = 4, + Integer = 8, + String = 16, + Array = 32, + Object = 64, +} + +impl Type { + fn of(v: &Value) -> Self { + match v { + Value::Null => Type::Null, + Value::Bool(_) => Type::Boolean, + Value::Number(_) => Type::Number, + Value::String(_) => Type::String, + Value::Array(_) => Type::Array, + Value::Object(_) => Type::Object, + } + } + + fn from_str(value: &str) -> Option { + match value { + "null" => Some(Self::Null), + "boolean" => Some(Self::Boolean), + "number" => Some(Self::Number), + "integer" => Some(Self::Integer), + "string" => Some(Self::String), + "array" => Some(Self::Array), + "object" => Some(Self::Object), + _ => None, + } + } + + fn primitive(v: &Value) -> bool { + !matches!(Self::of(v), Self::Array | Self::Object) + } +} + +impl Display for Type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Type::Null => write!(f, "null"), + Type::Boolean => write!(f, "boolean"), + Type::Number => write!(f, "number"), + Type::Integer => write!(f, "integer"), + Type::String => write!(f, "string"), + Type::Array => write!(f, "array"), + Type::Object => write!(f, "object"), + } + } +} + +/// Set of [`Type`]s +#[derive(Debug, Default, Clone, Copy)] +pub struct Types(u8); + +impl Types { + fn is_empty(self) -> bool { + self.0 == 0 + } + + fn add(&mut self, t: Type) { + self.0 |= t as u8; + } + + /// Returns `true` if this set contains given type. + pub fn contains(&self, t: Type) -> bool { + self.0 & t as u8 != 0 + } + + /// Returns an iterator over types. + pub fn iter(&self) -> impl Iterator + '_ { + static TYPES: [Type; 7] = [ + Type::Null, + Type::Boolean, + Type::Number, + Type::Integer, + Type::String, + Type::Array, + Type::Object, + ]; + TYPES.iter().cloned().filter(|t| self.contains(*t)) + } +} + +impl FromIterator for Types { + fn from_iter>(iter: T) -> Self { + let mut types = Types::default(); + for t in iter { + types.add(t); + } + types + } +} + +/// Error type for validation failures. +#[derive(Debug)] +pub struct ValidationError<'s, 'v> { + /// The absolute, dereferenced schema location. + pub schema_url: &'s str, + /// The location of the JSON value within the instance being validated + pub instance_location: InstanceLocation<'v>, + /// kind of error + pub kind: ErrorKind<'s, 'v>, + /// Holds nested errors + pub causes: Vec>, +} + +impl Error for ValidationError<'_, '_> {} + +/// A list specifying general categories of validation errors. +#[derive(Debug)] +pub enum ErrorKind<'s, 'v> { + Group, + Schema { + url: &'s str, + }, + ContentSchema, + PropertyName { + prop: String, + }, + Reference { + kw: &'static str, + url: &'s str, + }, + RefCycle { + url: &'s str, + kw_loc1: String, + kw_loc2: String, + }, + FalseSchema, + Type { + got: Type, + want: Types, + }, + Enum { + want: &'s Vec, + }, + Const { + want: &'s Value, + }, + Format { + got: Cow<'v, Value>, + want: &'static str, + err: Box, + }, + MinProperties { + got: usize, + want: usize, + }, + MaxProperties { + got: usize, + want: usize, + }, + AdditionalProperties { + got: Vec>, + }, + Required { + want: Vec<&'s str>, + }, + Dependency { + /// dependency of prop that failed. + prop: &'s str, + /// missing props. + missing: Vec<&'s str>, + }, + DependentRequired { + /// dependency of prop that failed. + prop: &'s str, + /// missing props. + missing: Vec<&'s str>, + }, + MinItems { + got: usize, + want: usize, + }, + MaxItems { + got: usize, + want: usize, + }, + Contains, + MinContains { + got: Vec, + want: usize, + }, + MaxContains { + got: Vec, + want: usize, + }, + UniqueItems { + got: [usize; 2], + }, + AdditionalItems { + got: usize, + }, + MinLength { + got: usize, + want: usize, + }, + MaxLength { + got: usize, + want: usize, + }, + Pattern { + got: Cow<'v, str>, + want: &'s str, + }, + ContentEncoding { + want: &'static str, + err: Box, + }, + ContentMediaType { + got: Vec, + want: &'static str, + err: Box, + }, + Minimum { + got: Cow<'v, Number>, + want: &'s Number, + }, + Maximum { + got: Cow<'v, Number>, + want: &'s Number, + }, + ExclusiveMinimum { + got: Cow<'v, Number>, + want: &'s Number, + }, + ExclusiveMaximum { + got: Cow<'v, Number>, + want: &'s Number, + }, + MultipleOf { + got: Cow<'v, Number>, + want: &'s Number, + }, + Not, + /// none of the subschemas matched + AllOf, + /// none of the subschemas matched. + AnyOf, + /// - `None`: none of the schemas matched. + /// - Some(i, j): subschemas at i, j matched + OneOf(Option<(usize, usize)>), +} + +impl Display for ErrorKind<'_, '_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Group => write!(f, "validation failed"), + Self::Schema { url } => write!(f, "validation failed with {url}"), + Self::ContentSchema => write!(f, "contentSchema failed"), + Self::PropertyName { prop } => write!(f, "invalid property {}", quote(prop)), + Self::Reference { .. } => { + write!(f, "validation failed") + } + Self::RefCycle { + url, + kw_loc1, + kw_loc2, + } => write!( + f, + "both {} and {} resolve to {url} causing reference cycle", + quote(&kw_loc1.to_string()), + quote(&kw_loc2.to_string()) + ), + Self::FalseSchema => write!(f, "false schema"), + Self::Type { got, want } => { + // todo: why join not working for Type struct ?? + let want = join_iter(want.iter(), " or "); + write!(f, "want {want}, but got {got}",) + } + Self::Enum { want } => { + if want.iter().all(Type::primitive) { + if want.len() == 1 { + write!(f, "value must be ")?; + display(f, &want[0]) + } else { + let want = join_iter(want.iter().map(string), ", "); + write!(f, "value must be one of {want}") + } + } else { + write!(f, "enum failed") + } + } + Self::Const { want } => { + if Type::primitive(want) { + write!(f, "value must be ")?; + display(f, want) + } else { + write!(f, "const failed") + } + } + Self::Format { got, want, err } => { + display(f, got)?; + write!(f, " is not valid {want}: {err}") + } + Self::MinProperties { got, want } => write!( + f, + "minimum {want} properties required, but got {got} properties" + ), + Self::MaxProperties { got, want } => write!( + f, + "maximum {want} properties required, but got {got} properties" + ), + Self::AdditionalProperties { got } => { + write!( + f, + "additionalProperties {} not allowed", + join_iter(got.iter().map(quote), ", ") + ) + } + Self::Required { want } => write!( + f, + "missing properties {}", + join_iter(want.iter().map(quote), ", ") + ), + Self::Dependency { prop, missing } => { + write!( + f, + "properties {} required, if {} property exists", + join_iter(missing.iter().map(quote), ", "), + quote(prop) + ) + } + Self::DependentRequired { prop, missing } => write!( + f, + "properties {} required, if {} property exists", + join_iter(missing.iter().map(quote), ", "), + quote(prop) + ), + Self::MinItems { got, want } => { + write!(f, "minimum {want} items required, but got {got} items") + } + Self::MaxItems { got, want } => { + write!(f, "maximum {want} items required, but got {got} items") + } + Self::MinContains { got, want } => { + if got.is_empty() { + write!( + f, + "minimum {want} items required to match contains schema, but found none", + ) + } else { + write!( + f, + "minimum {want} items required to match contains schema, but found {} items at {}", + got.len(), + join_iter(got, ", ") + ) + } + } + Self::Contains => write!(f, "no items match contains schema"), + Self::MaxContains { got, want } => { + write!( + f, + "maximum {want} items required to match contains schema, but found {} items at {}", + got.len(), + join_iter(got, ", ") + ) + } + Self::UniqueItems { got: [i, j] } => write!(f, "items at {i} and {j} are equal"), + Self::AdditionalItems { got } => write!(f, "last {got} additionalItems not allowed"), + Self::MinLength { got, want } => write!(f, "length must be >={want}, but got {got}"), + Self::MaxLength { got, want } => write!(f, "length must be <={want}, but got {got}"), + Self::Pattern { got, want } => { + write!(f, "{} does not match pattern {}", quote(got), quote(want)) + } + Self::ContentEncoding { want, err } => { + write!(f, "value is not {} encoded: {err}", quote(want)) + } + Self::ContentMediaType { want, err, .. } => { + write!(f, "value is not of mediatype {}: {err}", quote(want)) + } + Self::Minimum { got, want } => write!(f, "must be >={want}, but got {got}"), + Self::Maximum { got, want } => write!(f, "must be <={want}, but got {got}"), + Self::ExclusiveMinimum { got, want } => write!(f, "must be > {want} but got {got}"), + Self::ExclusiveMaximum { got, want } => write!(f, "must be < {want} but got {got}"), + Self::MultipleOf { got, want } => write!(f, "{got} is not multipleOf {want}"), + Self::Not => write!(f, "not failed"), + Self::AllOf => write!(f, "allOf failed",), + Self::AnyOf => write!(f, "anyOf failed"), + Self::OneOf(None) => write!(f, "oneOf failed, none matched"), + Self::OneOf(Some((i, j))) => write!(f, "oneOf failed, subschemas {i}, {j} matched"), + } + } +} + +fn display(f: &mut std::fmt::Formatter, v: &Value) -> std::fmt::Result { + match v { + Value::String(s) => write!(f, "{}", quote(s)), + Value::Array(_) | Value::Object(_) => write!(f, "value"), + _ => write!(f, "{v}"), + } +} + +fn string(primitive: &Value) -> String { + if let Value::String(s) = primitive { + quote(s) + } else { + format!("{primitive}") + } +} diff --git a/validator/src/loader.rs b/validator/src/loader.rs new file mode 100644 index 0000000..8871337 --- /dev/null +++ b/validator/src/loader.rs @@ -0,0 +1,243 @@ +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + error::Error, +}; + +#[cfg(not(target_arch = "wasm32"))] +use std::fs::File; + +use appendlist::AppendList; +use once_cell::sync::Lazy; +use serde_json::Value; +use url::Url; + +use crate::{ + compiler::CompileError, + draft::{latest, Draft}, + util::split, + UrlPtr, +}; + +/// A trait for loading json from given `url` +pub trait UrlLoader { + /// Loads json from given absolute `url`. + fn load(&self, url: &str) -> Result>; +} + +// -- + +#[cfg(not(target_arch = "wasm32"))] +pub struct FileLoader; + +#[cfg(not(target_arch = "wasm32"))] +impl UrlLoader for FileLoader { + fn load(&self, url: &str) -> Result> { + let url = Url::parse(url)?; + let path = url.to_file_path().map_err(|_| "invalid file path")?; + let file = File::open(path)?; + Ok(serde_json::from_reader(file)?) + } +} + +// -- + +#[derive(Default)] +pub struct SchemeUrlLoader { + loaders: HashMap<&'static str, Box>, +} + +impl SchemeUrlLoader { + pub fn new() -> Self { + Self::default() + } + + /// Registers [`UrlLoader`] for given url `scheme` + pub fn register(&mut self, scheme: &'static str, url_loader: Box) { + self.loaders.insert(scheme, url_loader); + } +} + +impl UrlLoader for SchemeUrlLoader { + fn load(&self, url: &str) -> Result> { + let url = Url::parse(url)?; + let Some(loader) = self.loaders.get(url.scheme()) else { + return Err(CompileError::UnsupportedUrlScheme { + url: url.as_str().to_owned(), + } + .into()); + }; + loader.load(url.as_str()) + } +} + +// -- + +pub(crate) struct DefaultUrlLoader { + doc_map: RefCell>, + doc_list: AppendList, + loader: Box, +} + +impl DefaultUrlLoader { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] + pub fn new() -> Self { + let mut loader = SchemeUrlLoader::new(); + #[cfg(not(target_arch = "wasm32"))] + loader.register("file", Box::new(FileLoader)); + Self { + doc_map: Default::default(), + doc_list: AppendList::new(), + loader: Box::new(loader), + } + } + + pub fn get_doc(&self, url: &Url) -> Option<&Value> { + self.doc_map + .borrow() + .get(url) + .and_then(|i| self.doc_list.get(*i)) + } + + pub fn add_doc(&self, url: Url, json: Value) { + if self.get_doc(&url).is_some() { + return; + } + self.doc_list.push(json); + self.doc_map + .borrow_mut() + .insert(url, self.doc_list.len() - 1); + } + + pub fn use_loader(&mut self, loader: Box) { + self.loader = loader; + } + + pub(crate) fn load(&self, url: &Url) -> Result<&Value, CompileError> { + if let Some(doc) = self.get_doc(url) { + return Ok(doc); + } + + // check in STD_METAFILES + let doc = if let Some(content) = load_std_meta(url.as_str()) { + serde_json::from_str::(content).map_err(|e| CompileError::LoadUrlError { + url: url.to_string(), + src: e.into(), + })? + } else { + self.loader + .load(url.as_str()) + .map_err(|src| CompileError::LoadUrlError { + url: url.as_str().to_owned(), + src, + })? + }; + self.add_doc(url.clone(), doc); + self.get_doc(url) + .ok_or(CompileError::Bug("doc must exist".into())) + } + + pub(crate) fn get_draft( + &self, + up: &UrlPtr, + doc: &Value, + default_draft: &'static Draft, + mut cycle: HashSet, + ) -> Result<&'static Draft, CompileError> { + let Value::Object(obj) = &doc else { + return Ok(default_draft); + }; + let Some(Value::String(sch)) = obj.get("$schema") else { + return Ok(default_draft); + }; + if let Some(draft) = Draft::from_url(sch) { + return Ok(draft); + } + let (sch, _) = split(sch); + let sch = Url::parse(sch).map_err(|e| CompileError::InvalidMetaSchemaUrl { + url: up.to_string(), + src: e.into(), + })?; + if up.ptr.is_empty() && sch == up.url { + return Err(CompileError::UnsupportedDraft { url: sch.into() }); + } + if !cycle.insert(sch.clone()) { + return Err(CompileError::MetaSchemaCycle { url: sch.into() }); + } + + let doc = self.load(&sch)?; + let up = UrlPtr { + url: sch, + ptr: "".into(), + }; + self.get_draft(&up, doc, default_draft, cycle) + } + + pub(crate) fn get_meta_vocabs( + &self, + doc: &Value, + draft: &'static Draft, + ) -> Result>, CompileError> { + let Value::Object(obj) = &doc else { + return Ok(None); + }; + let Some(Value::String(sch)) = obj.get("$schema") else { + return Ok(None); + }; + if Draft::from_url(sch).is_some() { + return Ok(None); + } + let (sch, _) = split(sch); + let sch = Url::parse(sch).map_err(|e| CompileError::ParseUrlError { + url: sch.to_string(), + src: e.into(), + })?; + let doc = self.load(&sch)?; + draft.get_vocabs(&sch, doc) + } +} + +pub(crate) static STD_METAFILES: Lazy> = Lazy::new(|| { + let mut files = HashMap::new(); + macro_rules! add { + ($path:expr) => { + files.insert( + $path["metaschemas/".len()..].to_owned(), + include_str!($path), + ); + }; + } + add!("metaschemas/draft-04/schema"); + add!("metaschemas/draft-06/schema"); + add!("metaschemas/draft-07/schema"); + add!("metaschemas/draft/2019-09/schema"); + add!("metaschemas/draft/2019-09/meta/core"); + add!("metaschemas/draft/2019-09/meta/applicator"); + add!("metaschemas/draft/2019-09/meta/validation"); + add!("metaschemas/draft/2019-09/meta/meta-data"); + add!("metaschemas/draft/2019-09/meta/format"); + add!("metaschemas/draft/2019-09/meta/content"); + add!("metaschemas/draft/2020-12/schema"); + add!("metaschemas/draft/2020-12/meta/core"); + add!("metaschemas/draft/2020-12/meta/applicator"); + add!("metaschemas/draft/2020-12/meta/unevaluated"); + add!("metaschemas/draft/2020-12/meta/validation"); + add!("metaschemas/draft/2020-12/meta/meta-data"); + add!("metaschemas/draft/2020-12/meta/content"); + add!("metaschemas/draft/2020-12/meta/format-annotation"); + add!("metaschemas/draft/2020-12/meta/format-assertion"); + files +}); + +fn load_std_meta(url: &str) -> Option<&'static str> { + let meta = url + .strip_prefix("http://json-schema.org/") + .or_else(|| url.strip_prefix("https://json-schema.org/")); + if let Some(meta) = meta { + if meta == "schema" { + return load_std_meta(latest().url); + } + return STD_METAFILES.get(meta).cloned(); + } + None +} diff --git a/validator/src/metaschemas/draft-04/schema b/validator/src/metaschemas/draft-04/schema new file mode 100644 index 0000000..b2a7ff0 --- /dev/null +++ b/validator/src/metaschemas/draft-04/schema @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "positiveInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveIntegerDefault0": { + "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] + }, + "simpleTypes": { + "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uriref" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { "$ref": "#/definitions/positiveInteger" }, + "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/positiveInteger" }, + "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { "$ref": "#/definitions/positiveInteger" }, + "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" }, + "format": { "type": "string" }, + "$ref": { "type": "string" } + }, + "dependencies": { + "exclusiveMaximum": [ "maximum" ], + "exclusiveMinimum": [ "minimum" ] + }, + "default": {} +} diff --git a/validator/src/metaschemas/draft-06/schema b/validator/src/metaschemas/draft-06/schema new file mode 100644 index 0000000..45dce72 --- /dev/null +++ b/validator/src/metaschemas/draft-06/schema @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://json-schema.org/draft-06/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "regexProperties": true, + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": {}, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": {} +} diff --git a/validator/src/metaschemas/draft-07/schema b/validator/src/metaschemas/draft-07/schema new file mode 100644 index 0000000..326759a --- /dev/null +++ b/validator/src/metaschemas/draft-07/schema @@ -0,0 +1,172 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://json-schema.org/draft-07/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": true + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "if": { "$ref": "#" }, + "then": { "$ref": "#" }, + "else": { "$ref": "#" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": true +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/applicator b/validator/src/metaschemas/draft/2019-09/meta/applicator new file mode 100644 index 0000000..857d2d4 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/applicator @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/applicator": true + }, + "$recursiveAnchor": true, + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "additionalItems": { "$recursiveRef": "#" }, + "unevaluatedItems": { "$recursiveRef": "#" }, + "items": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "#/$defs/schemaArray" } + ] + }, + "contains": { "$recursiveRef": "#" }, + "additionalProperties": { "$recursiveRef": "#" }, + "unevaluatedProperties": { "$recursiveRef": "#" }, + "properties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { + "$recursiveRef": "#" + } + }, + "propertyNames": { "$recursiveRef": "#" }, + "if": { "$recursiveRef": "#" }, + "then": { "$recursiveRef": "#" }, + "else": { "$recursiveRef": "#" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$recursiveRef": "#" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$recursiveRef": "#" } + } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/content b/validator/src/metaschemas/draft/2019-09/meta/content new file mode 100644 index 0000000..fa5d20b --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/content @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + "title": "Content vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "contentSchema": { "$recursiveRef": "#" } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/core b/validator/src/metaschemas/draft/2019-09/meta/core new file mode 100644 index 0000000..bf57319 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/core @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true + }, + "$recursiveAnchor": true, + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$anchor": { + "type": "string", + "pattern": "^[A-Za-z][-A-Za-z0-9.:_]*$" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveRef": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveAnchor": { + "type": "boolean", + "default": false + }, + "$vocabulary": { + "type": "object", + "propertyNames": { + "type": "string", + "format": "uri" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/format b/validator/src/metaschemas/draft/2019-09/meta/format new file mode 100644 index 0000000..fe553c2 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/format @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/format", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/format": true + }, + "$recursiveAnchor": true, + "title": "Format vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/meta-data b/validator/src/metaschemas/draft/2019-09/meta/meta-data new file mode 100644 index 0000000..5c95715 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/meta-data @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/meta-data": true + }, + "$recursiveAnchor": true, + "title": "Meta-data vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/meta/validation b/validator/src/metaschemas/draft/2019-09/meta/validation new file mode 100644 index 0000000..f3525e0 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/meta/validation @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/validation": true + }, + "$recursiveAnchor": true, + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/validator/src/metaschemas/draft/2019-09/schema b/validator/src/metaschemas/draft/2019-09/schema new file mode 100644 index 0000000..f433389 --- /dev/null +++ b/validator/src/metaschemas/draft/2019-09/schema @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true, + "https://json-schema.org/draft/2019-09/vocab/applicator": true, + "https://json-schema.org/draft/2019-09/vocab/validation": true, + "https://json-schema.org/draft/2019-09/vocab/meta-data": true, + "https://json-schema.org/draft/2019-09/vocab/format": false, + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "properties": { + "definitions": { + "$comment": "While no longer an official keyword as it is replaced by $defs, this keyword is retained in the meta-schema to prevent incompatible extensions as it remains in common use.", + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" is no longer a keyword, but schema authors should avoid redefining it to facilitate a smooth transition to \"dependentSchemas\" and \"dependentRequired\"", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + } + } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/applicator b/validator/src/metaschemas/draft/2020-12/meta/applicator new file mode 100644 index 0000000..0ef24ed --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/applicator @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true + }, + "$dynamicAnchor": "meta", + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/content b/validator/src/metaschemas/draft/2020-12/meta/content new file mode 100644 index 0000000..0330ff0 --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/content @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + "title": "Content vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/core b/validator/src/metaschemas/draft/2020-12/meta/core new file mode 100644 index 0000000..c4de700 --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/core @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "$dynamicAnchor": "meta", + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/format-annotation b/validator/src/metaschemas/draft/2020-12/meta/format-annotation new file mode 100644 index 0000000..0aa07d1 --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/format-annotation @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true + }, + "$dynamicAnchor": "meta", + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/format-assertion b/validator/src/metaschemas/draft/2020-12/meta/format-assertion new file mode 100644 index 0000000..38613bf --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/format-assertion @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-assertion", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-assertion": true + }, + "$dynamicAnchor": "meta", + "title": "Format vocabulary meta-schema for assertion results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/meta-data b/validator/src/metaschemas/draft/2020-12/meta/meta-data new file mode 100644 index 0000000..30e2837 --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/meta-data @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/meta-data": true + }, + "$dynamicAnchor": "meta", + "title": "Meta-data vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/unevaluated b/validator/src/metaschemas/draft/2020-12/meta/unevaluated new file mode 100644 index 0000000..e9e093d --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/unevaluated @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true + }, + "$dynamicAnchor": "meta", + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/meta/validation b/validator/src/metaschemas/draft/2020-12/meta/validation new file mode 100644 index 0000000..4e016ed --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/meta/validation @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/validator/src/metaschemas/draft/2020-12/schema b/validator/src/metaschemas/draft/2020-12/schema new file mode 100644 index 0000000..364f8ad --- /dev/null +++ b/validator/src/metaschemas/draft/2020-12/schema @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} diff --git a/validator/src/output.rs b/validator/src/output.rs new file mode 100644 index 0000000..8da46f8 --- /dev/null +++ b/validator/src/output.rs @@ -0,0 +1,622 @@ +use std::{ + borrow::Cow, + fmt::{Display, Formatter, Write}, +}; + +use serde::{ + ser::{SerializeMap, SerializeSeq}, + Serialize, +}; + +use crate::{util::*, ErrorKind, InstanceLocation, ValidationError}; + +impl<'s> ValidationError<'s, '_> { + fn absolute_keyword_location(&self) -> AbsoluteKeywordLocation<'s> { + if let ErrorKind::Reference { url, .. } = &self.kind { + AbsoluteKeywordLocation { + schema_url: url, + keyword_path: None, + } + } else { + AbsoluteKeywordLocation { + schema_url: self.schema_url, + keyword_path: self.kind.keyword_path(), + } + } + } + + fn skip(&self) -> bool { + self.causes.len() == 1 && matches!(self.kind, ErrorKind::Reference { .. }) + } + + /// The `Flag` output format, merely the boolean result. + pub fn flag_output(&self) -> FlagOutput { + FlagOutput { valid: false } + } + + /// The `Basic` structure, a flat list of output units. + pub fn basic_output(&self) -> OutputUnit<'_, '_, '_> { + let mut outputs = vec![]; + + let mut in_ref = InRef::default(); + let mut kw_loc = KeywordLocation::default(); + for node in DfsIterator::new(self) { + match node { + DfsItem::Pre(e) => { + in_ref.pre(e); + kw_loc.pre(e); + if e.skip() || matches!(e.kind, ErrorKind::Schema { .. }) { + continue; + } + let absolute_keyword_location = if in_ref.get() { + Some(e.absolute_keyword_location()) + } else { + None + }; + outputs.push(OutputUnit { + valid: false, + keyword_location: kw_loc.get(e), + absolute_keyword_location, + instance_location: &e.instance_location, + error: OutputError::Leaf(&e.kind), + }); + } + DfsItem::Post(e) => { + in_ref.post(); + kw_loc.post(); + if e.skip() || matches!(e.kind, ErrorKind::Schema { .. }) { + continue; + } + } + } + } + + let error = if outputs.is_empty() { + OutputError::Leaf(&self.kind) + } else { + OutputError::Branch(outputs) + }; + OutputUnit { + valid: false, + keyword_location: String::new(), + absolute_keyword_location: None, + instance_location: &self.instance_location, + error, + } + } + + /// The `Detailed` structure, based on the schema. + pub fn detailed_output(&self) -> OutputUnit<'_, '_, '_> { + let mut root = None; + let mut stack: Vec = vec![]; + + let mut in_ref = InRef::default(); + let mut kw_loc = KeywordLocation::default(); + for node in DfsIterator::new(self) { + match node { + DfsItem::Pre(e) => { + in_ref.pre(e); + kw_loc.pre(e); + if e.skip() { + continue; + } + let absolute_keyword_location = if in_ref.get() { + Some(e.absolute_keyword_location()) + } else { + None + }; + stack.push(OutputUnit { + valid: false, + keyword_location: kw_loc.get(e), + absolute_keyword_location, + instance_location: &e.instance_location, + error: OutputError::Leaf(&e.kind), + }); + } + DfsItem::Post(e) => { + in_ref.post(); + kw_loc.post(); + if e.skip() { + continue; + } + let output = stack.pop().unwrap(); + if let Some(parent) = stack.last_mut() { + match &mut parent.error { + OutputError::Leaf(_) => { + parent.error = OutputError::Branch(vec![output]); + } + OutputError::Branch(v) => v.push(output), + } + } else { + root.replace(output); + } + } + } + } + root.unwrap() + } +} + +// DfsIterator -- + +impl Display for ValidationError<'_, '_> { + /// Formats error hierarchy. Use `#` to show the schema location. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut indent = Indent::default(); + let mut sloc = SchemaLocation::default(); + // let mut kw_loc = KeywordLocation::default(); + for node in DfsIterator::new(self) { + match node { + DfsItem::Pre(e) => { + // kw_loc.pre(e); + if e.skip() { + continue; + } + indent.pre(f)?; + if f.alternate() { + sloc.pre(e); + } + if let ErrorKind::Schema { .. } = &e.kind { + write!(f, "jsonschema {}", e.kind)?; + } else { + write!(f, "at {}", quote(&e.instance_location.to_string()))?; + if f.alternate() { + write!(f, " [{}]", sloc)?; + // write!(f, " [{}]", kw_loc.get(e))?; + // write!(f, " [{}]", e.absolute_keyword_location())?; + } + write!(f, ": {}", e.kind)?; + } + } + DfsItem::Post(e) => { + // kw_loc.post(); + if e.skip() { + continue; + } + indent.post(); + sloc.post(); + } + } + } + Ok(()) + } +} + +struct DfsIterator<'a, 'v, 's> { + root: Option<&'a ValidationError<'v, 's>>, + stack: Vec>, +} + +impl<'a, 'v, 's> DfsIterator<'a, 'v, 's> { + fn new(err: &'a ValidationError<'v, 's>) -> Self { + DfsIterator { + root: Some(err), + stack: vec![], + } + } +} + +impl<'a, 'v, 's> Iterator for DfsIterator<'a, 'v, 's> { + type Item = DfsItem<&'a ValidationError<'v, 's>>; + + fn next(&mut self) -> Option { + let Some(mut frame) = self.stack.pop() else { + if let Some(err) = self.root.take() { + self.stack.push(Frame::from(err)); + return Some(DfsItem::Pre(err)); + } else { + return None; + } + }; + + if frame.causes.is_empty() { + return Some(DfsItem::Post(frame.err)); + } + + let err = &frame.causes[0]; + frame.causes = &frame.causes[1..]; + self.stack.push(frame); + self.stack.push(Frame::from(err)); + Some(DfsItem::Pre(err)) + } +} + +struct Frame<'a, 'v, 's> { + err: &'a ValidationError<'v, 's>, + causes: &'a [ValidationError<'v, 's>], +} + +impl<'a, 'v, 's> Frame<'a, 'v, 's> { + fn from(err: &'a ValidationError<'v, 's>) -> Self { + Self { + err, + causes: &err.causes, + } + } +} + +enum DfsItem { + Pre(T), + Post(T), +} + +// Indent -- + +#[derive(Default)] +struct Indent { + n: usize, +} + +impl Indent { + fn pre(&mut self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if self.n > 0 { + writeln!(f)?; + for _ in 0..self.n - 1 { + write!(f, " ")?; + } + write!(f, "- ")?; + } + self.n += 1; + Ok(()) + } + + fn post(&mut self) { + self.n -= 1; + } +} + +// SchemaLocation + +#[derive(Default)] +struct SchemaLocation<'a, 's, 'v> { + stack: Vec<&'a ValidationError<'s, 'v>>, +} + +impl<'a, 's, 'v> SchemaLocation<'a, 's, 'v> { + fn pre(&mut self, e: &'a ValidationError<'s, 'v>) { + self.stack.push(e); + } + + fn post(&mut self) { + self.stack.pop(); + } +} + +impl Display for SchemaLocation<'_, '_, '_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut iter = self.stack.iter().cloned(); + let cur = iter.next_back().unwrap(); + let cur: Cow = match &cur.kind { + ErrorKind::Schema { url } => Cow::Borrowed(url), + ErrorKind::Reference { url, .. } => Cow::Borrowed(url), + _ => Cow::Owned(cur.absolute_keyword_location().to_string()), + }; + + let Some(prev) = iter.next_back() else { + return write!(f, "{cur}"); + }; + + let p = match &prev.kind { + ErrorKind::Schema { url } => { + let (p, _) = split(url); + p + } + ErrorKind::Reference { url, .. } => { + let (p, _) = split(url); + p + } + _ => { + let (p, _) = split(prev.schema_url); + p + } + }; + let (c, frag) = split(cur.as_ref()); + if c == p { + write!(f, "S#{frag}") + } else { + write!(f, "{cur}") + } + } +} + +// KeywordLocation -- + +#[derive(Default)] +struct KeywordLocation<'a> { + loc: String, + stack: Vec<(&'a str, usize)>, // (schema_url, len) +} + +impl<'a> KeywordLocation<'a> { + fn pre(&mut self, e: &'a ValidationError) { + let cur = match &e.kind { + ErrorKind::Schema { url } => url, + ErrorKind::Reference { url, .. } => url, + _ => e.schema_url, + }; + + if let Some((prev, _)) = self.stack.last() { + self.loc.push_str(&e.schema_url[prev.len()..]); // todo: url-decode + if let ErrorKind::Reference { kw, .. } = &e.kind { + self.loc.push('/'); + self.loc.push_str(kw); + } + } + self.stack.push((cur, self.loc.len())); + } + + fn post(&mut self) { + self.stack.pop(); + if let Some((_, len)) = self.stack.last() { + self.loc.truncate(*len); + } + } + + fn get(&mut self, cur: &'a ValidationError) -> String { + if let ErrorKind::Reference { .. } = &cur.kind { + self.loc.clone() + } else if let Some(kw_path) = &cur.kind.keyword_path() { + let len = self.loc.len(); + self.loc.push('/'); + write!(self.loc, "{}", kw_path).expect("write kw_path to String should not fail"); + let loc = self.loc.clone(); + self.loc.truncate(len); + loc + } else { + self.loc.clone() + } + } +} + +#[derive(Default)] +struct InRef { + stack: Vec, +} + +impl InRef { + fn pre(&mut self, e: &ValidationError) { + let in_ref: bool = self.get() || matches!(e.kind, ErrorKind::Reference { .. }); + self.stack.push(in_ref); + } + + fn post(&mut self) { + self.stack.pop(); + } + + fn get(&self) -> bool { + self.stack.last().cloned().unwrap_or_default() + } +} + +// output formats -- + +/// Simplest output format, merely the boolean result. +pub struct FlagOutput { + pub valid: bool, +} + +impl Serialize for FlagOutput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("valid", &self.valid)?; + map.end() + } +} + +impl Display for FlagOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write_json_to_fmt(f, self) + } +} + +/// Single OutputUnit used in Basic/Detailed output formats. +pub struct OutputUnit<'e, 's, 'v> { + pub valid: bool, + pub keyword_location: String, + /// The absolute, dereferenced location of the validating keyword + pub absolute_keyword_location: Option>, + /// The location of the JSON value within the instance being validated + pub instance_location: &'e InstanceLocation<'v>, + pub error: OutputError<'e, 's, 'v>, +} + +impl Serialize for OutputUnit<'_, '_, '_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let n = 4 + self.absolute_keyword_location.as_ref().map_or(0, |_| 1); + let mut map = serializer.serialize_map(Some(n))?; + map.serialize_entry("valid", &self.valid)?; + map.serialize_entry("keywordLocation", &self.keyword_location.to_string())?; + if let Some(s) = &self.absolute_keyword_location { + map.serialize_entry("absoluteKeywordLocation", &s.to_string())?; + } + map.serialize_entry("instanceLocation", &self.instance_location.to_string())?; + let pname = match self.error { + OutputError::Leaf(_) => "error", + OutputError::Branch(_) => "errors", + }; + map.serialize_entry(pname, &self.error)?; + map.end() + } +} + +impl Display for OutputUnit<'_, '_, '_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write_json_to_fmt(f, self) + } +} + +/// Error of [`OutputUnit`]. +pub enum OutputError<'e, 's, 'v> { + /// Single. + Leaf(&'e ErrorKind<'s, 'v>), + /// Nested. + Branch(Vec>), +} + +impl Serialize for OutputError<'_, '_, '_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + OutputError::Leaf(kind) => serializer.serialize_str(&kind.to_string()), + OutputError::Branch(units) => { + let mut seq = serializer.serialize_seq(Some(units.len()))?; + for unit in units { + seq.serialize_element(unit)?; + } + seq.end() + } + } + } +} + +// AbsoluteKeywordLocation -- + +impl<'s> ErrorKind<'s, '_> { + pub fn keyword_path(&self) -> Option> { + #[inline(always)] + fn kw(kw: &'static str) -> Option> { + Some(KeywordPath { + keyword: kw, + token: None, + }) + } + + #[inline(always)] + fn kw_prop<'s>(kw: &'static str, prop: &'s str) -> Option> { + Some(KeywordPath { + keyword: kw, + token: Some(SchemaToken::Prop(prop)), + }) + } + + use ErrorKind::*; + match self { + Group => None, + Schema { .. } => None, + ContentSchema => kw("contentSchema"), + PropertyName { .. } => kw("propertyNames"), + Reference { kw: kword, .. } => kw(kword), + RefCycle { .. } => None, + FalseSchema => None, + Type { .. } => kw("type"), + Enum { .. } => kw("enum"), + Const { .. } => kw("const"), + Format { .. } => kw("format"), + MinProperties { .. } => kw("minProperties"), + MaxProperties { .. } => kw("maxProperties"), + AdditionalProperties { .. } => kw("additionalProperty"), + Required { .. } => kw("required"), + Dependency { prop, .. } => kw_prop("dependencies", prop), + DependentRequired { prop, .. } => kw_prop("dependentRequired", prop), + MinItems { .. } => kw("minItems"), + MaxItems { .. } => kw("maxItems"), + Contains => kw("contains"), + MinContains { .. } => kw("minContains"), + MaxContains { .. } => kw("maxContains"), + UniqueItems { .. } => kw("uniqueItems"), + AdditionalItems { .. } => kw("additionalItems"), + MinLength { .. } => kw("minLength"), + MaxLength { .. } => kw("maxLength"), + Pattern { .. } => kw("pattern"), + ContentEncoding { .. } => kw("contentEncoding"), + ContentMediaType { .. } => kw("contentMediaType"), + Minimum { .. } => kw("minimum"), + Maximum { .. } => kw("maximum"), + ExclusiveMinimum { .. } => kw("exclusiveMinimum"), + ExclusiveMaximum { .. } => kw("exclusiveMaximum"), + MultipleOf { .. } => kw("multipleOf"), + Not => kw("not"), + AllOf => kw("allOf"), + AnyOf => kw("anyOf"), + OneOf(_) => kw("oneOf"), + } + } +} + +/// The absolute, dereferenced location of the validating keyword +#[derive(Debug, Clone)] +pub struct AbsoluteKeywordLocation<'s> { + /// The absolute, dereferenced schema location. + pub schema_url: &'s str, + /// Location within the `schema_url`. + pub keyword_path: Option>, +} + +impl Display for AbsoluteKeywordLocation<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.schema_url.fmt(f)?; + if let Some(path) = &self.keyword_path { + f.write_str("/")?; + path.keyword.fmt(f)?; + if let Some(token) = &path.token { + f.write_str("/")?; + match token { + SchemaToken::Prop(p) => write!(f, "{}", escape(p))?, // todo: url-encode + SchemaToken::Item(i) => write!(f, "{i}")?, + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +/// JsonPointer in schema. +pub struct KeywordPath<'s> { + /// The first token. + pub keyword: &'static str, + /// Optinal token within keyword. + pub token: Option>, +} + +impl Display for KeywordPath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.keyword.fmt(f)?; + if let Some(token) = &self.token { + f.write_str("/")?; + token.fmt(f)?; + } + Ok(()) + } +} + +/// Token for schema. +#[derive(Debug, Clone)] +pub enum SchemaToken<'s> { + /// Token for property. + Prop(&'s str), + /// Token for array item. + Item(usize), +} + +impl Display for SchemaToken<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SchemaToken::Prop(p) => write!(f, "{}", escape(p)), + SchemaToken::Item(i) => write!(f, "{i}"), + } + } +} + +// helpers -- + +fn write_json_to_fmt(f: &mut std::fmt::Formatter, value: &T) -> Result<(), std::fmt::Error> +where + T: ?Sized + Serialize, +{ + let s = if f.alternate() { + serde_json::to_string_pretty(value) + } else { + serde_json::to_string(value) + }; + let s = s.map_err(|_| std::fmt::Error)?; + f.write_str(&s) +} diff --git a/validator/src/root.rs b/validator/src/root.rs new file mode 100644 index 0000000..9c6213a --- /dev/null +++ b/validator/src/root.rs @@ -0,0 +1,128 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{compiler::CompileError, draft::*, util::*}; + +use serde_json::Value; +use url::Url; + +pub(crate) struct Root { + pub(crate) draft: &'static Draft, + pub(crate) resources: HashMap, // ptr => _ + pub(crate) url: Url, + pub(crate) meta_vocabs: Option>, +} + +impl Root { + pub(crate) fn has_vocab(&self, name: &str) -> bool { + if self.draft.version < 2019 || name == "core" { + return true; + } + if let Some(vocabs) = &self.meta_vocabs { + return vocabs.iter().any(|s| s == name); + } + self.draft.default_vocabs.contains(&name) + } + + fn resolve_fragment_in(&self, frag: &Fragment, res: &Resource) -> Result { + let ptr = match frag { + Fragment::Anchor(anchor) => { + let Some(ptr) = res.anchors.get(anchor) else { + return Err(CompileError::AnchorNotFound { + url: self.url.to_string(), + reference: UrlFrag::format(&res.id, frag.as_str()), + }); + }; + ptr.clone() + } + Fragment::JsonPointer(ptr) => res.ptr.concat(ptr), + }; + Ok(UrlPtr { + url: self.url.clone(), + ptr, + }) + } + + pub(crate) fn resolve_fragment(&self, frag: &Fragment) -> Result { + let res = self.resources.get("").ok_or(CompileError::Bug( + format!("no root resource found for {}", self.url).into(), + ))?; + self.resolve_fragment_in(frag, res) + } + + // resolves `UrlFrag` to `UrlPtr` from root. + // returns `None` if it is external. + pub(crate) fn resolve(&self, uf: &UrlFrag) -> Result, CompileError> { + let res = { + if uf.url == self.url { + self.resources.get("").ok_or(CompileError::Bug( + format!("no root resource found for {}", self.url).into(), + ))? + } else { + // look for resource with id==uf.url + let Some(res) = self.resources.values().find(|res| res.id == uf.url) else { + return Ok(None); // external url + }; + res + } + }; + + self.resolve_fragment_in(&uf.frag, res).map(Some) + } + + pub(crate) fn resource(&self, ptr: &JsonPointer) -> &Resource { + let mut ptr = ptr.as_str(); + loop { + if let Some(res) = self.resources.get(ptr) { + return res; + } + let Some((prefix, _)) = ptr.rsplit_once('/') else { + break; + }; + ptr = prefix; + } + self.resources.get("").expect("root resource should exist") + } + + pub(crate) fn base_url(&self, ptr: &JsonPointer) -> &Url { + &self.resource(ptr).id + } + + pub(crate) fn add_subschema( + &mut self, + doc: &Value, + ptr: &JsonPointer, + ) -> Result<(), CompileError> { + let v = ptr.lookup(doc, &self.url)?; + let base_url = self.base_url(ptr).clone(); + self.draft + .collect_resources(v, &base_url, ptr.clone(), &self.url, &mut self.resources)?; + + // collect anchors + if !self.resources.contains_key(ptr) { + let res = self.resource(ptr); + if let Some(res) = self.resources.get_mut(&res.ptr.clone()) { + self.draft.collect_anchors(v, ptr, res, &self.url)?; + } + } + Ok(()) + } +} + +#[derive(Debug)] +pub(crate) struct Resource { + pub(crate) ptr: JsonPointer, // from root + pub(crate) id: Url, + pub(crate) anchors: HashMap, // anchor => ptr + pub(crate) dynamic_anchors: HashSet, +} + +impl Resource { + pub(crate) fn new(ptr: JsonPointer, id: Url) -> Self { + Self { + ptr, + id, + anchors: HashMap::new(), + dynamic_anchors: HashSet::new(), + } + } +} diff --git a/validator/src/roots.rs b/validator/src/roots.rs new file mode 100644 index 0000000..fd64eca --- /dev/null +++ b/validator/src/roots.rs @@ -0,0 +1,107 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{compiler::CompileError, draft::*, loader::DefaultUrlLoader, root::Root, util::*}; + +use serde_json::Value; +use url::Url; + +// -- + +pub(crate) struct Roots { + pub(crate) default_draft: &'static Draft, + map: HashMap, + pub(crate) loader: DefaultUrlLoader, +} + +impl Roots { + fn new() -> Self { + Self { + default_draft: latest(), + map: Default::default(), + loader: DefaultUrlLoader::new(), + } + } +} + +impl Default for Roots { + fn default() -> Self { + Self::new() + } +} + +impl Roots { + pub(crate) fn get(&self, url: &Url) -> Option<&Root> { + self.map.get(url) + } + + pub(crate) fn resolve_fragment(&mut self, uf: UrlFrag) -> Result { + self.or_load(uf.url.clone())?; + let Some(root) = self.map.get(&uf.url) else { + return Err(CompileError::Bug("or_load didn't add".into())); + }; + root.resolve_fragment(&uf.frag) + } + + pub(crate) fn ensure_subschema(&mut self, up: &UrlPtr) -> Result<(), CompileError> { + self.or_load(up.url.clone())?; + let Some(root) = self.map.get_mut(&up.url) else { + return Err(CompileError::Bug("or_load didn't add".into())); + }; + if !root.draft.is_subschema(up.ptr.as_str()) { + let doc = self.loader.load(&root.url)?; + let v = up.ptr.lookup(doc, &up.url)?; + root.draft.validate(up, v)?; + root.add_subschema(doc, &up.ptr)?; + } + Ok(()) + } + + pub(crate) fn or_load(&mut self, url: Url) -> Result<(), CompileError> { + debug_assert!(url.fragment().is_none(), "trying to add root with fragment"); + if self.map.contains_key(&url) { + return Ok(()); + } + let doc = self.loader.load(&url)?; + let r = self.create_root(url.clone(), doc)?; + self.map.insert(url, r); + Ok(()) + } + + pub(crate) fn create_root(&self, url: Url, doc: &Value) -> Result { + let draft = { + let up = UrlPtr { + url: url.clone(), + ptr: "".into(), + }; + self.loader + .get_draft(&up, doc, self.default_draft, HashSet::new())? + }; + let vocabs = self.loader.get_meta_vocabs(doc, draft)?; + let resources = { + let mut m = HashMap::default(); + draft.collect_resources(doc, &url, "".into(), &url, &mut m)?; + m + }; + + if !matches!(url.host_str(), Some("json-schema.org")) { + draft.validate( + &UrlPtr { + url: url.clone(), + ptr: "".into(), + }, + doc, + )?; + } + + Ok(Root { + draft, + resources, + url: url.clone(), + meta_vocabs: vocabs, + }) + } + + pub(crate) fn insert(&mut self, roots: &mut HashMap) { + self.map.extend(roots.drain()); + } +} diff --git a/validator/src/util.rs b/validator/src/util.rs new file mode 100644 index 0000000..b064bad --- /dev/null +++ b/validator/src/util.rs @@ -0,0 +1,545 @@ +use std::{ + borrow::{Borrow, Cow}, + fmt::Display, + hash::{Hash, Hasher}, + str::FromStr, +}; + +use ahash::{AHashMap, AHasher}; +use percent_encoding::{percent_decode_str, AsciiSet, CONTROLS}; +use serde_json::Value; +use url::Url; + +use crate::CompileError; + +// -- + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub(crate) struct JsonPointer(pub(crate) String); + +impl JsonPointer { +pub(crate) fn escape(token: &str) -> Cow<'_, str> { + const SPECIAL: [char; 2] = ['~', '/']; + if token.contains(SPECIAL) { + token.replace('~', "~0").replace('/', "~1").into() + } else { + token.into() + } + } + + pub(crate) fn unescape(mut tok: &str) -> Result, ()> { + let Some(mut tilde) = tok.find('~') else { + return Ok(Cow::Borrowed(tok)); + }; + let mut s = String::with_capacity(tok.len()); + loop { + s.push_str(&tok[..tilde]); + tok = &tok[tilde + 1..]; + match tok.chars().next() { + Some('1') => s.push('/'), + Some('0') => s.push('~'), + _ => return Err(()), + } + tok = &tok[1..]; + let Some(i) = tok.find('~') else { + s.push_str(tok); + break; + }; + tilde = i; + } + Ok(Cow::Owned(s)) + } + + pub(crate) fn lookup<'a>( + &self, + mut v: &'a Value, + v_url: &Url, + ) -> Result<&'a Value, CompileError> { + for tok in self.0.split('/').skip(1) { + let Ok(tok) = Self::unescape(tok) else { + let loc = UrlFrag::format(v_url, self.as_str()); + return Err(CompileError::InvalidJsonPointer(loc)); + }; + match v { + Value::Object(obj) => { + if let Some(pvalue) = obj.get(tok.as_ref()) { + v = pvalue; + continue; + } + } + Value::Array(arr) => { + if let Ok(i) = usize::from_str(tok.as_ref()) { + if let Some(item) = arr.get(i) { + v = item; + continue; + } + }; + } + _ => {} + } + let loc = UrlFrag::format(v_url, self.as_str()); + return Err(CompileError::JsonPointerNotFound(loc)); + } + Ok(v) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn concat(&self, next: &Self) -> Self { + JsonPointer(format!("{}{}", self.0, next.0)) + } + + pub(crate) fn append(&self, tok: &str) -> Self { + Self(format!("{}/{}", self, Self::escape(tok))) + } + + pub(crate) fn append2(&self, tok1: &str, tok2: &str) -> Self { + Self(format!( + "{}/{}/{}", + self, + Self::escape(tok1), + Self::escape(tok2) + )) + } +} + +impl Display for JsonPointer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Borrow for JsonPointer { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From<&str> for JsonPointer { + fn from(value: &str) -> Self { + Self(value.into()) + } +} + +// -- + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub(crate) struct Anchor(pub(crate) String); + +impl Display for Anchor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Borrow for Anchor { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From<&str> for Anchor { + fn from(value: &str) -> Self { + Self(value.into()) + } +} + +// -- +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) enum Fragment { + Anchor(Anchor), + JsonPointer(JsonPointer), +} + +impl Fragment { + pub(crate) fn split(s: &str) -> Result<(&str, Fragment), CompileError> { + let (u, frag) = split(s); + let frag = percent_decode_str(frag) + .decode_utf8() + .map_err(|src| CompileError::ParseUrlError { + url: s.to_string(), + src: src.into(), + })? + .to_string(); + let frag = if frag.is_empty() || frag.starts_with('/') { + Fragment::JsonPointer(JsonPointer(frag)) + } else { + Fragment::Anchor(Anchor(frag)) + }; + Ok((u, frag)) + } + + pub(crate) fn encode(frag: &str) -> String { + // https://url.spec.whatwg.org/#fragment-percent-encode-set + const FRAGMENT: &AsciiSet = &CONTROLS + .add(b'%') + .add(b' ') + .add(b'"') + .add(b'<') + .add(b'>') + .add(b'`'); + percent_encoding::utf8_percent_encode(frag, FRAGMENT).to_string() + } + + pub(crate) fn as_str(&self) -> &str { + match self { + Fragment::Anchor(s) => &s.0, + Fragment::JsonPointer(s) => &s.0, + } + } +} + +// -- + +#[derive(Clone)] +pub(crate) struct UrlFrag { + pub(crate) url: Url, + pub(crate) frag: Fragment, +} + +impl UrlFrag { + pub(crate) fn absolute(input: &str) -> Result { + let (u, frag) = Fragment::split(input)?; + + // note: windows drive letter is treated as url scheme by url parser + #[cfg(not(target_arch = "wasm32"))] + if std::env::consts::OS == "windows" && starts_with_windows_drive(u) { + let url = Url::from_file_path(u) + .map_err(|_| CompileError::Bug(format!("failed to convert {u} into url").into()))?; + return Ok(UrlFrag { url, frag }); + } + + match Url::parse(u) { + Ok(url) => Ok(UrlFrag { url, frag }), + #[cfg(not(target_arch = "wasm32"))] + Err(url::ParseError::RelativeUrlWithoutBase) => { + let p = std::path::absolute(u).map_err(|e| CompileError::ParseUrlError { + url: u.to_owned(), + src: e.into(), + })?; + let url = Url::from_file_path(p).map_err(|_| { + CompileError::Bug(format!("failed to convert {u} into url").into()) + })?; + Ok(UrlFrag { url, frag }) + } + Err(e) => Err(CompileError::ParseUrlError { + url: u.to_owned(), + src: e.into(), + }), + } + } + + pub(crate) fn join(url: &Url, input: &str) -> Result { + let (input, frag) = Fragment::split(input)?; + if input.is_empty() { + return Ok(UrlFrag { + url: url.clone(), + frag, + }); + } + let url = url.join(input).map_err(|e| CompileError::ParseUrlError { + url: input.to_string(), + src: e.into(), + })?; + + Ok(UrlFrag { url, frag }) + } + + pub(crate) fn format(url: &Url, frag: &str) -> String { + if frag.is_empty() { + url.to_string() + } else { + format!("{}#{}", url, Fragment::encode(frag)) + } + } +} + +impl Display for UrlFrag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}#{}", self.url, Fragment::encode(self.frag.as_str())) + } +} + +// -- + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub(crate) struct UrlPtr { + pub(crate) url: Url, + pub(crate) ptr: JsonPointer, +} + +impl UrlPtr { + pub(crate) fn lookup<'a>(&self, doc: &'a Value) -> Result<&'a Value, CompileError> { + self.ptr.lookup(doc, &self.url) + } + + pub(crate) fn format(&self, tok: &str) -> String { + format!( + "{}#{}/{}", + self.url, + Fragment::encode(self.ptr.as_str()), + Fragment::encode(JsonPointer::escape(tok).as_ref()), + ) + } +} + +impl Display for UrlPtr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}#{}", self.url, Fragment::encode(self.ptr.as_str())) + } +} + +// -- + +pub(crate) fn is_integer(v: &Value) -> bool { + match v { + Value::Number(n) => { + n.is_i64() || n.is_u64() || n.as_f64().filter(|n| n.fract() == 0.0).is_some() + } + _ => false, + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn starts_with_windows_drive(p: &str) -> bool { + p.chars().next().filter(char::is_ascii_uppercase).is_some() && p[1..].starts_with(":\\") +} + +/// returns single-quoted string +pub(crate) fn quote(s: &T) -> String +where + T: AsRef + std::fmt::Debug + ?Sized, +{ + let s = format!("{s:?}").replace(r#"\""#, "\"").replace('\'', r"\'"); + format!("'{}'", &s[1..s.len() - 1]) +} + +pub(crate) fn join_iter(iterable: T, sep: &str) -> String +where + T: IntoIterator, + T::Item: Display, +{ + iterable + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(sep) +} + +pub(crate) fn escape(token: &str) -> Cow<'_, str> { + JsonPointer::escape(token) +} + +pub(crate) fn split(url: &str) -> (&str, &str) { + if let Some(i) = url.find('#') { + (&url[..i], &url[i + 1..]) + } else { + (url, "") + } +} + +/// serde_json treats 0 and 0.0 not equal. so we cannot simply use v1==v2 +pub(crate) fn equals(v1: &Value, v2: &Value) -> bool { + match (v1, v2) { + (Value::Null, Value::Null) => true, + (Value::Bool(b1), Value::Bool(b2)) => b1 == b2, + (Value::Number(n1), Value::Number(n2)) => { + if let (Some(n1), Some(n2)) = (n1.as_u64(), n2.as_u64()) { + return n1 == n2; + } + if let (Some(n1), Some(n2)) = (n1.as_i64(), n2.as_i64()) { + return n1 == n2; + } + if let (Some(n1), Some(n2)) = (n1.as_f64(), n2.as_f64()) { + return n1 == n2; + } + false + } + (Value::String(s1), Value::String(s2)) => s1 == s2, + (Value::Array(arr1), Value::Array(arr2)) => { + if arr1.len() != arr2.len() { + return false; + } + arr1.iter().zip(arr2).all(|(e1, e2)| equals(e1, e2)) + } + (Value::Object(obj1), Value::Object(obj2)) => { + if obj1.len() != obj2.len() { + return false; + } + for (k1, v1) in obj1 { + if let Some(v2) = obj2.get(k1) { + if !equals(v1, v2) { + return false; + } + } else { + return false; + } + } + true + } + _ => false, + } +} + +pub(crate) fn duplicates(arr: &Vec) -> Option<(usize, usize)> { + match arr.as_slice() { + [e0, e1] => { + if equals(e0, e1) { + return Some((0, 1)); + } + } + [e0, e1, e2] => { + if equals(e0, e1) { + return Some((0, 1)); + } else if equals(e0, e2) { + return Some((0, 2)); + } else if equals(e1, e2) { + return Some((1, 2)); + } + } + _ => { + let len = arr.len(); + if len <= 20 { + for i in 0..len - 1 { + for j in i + 1..len { + if equals(&arr[i], &arr[j]) { + return Some((i, j)); + } + } + } + } else { + let mut seen = AHashMap::with_capacity(len); + for (i, item) in arr.iter().enumerate() { + if let Some(j) = seen.insert(HashedValue(item), i) { + return Some((j, i)); + } + } + } + } + } + None +} + +// HashedValue -- + +// Based on implementation proposed by Sven Marnach: +// https://stackoverflow.com/questions/60882381/what-is-the-fastest-correct-way-to-detect-that-there-are-no-duplicates-in-a-json +pub(crate) struct HashedValue<'a>(pub(crate) &'a Value); + +impl PartialEq for HashedValue<'_> { + fn eq(&self, other: &Self) -> bool { + equals(self.0, other.0) + } +} + +impl Eq for HashedValue<'_> {} + +impl Hash for HashedValue<'_> { + fn hash(&self, state: &mut H) { + match self.0 { + Value::Null => state.write_u32(3_221_225_473), // chosen randomly + Value::Bool(ref b) => b.hash(state), + Value::Number(ref num) => { + if let Some(num) = num.as_f64() { + num.to_bits().hash(state); + } else if let Some(num) = num.as_u64() { + num.hash(state); + } else if let Some(num) = num.as_i64() { + num.hash(state); + } + } + Value::String(ref str) => str.hash(state), + Value::Array(ref arr) => { + for item in arr { + HashedValue(item).hash(state); + } + } + Value::Object(ref obj) => { + let mut hash = 0; + for (pname, pvalue) in obj { + // We have no way of building a new hasher of type `H`, so we + // hardcode using the default hasher of a hash map. + let mut hasher = AHasher::default(); + pname.hash(&mut hasher); + HashedValue(pvalue).hash(&mut hasher); + hash ^= hasher.finish(); + } + state.write_u64(hash); + } + } + } +} + +#[cfg(test)] +mod tests { + + use ahash::AHashMap; + use serde_json::json; + + use super::*; + + #[test] + fn test_quote() { + assert_eq!(quote(r#"abc"def'ghi"#), r#"'abc"def\'ghi'"#); + } + + #[test] + fn test_fragment_split() { + let tests = [ + ("#", Fragment::JsonPointer("".into())), + ("#/a/b", Fragment::JsonPointer("/a/b".into())), + ("#abcd", Fragment::Anchor("abcd".into())), + ("#%61%62%63%64", Fragment::Anchor("abcd".into())), + ( + "#%2F%61%62%63%64%2fef", + Fragment::JsonPointer("/abcd/ef".into()), + ), // '/' is encoded + ("#abcd+ef", Fragment::Anchor("abcd+ef".into())), // '+' should not traslate to space + ]; + for test in tests { + let (_, got) = Fragment::split(test.0).unwrap(); + assert_eq!(got, test.1, "Fragment::split({:?})", test.0); + } + } + + #[test] + fn test_unescape() { + let tests = [ + ("bar~0", Some("bar~")), + ("bar~1", Some("bar/")), + ("bar~01", Some("bar~1")), + ("bar~", None), + ("bar~~", None), + ]; + for (tok, want) in tests { + let res = JsonPointer::unescape(tok).ok(); + let got = res.as_ref().map(|c| c.as_ref()); + assert_eq!(got, want, "unescape({:?})", tok) + } + } + + #[test] + fn test_equals() { + let tests = [["1.0", "1"], ["-1.0", "-1"]]; + for [a, b] in tests { + let a = serde_json::from_str(a).unwrap(); + let b = serde_json::from_str(b).unwrap(); + assert!(equals(&a, &b)); + } + } + + #[test] + fn test_hashed_value() { + let mut seen = AHashMap::with_capacity(10); + let (v1, v2) = (json!(2), json!(2.0)); + assert!(equals(&v1, &v2)); + assert!(seen.insert(HashedValue(&v1), 1).is_none()); + assert!(seen.insert(HashedValue(&v2), 1).is_some()); + } +} diff --git a/validator/src/validator.rs b/validator/src/validator.rs new file mode 100644 index 0000000..2dfa804 --- /dev/null +++ b/validator/src/validator.rs @@ -0,0 +1,1169 @@ +use std::{borrow::Cow, cmp::min, collections::HashSet, fmt::Write}; + +use serde_json::{Map, Value}; + +use crate::{util::*, *}; + +macro_rules! prop { + ($prop:expr) => { + InstanceToken::Prop(Cow::Borrowed($prop)) + }; +} + +macro_rules! item { + ($item:expr) => { + InstanceToken::Item($item) + }; +} + +pub(crate) fn validate<'s, 'v>( + v: &'v Value, + schema: &'s Schema, + schemas: &'s Schemas, +) -> Result<(), ValidationError<'s, 'v>> { + let scope = Scope { + sch: schema.idx, + ref_kw: None, + vid: 0, + parent: None, + }; + let mut vloc = Vec::with_capacity(8); + let (result, _) = Validator { + v, + vloc: &mut vloc, + schema, + schemas, + scope, + uneval: Uneval::from(v, schema, false), + errors: vec![], + bool_result: false, + } + .validate(); + match result { + Err(err) => { + let mut e = ValidationError { + schema_url: &schema.loc, + instance_location: InstanceLocation::new(), + kind: ErrorKind::Schema { url: &schema.loc }, + causes: vec![], + }; + if let ErrorKind::Group = err.kind { + e.causes = err.causes; + } else { + e.causes.push(err); + } + Err(e) + } + Ok(_) => Ok(()), + } +} + +macro_rules! kind { + ($kind:ident, $name:ident: $value:expr) => { + ErrorKind::$kind { $name: $value } + }; + ($kind:ident, $got:expr, $want:expr) => { + ErrorKind::$kind { + got: $got, + want: $want, + } + }; + ($kind:ident, $got:expr, $want:expr, $err:expr) => { + ErrorKind::$kind { + got: $got, + want: $want, + err: $err, + } + }; + ($kind: ident) => { + ErrorKind::$kind + }; +} + +struct Validator<'v, 's, 'd, 'e> { + v: &'v Value, + vloc: &'e mut Vec>, + schema: &'s Schema, + schemas: &'s Schemas, + scope: Scope<'d>, + uneval: Uneval<'v>, + errors: Vec>, + bool_result: bool, // is interested to know valid or not (but not actuall error) +} + +impl<'v, 's> Validator<'v, 's, '_, '_> { + fn validate(mut self) -> (Result<(), ValidationError<'s, 'v>>, Uneval<'v>) { + let s = self.schema; + let v = self.v; + + // boolean -- + if let Some(b) = s.boolean { + return match b { + false => (Err(self.error(kind!(FalseSchema))), self.uneval), + true => (Ok(()), self.uneval), + }; + } + + // check cycle -- + if let Some(scp) = self.scope.check_cycle() { + let kind = ErrorKind::RefCycle { + url: &self.schema.loc, + kw_loc1: self.kw_loc(&self.scope), + kw_loc2: self.kw_loc(scp), + }; + return (Err(self.error(kind)), self.uneval); + } + + // type -- + if !s.types.is_empty() { + let v_type = Type::of(v); + let matched = + s.types.contains(v_type) || (s.types.contains(Type::Integer) && is_integer(v)); + if !matched { + return (Err(self.error(kind!(Type, v_type, s.types))), self.uneval); + } + } + + // constant -- + if let Some(c) = &s.constant { + if !equals(v, c) { + return (Err(self.error(kind!(Const, want: c))), self.uneval); + } + } + + // enum -- + if let Some(Enum { types, values }) = &s.enum_ { + if !types.contains(Type::of(v)) || !values.iter().any(|e| equals(e, v)) { + return (Err(self.error(kind!(Enum, want: values))), self.uneval); + } + } + + // format -- + if let Some(format) = &s.format { + if let Err(e) = (format.func)(v) { + self.add_error(kind!(Format, Cow::Borrowed(v), format.name, e)); + } + } + + // $ref -- + if let Some(ref_) = s.ref_ { + let result = self.validate_ref(ref_, "$ref"); + if s.draft_version < 2019 { + return (result, self.uneval); + } + self.errors.extend(result.err()); + } + + // type specific validations -- + match v { + Value::Object(obj) => self.obj_validate(obj), + Value::Array(arr) => self.arr_validate(arr), + Value::String(str) => self.str_validate(str), + Value::Number(num) => self.num_validate(num), + _ => {} + } + + if self.errors.is_empty() || !self.bool_result { + if s.draft_version >= 2019 { + self.refs_validate(); + } + self.cond_validate(); + if s.draft_version >= 2019 { + self.uneval_validate(); + } + } + + match self.errors.len() { + 0 => (Ok(()), self.uneval), + 1 => (Err(self.errors.remove(0)), self.uneval), + _ => { + let mut e = self.error(kind!(Group)); + e.causes = self.errors; + (Err(e), self.uneval) + } + } + } +} + +// type specific validations +impl<'v> Validator<'v, '_, '_, '_> { + fn obj_validate(&mut self, obj: &'v Map) { + let s = self.schema; + macro_rules! add_err { + ($result:expr) => { + if let Err(e) = $result { + self.errors.push(e); + } + }; + } + + // minProperties -- + if let Some(min) = s.min_properties { + if obj.len() < min { + self.add_error(kind!(MinProperties, obj.len(), min)); + } + } + + // maxProperties -- + if let Some(max) = s.max_properties { + if obj.len() > max { + self.add_error(kind!(MaxProperties, obj.len(), max)); + } + } + + // required -- + if !s.required.is_empty() { + if let Some(missing) = self.find_missing(obj, &s.required) { + self.add_error(kind!(Required, want: missing)); + } + } + + if self.bool_result && !self.errors.is_empty() { + return; + } + + // dependencies -- + for (prop, dep) in &s.dependencies { + if obj.contains_key(prop) { + match dep { + Dependency::Props(required) => { + if let Some(missing) = self.find_missing(obj, required) { + self.add_error(ErrorKind::Dependency { prop, missing }); + } + } + Dependency::SchemaRef(sch) => { + add_err!(self.validate_self(*sch)); + } + } + } + } + + let mut additional_props = vec![]; + for (pname, pvalue) in obj { + if self.bool_result && !self.errors.is_empty() { + return; + } + let mut evaluated = false; + + // properties -- + if let Some(sch) = s.properties.get(pname) { + evaluated = true; + add_err!(self.validate_val(*sch, pvalue, prop!(pname))); + } + + // patternProperties -- + for (regex, sch) in &s.pattern_properties { + if regex.is_match(pname) { + evaluated = true; + add_err!(self.validate_val(*sch, pvalue, prop!(pname))); + } + } + + if !evaluated { + // additionalProperties -- + if let Some(additional) = &s.additional_properties { + evaluated = true; + match additional { + Additional::Bool(allowed) => { + if !allowed { + additional_props.push(pname.into()); + } + } + Additional::SchemaRef(sch) => { + add_err!(self.validate_val(*sch, pvalue, prop!(pname))); + } + } + } + } + + if evaluated { + self.uneval.props.remove(pname); + } + } + if !additional_props.is_empty() { + self.add_error(kind!(AdditionalProperties, got: additional_props)); + } + + if s.draft_version == 4 { + return; + } + + // propertyNames -- + if let Some(sch) = &s.property_names { + for pname in obj.keys() { + let v = Value::String(pname.to_owned()); + if let Err(mut e) = self.schemas.validate(&v, *sch) { + e.schema_url = &s.loc; + e.kind = ErrorKind::PropertyName { + prop: pname.to_owned(), + }; + self.errors.push(e.clone_static()); + } + } + } + + if s.draft_version == 6 { + return; + } + + // dependentSchemas -- + for (pname, sch) in &s.dependent_schemas { + if obj.contains_key(pname) { + add_err!(self.validate_self(*sch)); + } + } + + // dependentRequired -- + for (prop, required) in &s.dependent_required { + if obj.contains_key(prop) { + if let Some(missing) = self.find_missing(obj, required) { + self.add_error(ErrorKind::DependentRequired { prop, missing }); + } + } + } + } + + fn arr_validate(&mut self, arr: &'v Vec) { + let s = self.schema; + let len = arr.len(); + macro_rules! add_err { + ($result:expr) => { + if let Err(e) = $result { + self.errors.push(e); + } + }; + } + + // minItems -- + if let Some(min) = s.min_items { + if len < min { + self.add_error(kind!(MinItems, len, min)); + } + } + + // maxItems -- + if let Some(max) = s.max_items { + if len > max { + self.add_error(kind!(MaxItems, len, max)); + } + } + + // uniqueItems -- + if len > 1 && s.unique_items { + if let Some((i, j)) = duplicates(arr) { + self.add_error(kind!(UniqueItems, got: [i, j])); + } + } + + if s.draft_version < 2020 { + let mut evaluated = 0; + + // items -- + if let Some(items) = &s.items { + match items { + Items::SchemaRef(sch) => { + for (i, item) in arr.iter().enumerate() { + add_err!(self.validate_val(*sch, item, item!(i))); + } + evaluated = len; + debug_assert!(self.uneval.items.is_empty()); + } + Items::SchemaRefs(list) => { + for (i, (item, sch)) in arr.iter().zip(list).enumerate() { + add_err!(self.validate_val(*sch, item, item!(i))); + } + evaluated = min(list.len(), len); + } + } + } + + // additionalItems -- + if let Some(additional) = &s.additional_items { + match additional { + Additional::Bool(allowed) => { + if !allowed && evaluated != len { + self.add_error(kind!(AdditionalItems, got: len - evaluated)); + } + } + Additional::SchemaRef(sch) => { + for (i, item) in arr[evaluated..].iter().enumerate() { + add_err!(self.validate_val(*sch, item, item!(i))); + } + } + } + debug_assert!(self.uneval.items.is_empty()); + } + } else { + // prefixItems -- + for (i, (sch, item)) in s.prefix_items.iter().zip(arr).enumerate() { + add_err!(self.validate_val(*sch, item, item!(i))); + } + + // items2020 -- + if let Some(sch) = &s.items2020 { + let evaluated = min(s.prefix_items.len(), len); + for (i, item) in arr[evaluated..].iter().enumerate() { + add_err!(self.validate_val(*sch, item, item!(i))); + } + debug_assert!(self.uneval.items.is_empty()); + } + } + + // contains -- + if let Some(sch) = &s.contains { + let mut matched = vec![]; + let mut errors = vec![]; + + for (i, item) in arr.iter().enumerate() { + if let Err(e) = self.validate_val(*sch, item, item!(i)) { + errors.push(e); + } else { + matched.push(i); + if s.draft_version >= 2020 { + self.uneval.items.remove(&i); + } + } + } + + // minContains -- + if let Some(min) = s.min_contains { + if matched.len() < min { + let mut e = self.error(kind!(MinContains, matched.clone(), min)); + e.causes = errors; + self.errors.push(e); + } + } else if matched.is_empty() { + let mut e = self.error(kind!(Contains)); + e.causes = errors; + self.errors.push(e); + } + + // maxContains -- + if let Some(max) = s.max_contains { + if matched.len() > max { + self.add_error(kind!(MaxContains, matched, max)); + } + } + } + } + + fn str_validate(&mut self, str: &'v String) { + let s = self.schema; + let mut len = None; + + // minLength -- + if let Some(min) = s.min_length { + let len = len.get_or_insert_with(|| str.chars().count()); + if *len < min { + self.add_error(kind!(MinLength, *len, min)); + } + } + + // maxLength -- + if let Some(max) = s.max_length { + let len = len.get_or_insert_with(|| str.chars().count()); + if *len > max { + self.add_error(kind!(MaxLength, *len, max)); + } + } + + // pattern -- + if let Some(regex) = &s.pattern { + if !regex.is_match(str) { + self.add_error(kind!(Pattern, str.into(), regex.as_str())); + } + } + + if s.draft_version == 6 { + return; + } + + // contentEncoding -- + let mut decoded = Some(Cow::from(str.as_bytes())); + if let Some(decoder) = &s.content_encoding { + match (decoder.func)(str) { + Ok(bytes) => decoded = Some(Cow::from(bytes)), + Err(err) => { + decoded = None; + self.add_error(ErrorKind::ContentEncoding { + want: decoder.name, + err, + }) + } + } + } + + // contentMediaType -- + let mut deserialized = None; + if let (Some(mt), Some(decoded)) = (&s.content_media_type, decoded) { + match (mt.func)(decoded.as_ref(), s.content_schema.is_some()) { + Ok(des) => deserialized = des, + Err(e) => { + self.add_error(kind!(ContentMediaType, decoded.into(), mt.name, e)); + } + } + } + + // contentSchema -- + if let (Some(sch), Some(v)) = (s.content_schema, deserialized) { + if let Err(mut e) = self.schemas.validate(&v, sch) { + e.schema_url = &s.loc; + e.kind = kind!(ContentSchema); + self.errors.push(e.clone_static()); + } + } + } + + fn num_validate(&mut self, num: &'v Number) { + let s = self.schema; + + // minimum -- + if let Some(min) = &s.minimum { + if let (Some(minf), Some(numf)) = (min.as_f64(), num.as_f64()) { + if numf < minf { + self.add_error(kind!(Minimum, Cow::Borrowed(num), min)); + } + } + } + + // maximum -- + if let Some(max) = &s.maximum { + if let (Some(maxf), Some(numf)) = (max.as_f64(), num.as_f64()) { + if numf > maxf { + self.add_error(kind!(Maximum, Cow::Borrowed(num), max)); + } + } + } + + // exclusiveMinimum -- + if let Some(ex_min) = &s.exclusive_minimum { + if let (Some(ex_minf), Some(numf)) = (ex_min.as_f64(), num.as_f64()) { + if numf <= ex_minf { + self.add_error(kind!(ExclusiveMinimum, Cow::Borrowed(num), ex_min)); + } + } + } + + // exclusiveMaximum -- + if let Some(ex_max) = &s.exclusive_maximum { + if let (Some(ex_maxf), Some(numf)) = (ex_max.as_f64(), num.as_f64()) { + if numf >= ex_maxf { + self.add_error(kind!(ExclusiveMaximum, Cow::Borrowed(num), ex_max)); + } + } + } + + // multipleOf -- + if let Some(mul) = &s.multiple_of { + if let (Some(mulf), Some(numf)) = (mul.as_f64(), num.as_f64()) { + if (numf / mulf).fract() != 0.0 { + self.add_error(kind!(MultipleOf, Cow::Borrowed(num), mul)); + } + } + } + } +} + +// references validation +impl<'v, 's> Validator<'v, 's, '_, '_> { + fn refs_validate(&mut self) { + let s = self.schema; + macro_rules! add_err { + ($result:expr) => { + if let Err(e) = $result { + self.errors.push(e); + } + }; + } + + // $recursiveRef -- + if let Some(mut sch) = s.recursive_ref { + if self.schemas.get(sch).recursive_anchor { + sch = self.resolve_recursive_anchor(sch); + } + add_err!(self.validate_ref(sch, "$recursiveRef")); + } + + // $dynamicRef -- + if let Some(dref) = &s.dynamic_ref { + let mut sch = dref.sch; // initial target + if let Some(anchor) = &dref.anchor { + // $dynamicRef includes anchor + if self.schemas.get(sch).dynamic_anchor == dref.anchor { + // initial target has matching $dynamicAnchor + sch = self.resolve_dynamic_anchor(anchor, sch); + } + } + add_err!(self.validate_ref(sch, "$dynamicRef")); + } + } + + fn validate_ref( + &mut self, + sch: SchemaIndex, + kw: &'static str, + ) -> Result<(), ValidationError<'s, 'v>> { + if let Err(err) = self._validate_self(sch, kw.into(), false) { + let url = &self.schemas.get(sch).loc; + let mut ref_err = self.error(ErrorKind::Reference { kw, url }); + if let ErrorKind::Group = err.kind { + ref_err.causes = err.causes; + } else { + ref_err.causes.push(err); + } + return Err(ref_err); + } + Ok(()) + } + + fn resolve_recursive_anchor(&self, fallback: SchemaIndex) -> SchemaIndex { + let mut sch = fallback; + let mut scope = &self.scope; + loop { + let scope_sch = self.schemas.get(scope.sch); + let base_sch = self.schemas.get(scope_sch.resource); + if base_sch.recursive_anchor { + sch = scope.sch + } + if let Some(parent) = scope.parent { + scope = parent; + } else { + return sch; + } + } + } + + fn resolve_dynamic_anchor(&self, name: &String, fallback: SchemaIndex) -> SchemaIndex { + let mut sch = fallback; + let mut scope = &self.scope; + loop { + let scope_sch = self.schemas.get(scope.sch); + let base_sch = self.schemas.get(scope_sch.resource); + debug_assert_eq!(base_sch.idx, base_sch.resource); + if let Some(dsch) = base_sch.dynamic_anchors.get(name) { + sch = *dsch + } + if let Some(parent) = scope.parent { + scope = parent; + } else { + return sch; + } + } + } +} + +// conditional validation +impl Validator<'_, '_, '_, '_> { + fn cond_validate(&mut self) { + let s = self.schema; + macro_rules! add_err { + ($result:expr) => { + if let Err(e) = $result { + self.errors.push(e); + } + }; + } + + // not -- + if let Some(not) = s.not { + if self._validate_self(not, None, true).is_ok() { + self.add_error(kind!(Not)); + } + } + + // allOf -- + if !s.all_of.is_empty() { + let mut errors = vec![]; + for sch in &s.all_of { + if let Err(e) = self.validate_self(*sch) { + errors.push(e); + if self.bool_result { + break; + } + } + } + if !errors.is_empty() { + self.add_errors(errors, kind!(AllOf)); + } + } + + // anyOf -- + if !s.any_of.is_empty() { + let mut matched = false; + let mut errors = vec![]; + for sch in &s.any_of { + match self.validate_self(*sch) { + Ok(_) => { + matched = true; + // for uneval, all schemas must be checked + if self.uneval.is_empty() { + break; + } + } + Err(e) => errors.push(e), + } + } + if !matched { + self.add_errors(errors, kind!(AnyOf)); + } + } + + // oneOf -- + if !s.one_of.is_empty() { + let mut matched = None; + let mut errors = vec![]; + for (i, sch) in s.one_of.iter().enumerate() { + if let Err(e) = self._validate_self(*sch, None, matched.is_some()) { + if matched.is_none() { + errors.push(e); + } + } else { + match matched { + None => _ = matched.replace(i), + Some(prev) => { + self.add_error(ErrorKind::OneOf(Some((prev, i)))); + break; + } + } + } + } + if matched.is_none() { + self.add_errors(errors, ErrorKind::OneOf(None)); + } + } + + // if, then, else -- + if let Some(if_) = s.if_ { + if self._validate_self(if_, None, true).is_ok() { + if let Some(then) = s.then { + add_err!(self.validate_self(then)); + } + } else if let Some(else_) = s.else_ { + add_err!(self.validate_self(else_)); + } + } + } +} + +// uneval validation +impl Validator<'_, '_, '_, '_> { + fn uneval_validate(&mut self) { + let s = self.schema; + let v = self.v; + macro_rules! add_err { + ($result:expr) => { + if let Err(e) = $result { + self.errors.push(e); + } + }; + } + + // unevaluatedProperties -- + if let (Some(sch), Value::Object(obj)) = (s.unevaluated_properties, v) { + let uneval = std::mem::take(&mut self.uneval); + for pname in &uneval.props { + if let Some(pvalue) = obj.get(*pname) { + add_err!(self.validate_val(sch, pvalue, prop!(pname))); + } + } + self.uneval.props.clear(); + } + + // unevaluatedItems -- + if let (Some(sch), Value::Array(arr)) = (s.unevaluated_items, v) { + let uneval = std::mem::take(&mut self.uneval); + for i in &uneval.items { + if let Some(pvalue) = arr.get(*i) { + add_err!(self.validate_val(sch, pvalue, item!(*i))); + } + } + self.uneval.items.clear(); + } + } +} + +// validation helpers +impl<'v, 's> Validator<'v, 's, '_, '_> { + fn validate_val( + &mut self, + sch: SchemaIndex, + v: &'v Value, + token: InstanceToken<'v>, + ) -> Result<(), ValidationError<'s, 'v>> { + if self.vloc.len() == self.scope.vid { + self.vloc.push(token); + } else { + self.vloc[self.scope.vid] = token; + } + let scope = self.scope.child(sch, None, self.scope.vid + 1); + let schema = &self.schemas.get(sch); + Validator { + v, + vloc: self.vloc, + schema, + schemas: self.schemas, + scope, + uneval: Uneval::from(v, schema, false), + errors: vec![], + bool_result: self.bool_result, + } + .validate() + .0 + } + + fn _validate_self( + &mut self, + sch: SchemaIndex, + ref_kw: Option<&'static str>, + bool_result: bool, + ) -> Result<(), ValidationError<'s, 'v>> { + let scope = self.scope.child(sch, ref_kw, self.scope.vid); + let schema = &self.schemas.get(sch); + let (result, reply) = Validator { + v: self.v, + vloc: self.vloc, + schema, + schemas: self.schemas, + scope, + uneval: Uneval::from(self.v, schema, !self.uneval.is_empty()), + errors: vec![], + bool_result: self.bool_result || bool_result, + } + .validate(); + self.uneval.merge(&reply); + result + } + + #[inline(always)] + fn validate_self(&mut self, sch: SchemaIndex) -> Result<(), ValidationError<'s, 'v>> { + self._validate_self(sch, None, false) + } +} + +// error helpers +impl<'v, 's> Validator<'v, 's, '_, '_> { + #[inline(always)] + fn error(&self, kind: ErrorKind<'s, 'v>) -> ValidationError<'s, 'v> { + if self.bool_result { + return ValidationError { + schema_url: &self.schema.loc, + instance_location: InstanceLocation::new(), + kind: ErrorKind::Group, + causes: vec![], + }; + } + ValidationError { + schema_url: &self.schema.loc, + instance_location: self.instance_location(), + kind, + causes: vec![], + } + } + + #[inline(always)] + fn add_error(&mut self, kind: ErrorKind<'s, 'v>) { + self.errors.push(self.error(kind)); + } + + #[inline(always)] + fn add_errors(&mut self, errors: Vec>, kind: ErrorKind<'s, 'v>) { + if errors.len() == 1 { + self.errors.extend(errors); + } else { + let mut err = self.error(kind); + err.causes = errors; + self.errors.push(err); + } + } + + fn kw_loc(&self, mut scope: &Scope) -> String { + let mut loc = String::new(); + while let Some(parent) = scope.parent { + if let Some(kw) = scope.ref_kw { + loc.insert_str(0, kw); + loc.insert(0, '/'); + } else { + let cur = &self.schemas.get(scope.sch).loc; + let parent = &self.schemas.get(parent.sch).loc; + loc.insert_str(0, &cur[parent.len()..]); + } + scope = parent; + } + loc + } + + fn find_missing( + &self, + obj: &'v Map, + required: &'s [String], + ) -> Option> { + let mut missing = required + .iter() + .filter(|p| !obj.contains_key(p.as_str())) + .map(|p| p.as_str()); + if self.bool_result { + missing.next().map(|_| Vec::new()) + } else { + let missing = missing.collect::>(); + if missing.is_empty() { + None + } else { + Some(missing) + } + } + } + + fn instance_location(&self) -> InstanceLocation<'v> { + let len = self.scope.vid; + let mut tokens = Vec::with_capacity(len); + for tok in &self.vloc[..len] { + tokens.push(tok.clone()); + } + InstanceLocation { tokens } + } +} + +// Uneval -- + +#[derive(Default)] +struct Uneval<'v> { + props: HashSet<&'v String>, + items: HashSet, +} + +impl<'v> Uneval<'v> { + fn is_empty(&self) -> bool { + self.props.is_empty() && self.items.is_empty() + } + + fn from(v: &'v Value, sch: &Schema, caller_needs: bool) -> Self { + let mut uneval = Self::default(); + match v { + Value::Object(obj) => { + if !sch.all_props_evaluated + && (caller_needs || sch.unevaluated_properties.is_some()) + { + uneval.props = obj.keys().collect(); + } + } + Value::Array(arr) => { + if !sch.all_items_evaluated + && (caller_needs || sch.unevaluated_items.is_some()) + && sch.num_items_evaluated < arr.len() + { + uneval.items = (sch.num_items_evaluated..arr.len()).collect(); + } + } + _ => (), + } + uneval + } + + fn merge(&mut self, other: &Uneval) { + self.props.retain(|p| other.props.contains(p)); + self.items.retain(|i| other.items.contains(i)); + } +} + +// Scope --- + +#[derive(Debug)] +struct Scope<'a> { + sch: SchemaIndex, + // if None, compute from self.sch and self.parent.sh + // not None only when there is jump i.e $ref, $XXXRef + ref_kw: Option<&'static str>, + /// unique id of value being validated + // if two scope validate same value, they will have same vid + vid: usize, + parent: Option<&'a Scope<'a>>, +} + +impl Scope<'_> { + fn child<'x>( + &'x self, + sch: SchemaIndex, + ref_kw: Option<&'static str>, + vid: usize, + ) -> Scope<'x> { + Scope { + sch, + ref_kw, + vid, + parent: Some(self), + } + } + + fn check_cycle(&self) -> Option<&Scope<'_>> { + let mut scope = self.parent; + while let Some(scp) = scope { + if scp.vid != self.vid { + break; + } + if scp.sch == self.sch { + return Some(scp); + } + scope = scp.parent; + } + None + } +} + +/// Token in InstanceLocation json-pointer. +#[derive(Debug, Clone)] +pub enum InstanceToken<'v> { + /// Token for property. + Prop(Cow<'v, str>), + /// Token for array item. + Item(usize), +} + +impl From for InstanceToken<'_> { + fn from(prop: String) -> Self { + InstanceToken::Prop(prop.into()) + } +} + +impl<'v> From<&'v str> for InstanceToken<'v> { + fn from(prop: &'v str) -> Self { + InstanceToken::Prop(prop.into()) + } +} + +impl From for InstanceToken<'_> { + fn from(index: usize) -> Self { + InstanceToken::Item(index) + } +} + +/// The location of the JSON value within the instance being validated +#[derive(Debug, Default)] +pub struct InstanceLocation<'v> { + pub tokens: Vec>, +} + +impl InstanceLocation<'_> { + fn new() -> Self { + Self::default() + } + + fn clone_static(self) -> InstanceLocation<'static> { + let mut tokens = Vec::with_capacity(self.tokens.len()); + for tok in self.tokens { + let tok = match tok { + InstanceToken::Prop(p) => InstanceToken::Prop(p.into_owned().into()), + InstanceToken::Item(i) => InstanceToken::Item(i), + }; + tokens.push(tok); + } + InstanceLocation { tokens } + } +} + +impl Display for InstanceLocation<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for tok in &self.tokens { + f.write_char('/')?; + match tok { + InstanceToken::Prop(s) => f.write_str(&escape(s))?, + InstanceToken::Item(i) => write!(f, "{i}")?, + } + } + Ok(()) + } +} + +impl<'s> ValidationError<'s, '_> { + pub(crate) fn clone_static(self) -> ValidationError<'s, 'static> { + let mut causes = Vec::with_capacity(self.causes.len()); + for cause in self.causes { + causes.push(cause.clone_static()); + } + ValidationError { + instance_location: self.instance_location.clone_static(), + kind: self.kind.clone_static(), + causes, + ..self + } + } +} + +impl<'s> ErrorKind<'s, '_> { + fn clone_static(self) -> ErrorKind<'s, 'static> { + use ErrorKind::*; + match self { + AdditionalProperties { got } => AdditionalProperties { + got: got.into_iter().map(|e| e.into_owned().into()).collect(), + }, + Format { got, want, err } => Format { + got: Cow::Owned(got.into_owned()), + want, + err, + }, + Pattern { got, want } => Pattern { + got: got.into_owned().into(), + want, + }, + Minimum { got, want } => Minimum { + got: Cow::Owned(got.into_owned()), + want, + }, + Maximum { got, want } => Maximum { + got: Cow::Owned(got.into_owned()), + want, + }, + ExclusiveMinimum { got, want } => ExclusiveMinimum { + got: Cow::Owned(got.into_owned()), + want, + }, + ExclusiveMaximum { got, want } => ExclusiveMaximum { + got: Cow::Owned(got.into_owned()), + want, + }, + MultipleOf { got, want } => MultipleOf { + got: Cow::Owned(got.into_owned()), + want, + }, + // #[cfg(not(debug_assertions))] + // _ => unsafe { std::mem::transmute(self) }, + Group => Group, + Schema { url } => Schema { url }, + ContentSchema => ContentSchema, + PropertyName { prop } => PropertyName { prop }, + Reference { kw, url } => Reference { kw, url }, + RefCycle { + url, + kw_loc1, + kw_loc2, + } => RefCycle { + url, + kw_loc1, + kw_loc2, + }, + FalseSchema => FalseSchema, + Type { got, want } => Type { got, want }, + Enum { want } => Enum { want }, + Const { want } => Const { want }, + MinProperties { got, want } => MinProperties { got, want }, + MaxProperties { got, want } => MaxProperties { got, want }, + Required { want } => Required { want }, + Dependency { prop, missing } => Dependency { prop, missing }, + DependentRequired { prop, missing } => DependentRequired { prop, missing }, + MinItems { got, want } => MinItems { got, want }, + MaxItems { got, want } => MaxItems { got, want }, + Contains => Contains, + MinContains { got, want } => MinContains { got, want }, + MaxContains { got, want } => MaxContains { got, want }, + UniqueItems { got } => UniqueItems { got }, + AdditionalItems { got } => AdditionalItems { got }, + MinLength { got, want } => MinLength { got, want }, + MaxLength { got, want } => MaxLength { got, want }, + ContentEncoding { want, err } => ContentEncoding { want, err }, + ContentMediaType { got, want, err } => ContentMediaType { got, want, err }, + Not => Not, + AllOf => AllOf, + AnyOf => AnyOf, + OneOf(opt) => OneOf(opt), + } + } +} diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/const.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/const.json new file mode 100644 index 0000000..59095d8 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/const.json @@ -0,0 +1,21 @@ +[ + { + "description": "zero fraction", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "const": 2 + }, + "tests": [ + { + "description": "with fraction", + "data": 2.0, + "valid": true + }, + { + "description": "without fraction", + "data": 2, + "valid": true + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/infinite-loop-detection.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/infinite-loop-detection.json new file mode 100644 index 0000000..b4462e5 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/infinite-loop-detection.json @@ -0,0 +1,26 @@ +[ + { + "description": "guard against infinite recursion", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "alice": { + "$anchor": "alice", + "allOf": [{"$ref": "#bob"}] + }, + "bob": { + "$anchor": "bob", + "allOf": [{"$ref": "#alice"}] + } + }, + "$ref": "#alice" + }, + "tests": [ + { + "description": "infinite recursion detected", + "data": {}, + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/contentSchema.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/contentSchema.json new file mode 100644 index 0000000..7f78015 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/contentSchema.json @@ -0,0 +1,143 @@ +[ + { + "description": "validation of binary-encoded media type documents with schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentMediaType": "application/json", + "contentEncoding": "base64", + "contentSchema": { "required": ["foo"], "properties": { "foo": { "type": "string" } } } + }, + "tests": [ + { + "description": "a valid base64-encoded JSON document", + "data": "eyJmb28iOiAiYmFyIn0K", + "valid": true + }, + { + "description": "another valid base64-encoded JSON document", + "data": "eyJib28iOiAyMCwgImZvbyI6ICJiYXoifQ==", + "valid": true + }, + { + "description": "an invalid base64-encoded JSON document; validates false", + "data": "eyJib28iOiAyMH0=", + "valid": false + }, + { + "description": "an empty object as a base64-encoded JSON document; validates false", + "data": "e30=", + "valid": false + }, + { + "description": "an empty array as a base64-encoded JSON document", + "data": "W10=", + "valid": true + }, + { + "description": "a validly-encoded invalid JSON document; validates false", + "data": "ezp9Cg==", + "valid": false + }, + { + "description": "an invalid base64 string that is valid JSON; validates false", + "data": "{}", + "valid": false + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, + { + "description": "contentSchema without contentMediaType", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentEncoding": "base64", + "contentSchema": { "required": ["foo"], "properties": { "foo": { "type": "string" } } } + }, + "tests": [ + { + "description": "a valid base64-encoded JSON document", + "data": "eyJmb28iOiAiYmFyIn0K", + "valid": true + }, + { + "description": "another valid base64-encoded JSON document", + "data": "eyJib28iOiAyMCwgImZvbyI6ICJiYXoifQ==", + "valid": true + }, + { + "description": "an invalid base64-encoded JSON document; validates true", + "data": "eyJib28iOiAyMH0=", + "valid": true + }, + { + "description": "an empty object as a base64-encoded JSON document; validates true", + "data": "e30=", + "valid": true + }, + { + "description": "an empty array as a base64-encoded JSON document", + "data": "W10=", + "valid": true + }, + { + "description": "a validly-encoded invalid JSON document; validates true", + "data": "ezp9Cg==", + "valid": true + }, + { + "description": "an invalid base64 string that is valid JSON; validates false", + "data": "{}", + "valid": false + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, + { + "description": "contentSchema without contentEncoding", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentMediaType": "application/json", + "contentSchema": { "required": ["foo"], "properties": { "foo": { "type": "string" } } } + }, + "tests": [ + { + "description": "a valid JSON document", + "data": "{\"foo\": \"bar\"}", + "valid": true + }, + { + "description": "another valid base64-encoded JSON document", + "data": "{\"boo\": 20, \"foo\": \"baz\"}", + "valid": true + }, + { + "description": "an empty object; validates false", + "data": "{}", + "valid": false + }, + { + "description": "an empty array; validates false", + "data": "[]", + "valid": true + }, + { + "description": "invalid JSON document; validates false", + "data": "[}", + "valid": false + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/date.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/date.json new file mode 100644 index 0000000..fe8dad3 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/date.json @@ -0,0 +1,16 @@ +[ + { + "description": "validation of date strings", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "format": "date" + }, + "tests": [ + { + "description": "contains alphabets", + "data": "yyyy-mm-dd", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/duration.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/duration.json new file mode 100644 index 0000000..5e4f712 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/duration.json @@ -0,0 +1,16 @@ +[ + { + "description": "validation of duration strings", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "format": "duration" + }, + "tests": [ + { + "description": "more than one T", + "data": "PT1MT1S", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/email.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/email.json new file mode 100644 index 0000000..a287919 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/email.json @@ -0,0 +1,31 @@ +[ + { + "description": "validation of duration strings", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "format": "email" + }, + "tests": [ + { + "description": "non printable character", + "data": "a\tb@gmail.com", + "valid": false + }, + { + "description": "tab ok if quoted", + "data": "\"a\tb\"@gmail.com", + "valid": true + }, + { + "description": "quote inside quoted", + "data": "\"a\"b\"@gmail.com", + "valid": false + }, + { + "description": "backslash inside quoted", + "data": "\"a\\b\"@gmail.com", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/time.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/time.json new file mode 100644 index 0000000..4bce03e --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/optional/format/time.json @@ -0,0 +1,23 @@ +[ + { + "description": "validation of time strings", + "schema": { "format": "time" }, + "tests": [ + { + "description": "contains alphabets", + "data": "ab:cd:efZ", + "valid": false + }, + { + "description": "no digit in second fraction", + "data": "23:20:50.Z", + "valid": false + }, + { + "description": "alphabets in offset", + "data": "08:30:06+ab:cd", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/properties.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/properties.json new file mode 100644 index 0000000..39d1bfd --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/properties.json @@ -0,0 +1,26 @@ +[ + { + "description": "special characters", + "schema": { + "properties": { + "a%20b/c": { "type": "number" } + } + }, + "tests": [ + { + "description": "valid", + "data": { + "a%20b/c": 1 + }, + "valid": true + }, + { + "description": "invalid", + "data": { + "a%20b/c": "hello" + }, + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/ref.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/ref.json new file mode 100644 index 0000000..7490401 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/ref.json @@ -0,0 +1,74 @@ +[ + { + "description": "percent-encoded json-pointer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a b": {"type": "number"} + }, + "$ref": "#/$defs/a%20b" + }, + "tests": [ + { + "description": "match", + "data": 1, + "valid": true + }, + { + "description": "mismatch", + "data": "foobar", + "valid": false + } + ] + }, + { + "description": "precent in resource ptr", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a%20b": { + "$id": "http://temp.com/ab", + "type": "number" + } + }, + "$ref": "http://temp.com/ab" + }, + "tests": [ + { + "description": "match", + "data": 1, + "valid": true + }, + { + "description": "mismatch", + "data": "foobar", + "valid": false + } + ] + }, + { + "description": "precent in anchor ptr", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a%20b": { + "$anchor": "abcd", + "type": "number" + } + }, + "$ref": "#abcd" + }, + "tests": [ + { + "description": "match", + "data": 1, + "valid": true + }, + { + "description": "mismatch", + "data": "foobar", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/unevaluatedProperties.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/unevaluatedProperties.json new file mode 100644 index 0000000..1de6e55 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/unevaluatedProperties.json @@ -0,0 +1,57 @@ +[ + { + "description": "unevaluatedProperties with a failing $ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "child": { + "type": "object", + "properties": { + "prop2": { "type": "string" } + }, + "unevaluatedProperties": false + } + }, + "type": "object", + "properties": { + "prop1": { "type": "string" }, + "child_schema": { "$ref": "#/$defs/child" } + }, + "unevaluatedProperties": false + }, + "tests": [ + { + "description": "unevaluated property in child should fail validation", + "data": { + "prop1": "value1", + "child_schema": { + "prop2": "value2", + "extra_prop_in_child": "this should fail" + } + }, + "valid": false + }, + { + "description": "a valid instance should pass", + "data": { + "prop1": "value1", + "child_schema": { + "prop2": "value2" + } + }, + "valid": true + }, + { + "description": "unevaluated property in parent should fail", + "data": { + "prop1": "value1", + "child_schema": { + "prop2": "value2" + }, + "extra_prop_in_parent": "this should fail" + }, + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft2020-12/uniqueItems.json b/validator/tests/Extra-Test-Suite/tests/draft2020-12/uniqueItems.json new file mode 100644 index 0000000..ad23ed4 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft2020-12/uniqueItems.json @@ -0,0 +1,21 @@ +[ + { + "description": "zero fraction", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "uniqueItems": true + }, + "tests": [ + { + "description": "with fraction", + "data": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, -1, -2, -3, -4, -5, -6, -7, -8, -9, -10, 2.0], + "valid": false + }, + { + "description": "without fraction", + "data": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, -1, -2, -3, -4, -5, -6, -7, -8, -9, -10, 2], + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft4/dependencies.json b/validator/tests/Extra-Test-Suite/tests/draft4/dependencies.json new file mode 100644 index 0000000..84db592 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft4/dependencies.json @@ -0,0 +1,27 @@ +[ + { + "description": "percent in dependencies", + "schema": { + "dependencies": { + "a%20b": { "required": ["x"] } + } + }, + "tests": [ + { + "description": "valid", + "data": { + "a%20b": null, + "x": 1 + }, + "valid": true + }, + { + "description": "invalid", + "data": { + "a%20b": null + }, + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft7/if-then-else.json b/validator/tests/Extra-Test-Suite/tests/draft7/if-then-else.json new file mode 100644 index 0000000..8d21721 --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft7/if-then-else.json @@ -0,0 +1,50 @@ +[ + { + "description": "skip then when if is false", + "schema": { + "if": false, + "then": { + "$ref": "blah/blah.json" + }, + "else": { + "type": "number" + } + }, + "tests": [ + { + "description": "number is valid", + "data": 0, + "valid": true + }, + { + "description": "string is invalid", + "data": "hello", + "valid": false + } + ] + }, + { + "description": "skip else when if is true", + "schema": { + "if": true, + "then": { + "type": "number" + }, + "else": { + "$ref": "blah/blah.json" + } + }, + "tests": [ + { + "description": "number is valid", + "data": 0, + "valid": true + }, + { + "description": "string is invalid", + "data": "hello", + "valid": false + } + ] + } +] diff --git a/validator/tests/Extra-Test-Suite/tests/draft7/optional/format/period.json b/validator/tests/Extra-Test-Suite/tests/draft7/optional/format/period.json new file mode 100644 index 0000000..b007dbe --- /dev/null +++ b/validator/tests/Extra-Test-Suite/tests/draft7/optional/format/period.json @@ -0,0 +1,98 @@ +[ + { + "description": "validation of period", + "schema": { "format": "period" }, + "tests": [ + { + "description": "all string formats ignore integers", + "data": 12, + "valid": true + }, + { + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "both-explicit", + "data": "1963-06-19T08:30:06Z/1963-06-19T08:30:07Z", + "valid": true + }, + { + "description": "start-explicit", + "data": "1963-06-19T08:30:06Z/P4DT12H30M5S", + "valid": true + }, + { + "description": "end-explicit", + "data": "P4DT12H30M5S/1963-06-19T08:30:06Z", + "valid": true + }, + { + "description": "none-explicit", + "data": "P4DT12H30M5S/P4DT12H30M5S", + "valid": false + }, + { + "description": "just date", + "data": "1963-06-19T08:30:06Z", + "valid": false + }, + { + "description": "just duration", + "data": "P4DT12H30M5S", + "valid": false + }, + { + "description": "more than two", + "data": "1963-06-19T08:30:06Z/1963-06-19T08:30:07Z/1963-06-19T08:30:07Z", + "valid": false + }, + { + "description": "separated by space", + "data": "1963-06-19T08:30:06Z 1963-06-19T08:30:07Z", + "valid": false + }, + { + "description": "separated by hyphen", + "data": "1963-06-19T08:30:06Z-1963-06-19T08:30:07Z", + "valid": false + }, + { + "description": "invalid components", + "data": "foo/bar", + "valid": false + }, + { + "description": "emtpy components", + "data": "/", + "valid": false + }, + { + "description": "empty string", + "data": "", + "valid": false + } + ] + } +] \ No newline at end of file diff --git a/validator/tests/compiler.rs b/validator/tests/compiler.rs new file mode 100644 index 0000000..d657a93 --- /dev/null +++ b/validator/tests/compiler.rs @@ -0,0 +1,87 @@ +use std::error::Error; + +use boon::{Compiler, Schemas}; +use serde_json::json; + +#[test] +fn test_metaschema_resource() -> Result<(), Box> { + let main_schema = json!({ + "$schema": "http://tmp.com/meta.json", + "type": "number" + }); + let meta_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/meta/applicator" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/core" } + ] + }); + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.add_resource("schema.json", main_schema)?; + compiler.add_resource("http://tmp.com/meta.json", meta_schema)?; + compiler.compile("schema.json", &mut schemas)?; + + Ok(()) +} + +#[test] +fn test_compile_anchor() -> Result<(), Box> { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "x": { + "$anchor": "a1", + "type": "number" + } + } + }); + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.add_resource("schema.json", schema)?; + let sch_index1 = compiler.compile("schema.json#a1", &mut schemas)?; + let sch_index2 = compiler.compile("schema.json#/$defs/x", &mut schemas)?; + assert_eq!(sch_index1, sch_index2); + + Ok(()) +} + +#[test] +fn test_compile_nonstd() -> Result<(), Box> { + let schema = json!({ + "components": { + "schemas": { + "foo" : { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "x": { + "$anchor": "a", + "type": "number" + }, + "y": { + "$id": "http://temp.com/y", + "type": "string" + } + }, + "oneOf": [ + { "$ref": "#a" }, + { "$ref": "http://temp.com/y" } + ] + } + } + } + }); + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.add_resource("schema.json", schema)?; + compiler.compile("schema.json#/components/schemas/foo", &mut schemas)?; + + Ok(()) +} diff --git a/validator/tests/debug.json b/validator/tests/debug.json new file mode 100644 index 0000000..bda5052 --- /dev/null +++ b/validator/tests/debug.json @@ -0,0 +1,33 @@ +{ + "remotes": { + "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "refToInteger": { + "$ref": "#foo" + }, + "A": { + "$anchor": "foo", + "type": "integer" + } + } + } + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a": { + "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#foo" + }, + "b": { + "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#foo" + } + } + }, + "data": { + "a": 1, + "b": "hello" + }, + "valid": false +} diff --git a/validator/tests/debug.rs b/validator/tests/debug.rs new file mode 100644 index 0000000..32b9914 --- /dev/null +++ b/validator/tests/debug.rs @@ -0,0 +1,41 @@ +use std::{error::Error, fs::File}; + +use boon::{Compiler, Schemas, UrlLoader}; +use serde_json::{Map, Value}; + +#[test] +fn test_debug() -> Result<(), Box> { + let test: Value = serde_json::from_reader(File::open("tests/debug.json")?)?; + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_format_assertions(); + compiler.enable_content_assertions(); + let remotes = Remotes(test["remotes"].as_object().unwrap().clone()); + compiler.use_loader(Box::new(remotes)); + let url = "http://debug.com/schema.json"; + compiler.add_resource(url, test["schema"].clone())?; + let sch = compiler.compile(url, &mut schemas)?; + let result = schemas.validate(&test["data"], sch); + if let Err(e) = &result { + for line in format!("{e}").lines() { + println!(" {line}"); + } + for line in format!("{e:#}").lines() { + println!(" {line}"); + } + println!("{:#}", e.detailed_output()); + } + assert_eq!(result.is_ok(), test["valid"].as_bool().unwrap()); + Ok(()) +} + +struct Remotes(Map); + +impl UrlLoader for Remotes { + fn load(&self, url: &str) -> Result> { + if let Some(v) = self.0.get(url) { + return Ok(v.clone()); + } + Err("remote not found")? + } +} diff --git a/validator/tests/examples.rs b/validator/tests/examples.rs new file mode 100644 index 0000000..5c9eab8 --- /dev/null +++ b/validator/tests/examples.rs @@ -0,0 +1,230 @@ +use std::{error::Error, fs::File}; + +use boon::{Compiler, Decoder, FileLoader, Format, MediaType, Schemas, SchemeUrlLoader, UrlLoader}; +use serde::de::IgnoredAny; +use serde_json::{json, Value}; +use url::Url; + +#[test] +fn example_from_files() -> Result<(), Box> { + let schema_file = "tests/examples/schema.json"; + let instance: Value = serde_json::from_reader(File::open("tests/examples/instance.json")?)?; + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + let sch_index = compiler.compile(schema_file, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} + +/** +This example shows how to load json schema from strings. + +The schema url used plays important role in resolving +schema references. + +You can see that `cat.json` is resolved internally to +another string schema where as dog.json is resolved +to local file. +*/ +#[test] +fn example_from_strings() -> Result<(), Box> { + let cat_schema: Value = json!({ + "type": "object", + "properties": { + "speak": { "const": "meow" } + }, + "required": ["speak"] + }); + let pet_schema: Value = json!({ + "oneOf": [ + { "$ref": "dog.json" }, + { "$ref": "cat.json" } + ] + }); + let instance: Value = json!({"speak": "bow"}); + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.add_resource("tests/examples/pet.json", pet_schema)?; + compiler.add_resource("tests/examples/cat.json", cat_schema)?; + let sch_index = compiler.compile("tests/examples/pet.json", &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} + +#[test] +#[ignore] +fn example_from_https() -> Result<(), Box> { + let schema_url = "https://json-schema.org/learn/examples/geographical-location.schema.json"; + let instance: Value = json!({"latitude": 48.858093, "longitude": 2.294694}); + + struct HttpUrlLoader; + impl UrlLoader for HttpUrlLoader { + fn load(&self, url: &str) -> Result> { + let reader = ureq::get(url).call()?.into_reader(); + Ok(serde_json::from_reader(reader)?) + } + } + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + let mut loader = SchemeUrlLoader::new(); + loader.register("file", Box::new(FileLoader)); + loader.register("http", Box::new(HttpUrlLoader)); + loader.register("https", Box::new(HttpUrlLoader)); + compiler.use_loader(Box::new(loader)); + let sch_index = compiler.compile(schema_url, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} + +#[test] +fn example_from_yaml_files() -> Result<(), Box> { + let schema_file = "tests/examples/schema.yml"; + let instance: Value = serde_yaml::from_reader(File::open("tests/examples/instance.yml")?)?; + + struct FileUrlLoader; + impl UrlLoader for FileUrlLoader { + fn load(&self, url: &str) -> Result> { + let url = Url::parse(url)?; + let path = url.to_file_path().map_err(|_| "invalid file path")?; + let file = File::open(&path)?; + if path + .extension() + .filter(|&ext| ext == "yaml" || ext == "yml") + .is_some() + { + Ok(serde_yaml::from_reader(file)?) + } else { + Ok(serde_json::from_reader(file)?) + } + } + } + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + let mut loader = SchemeUrlLoader::new(); + loader.register("file", Box::new(FileUrlLoader)); + compiler.use_loader(Box::new(loader)); + let sch_index = compiler.compile(schema_file, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} + +#[test] +fn example_custom_format() -> Result<(), Box> { + let schema_url = "http://tmp/schema.json"; + let schema: Value = json!({"type": "string", "format": "palindrome"}); + let instance: Value = json!("step on no pets"); + + fn is_palindrome(v: &Value) -> Result<(), Box> { + let Value::String(s) = v else { + return Ok(()); // applicable only on strings + }; + let mut chars = s.chars(); + while let (Some(c1), Some(c2)) = (chars.next(), chars.next_back()) { + if c1 != c2 { + Err("char mismatch")?; + } + } + Ok(()) + } + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_format_assertions(); // in draft2020-12 format assertions are not enabled by default + compiler.register_format(Format { + name: "palindrome", + func: is_palindrome, + }); + compiler.add_resource(schema_url, schema)?; + let sch_index = compiler.compile(schema_url, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} + +#[test] +fn example_custom_content_encoding() -> Result<(), Box> { + let schema_url = "http://tmp/schema.json"; + let schema: Value = json!({"type": "string", "contentEncoding": "hex"}); + let instance: Value = json!("aBcdxyz"); + + fn decode(b: u8) -> Result> { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err("decode_hex: non-hex char")?, + } + } + fn decode_hex(s: &str) -> Result, Box> { + if s.len() % 2 != 0 { + Err("decode_hex: odd length")?; + } + let mut bytes = s.bytes(); + let mut out = Vec::with_capacity(s.len() / 2); + for _ in 0..out.len() { + if let (Some(b1), Some(b2)) = (bytes.next(), bytes.next()) { + out.push(decode(b1)? << 4 | decode(b2)?); + } else { + Err("decode_hex: non-ascii char")?; + } + } + Ok(out) + } + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_content_assertions(); // content assertions are not enabled by default + compiler.register_content_encoding(Decoder { + name: "hex", + func: decode_hex, + }); + compiler.add_resource(schema_url, schema)?; + let sch_index = compiler.compile(schema_url, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_err()); + + Ok(()) +} + +#[test] +fn example_custom_content_media_type() -> Result<(), Box> { + let schema_url = "http://tmp/schema.json"; + let schema: Value = json!({"type": "string", "contentMediaType": "application/yaml"}); + let instance: Value = json!("name:foobar"); + + fn check_yaml(bytes: &[u8], deserialize: bool) -> Result, Box> { + if deserialize { + return Ok(Some(serde_yaml::from_slice(bytes)?)); + } + serde_yaml::from_slice::(bytes)?; + Ok(None) + } + + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.enable_content_assertions(); // content assertions are not enabled by default + compiler.register_content_media_type(MediaType { + name: "application/yaml", + json_compatible: true, + func: check_yaml, + }); + compiler.add_resource(schema_url, schema)?; + let sch_index = compiler.compile(schema_url, &mut schemas)?; + let result = schemas.validate(&instance, sch_index); + assert!(result.is_ok()); + + Ok(()) +} diff --git a/validator/tests/examples/dog.json b/validator/tests/examples/dog.json new file mode 100644 index 0000000..ed825d0 --- /dev/null +++ b/validator/tests/examples/dog.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "properties": { + "speak": { "const": "bow" } + }, + "required": ["speak"] +} diff --git a/validator/tests/examples/instance.json b/validator/tests/examples/instance.json new file mode 100644 index 0000000..2bff442 --- /dev/null +++ b/validator/tests/examples/instance.json @@ -0,0 +1,4 @@ +{ + "firstName": "Santhosh Kumar", + "lastName": "Tekuri" +} diff --git a/validator/tests/examples/instance.yml b/validator/tests/examples/instance.yml new file mode 100644 index 0000000..c242f6b --- /dev/null +++ b/validator/tests/examples/instance.yml @@ -0,0 +1,2 @@ +firstName: Santhosh Kumar +lastName: Tekuri diff --git a/validator/tests/examples/sample schema.json b/validator/tests/examples/sample schema.json new file mode 100644 index 0000000..cbebdb4 --- /dev/null +++ b/validator/tests/examples/sample schema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "required": ["firstName", "lastName"] +} diff --git a/validator/tests/examples/schema.json b/validator/tests/examples/schema.json new file mode 100644 index 0000000..cbebdb4 --- /dev/null +++ b/validator/tests/examples/schema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "required": ["firstName", "lastName"] +} diff --git a/validator/tests/examples/schema.yml b/validator/tests/examples/schema.yml new file mode 100644 index 0000000..cf73429 --- /dev/null +++ b/validator/tests/examples/schema.yml @@ -0,0 +1,9 @@ +type: object +properties: + firstName: + type: string + lastName: + type: string +required: +- firstName +- lastName diff --git a/validator/tests/filepaths.rs b/validator/tests/filepaths.rs new file mode 100644 index 0000000..2e9a375 --- /dev/null +++ b/validator/tests/filepaths.rs @@ -0,0 +1,44 @@ +use std::fs; + +use boon::{CompileError, Compiler, Schemas}; + +fn test(path: &str) -> Result<(), CompileError> { + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.compile(path, &mut schemas)?; + Ok(()) +} + +#[test] +fn test_absolute() -> Result<(), CompileError> { + let path = fs::canonicalize("tests/examples/schema.json").unwrap(); + test(path.to_string_lossy().as_ref()) +} + +#[test] +fn test_relative_slash() -> Result<(), CompileError> { + test("tests/examples/schema.json") +} + +#[test] +#[cfg(windows)] +fn test_relative_backslash() -> Result<(), CompileError> { + test("tests\\examples\\schema.json") +} + +#[test] +fn test_absolutei_space() -> Result<(), CompileError> { + let path = fs::canonicalize("tests/examples/sample schema.json").unwrap(); + test(path.to_string_lossy().as_ref()) +} + +#[test] +fn test_relative_slash_space() -> Result<(), CompileError> { + test("tests/examples/sample schema.json") +} + +#[test] +#[cfg(windows)] +fn test_relative_backslash_space() -> Result<(), CompileError> { + test("tests\\examples\\sample schema.json") +} diff --git a/validator/tests/invalid-schemas.json b/validator/tests/invalid-schemas.json new file mode 100644 index 0000000..a63120f --- /dev/null +++ b/validator/tests/invalid-schemas.json @@ -0,0 +1,244 @@ +[ + { + "description": "InvalidJsonPointer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/a~0b~~cd" + }, + "errors": [ + "InvalidJsonPointer(\"http://fake.com/schema.json#/a~0b~~cd\")" + ] + }, + { + "description": "UnsupportedUrlScheme", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "ftp://mars.com/schema.json" + }, + "errors": [ + "UnsupportedUrlScheme { url: \"ftp://mars.com/schema.json\" }" + ] + }, + { + "description": "ValidationError", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "^(abc]": { + "type": "string" + } + } + }, + "errors": [ + "ValidationError { url: \"http://fake.com/schema.json#\"" + ] + }, + { + "description": "ValidationError-nonsubschema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "dummy": { + "type": 1 + }, + "$ref": "#/dummy" + }, + "errors": [ + "ValidationError { url: \"http://fake.com/schema.json#/dummy\"" + ] + }, + { + "description": "JsonPointerNotFound-obj", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/something" + }, + "errors": [ + "JsonPointerNotFound(\"http://fake.com/schema.json#/$defs/something\")" + ] + }, + { + "description": "JsonPointerNotFound-arr-pos", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/dummy/0", + "dummy": [] + }, + "errors": [ + "JsonPointerNotFound(\"http://fake.com/schema.json#/dummy/0\")" + ] + }, + { + "description": "JsonPointerNotFound-arr-neg", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/dummy/-1", + "dummy": [] + }, + "errors": [ + "JsonPointerNotFound(\"http://fake.com/schema.json#/dummy/-1\")" + ] + }, + { + "description": "JsonPointerNotFound-primitive", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$schema/something" + }, + "errors": [ + "JsonPointerNotFound(\"http://fake.com/schema.json#/$schema/something\")" + ] + }, + { + "description": "InvalidRegex", + "schema": { + "$schema": "https://json-schema.org/draft-04/schema", + "patternProperties": { + "^(abc]": { + "type": "string" + } + } + }, + "errors": [ + "InvalidRegex { url: \"http://fake.com/schema.json#/patternProperties\", regex: \"^(abc]\", " + ] + }, + { + "description": "DuplicateId", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a": { + "$id": "http://a.com/b", + "$defs": { + "b": { + "$id": "a.json" + }, + "c": { + "$id": "a.json" + } + } + } + } + }, + "errors": [ + "DuplicateId { url: \"http://fake.com/schema.json\", id: \"http://a.com/a.json\", ", + "\"/$defs/a/$defs/b\"", + "\"/$defs/a/$defs/c\"" + ] + }, + { + "description": "DuplicateAnchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a": { + "$id": "http://a.com/b", + "$defs": { + "b": { + "$anchor": "a1" + }, + "c": { + "$anchor": "a1" + } + } + } + } + }, + "errors": [ + "DuplicateAnchor { anchor: \"a1\", url: \"http://fake.com/schema.json\", ", + "\"/$defs/a/$defs/b\"", + "\"/$defs/a/$defs/c\"" + ] + }, + { + "description": "UnsupportedDraft", + "remotes": { + "http://remotes/a.json": { + "$schema": "http://remotes/b.json" + }, + "http://remotes/b.json": { + "$schema": "http://remotes/b.json" + } + }, + "schema": { + "$schema": "http://remotes/a.json" + }, + "errors": [ + "UnsupportedDraft { url: \"http://remotes/b.json\" }" + ] + }, + { + "description": "MetaSchemaCycle", + "remotes": { + "http://remotes/a.json": { + "$schema": "http://remotes/b.json" + }, + "http://remotes/b.json": { + "$schema": "http://remotes/a.json" + } + }, + "schema": { + "$schema": "http://remotes/a.json" + }, + "errors": [ + "MetaSchemaCycle { url: \"http://remotes/a.json\" }" + ] + }, + { + "description": "AnchorNotFound-local", + "schema": { + "$ref": "sample.json#abcd", + "$defs": { + "a": { + "$id": "sample.json" + } + } + }, + "errors": [ + "AnchorNotFound { url: \"http://fake.com/schema.json\", reference: \"http://fake.com/sample.json#abcd\" }" + ] + }, + { + "description": "AnchorNotFound-remote", + "remotes": { + "http://remotes/a.json": {} + }, + "schema": { + "$ref": "http://remotes/a.json#abcd" + }, + "errors": [ + "AnchorNotFound { url: \"http://remotes/a.json\", reference: \"http://remotes/a.json#abcd\" }" + ] + }, + { + "description": "UnsupportedVocabulary-required", + "remotes": { + "http://remotes/a.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/format": true + } + } + }, + "schema": { + "$schema": "http://remotes/a.json" + }, + "errors": [ + "UnsupportedVocabulary { url: \"http://remotes/a.json\", vocabulary: \"https://json-schema.org/draft/2019-09/vocab/format\" }" + ] + }, + { + "description": "UnsupportedVocabulary-optioanl", + "remotes": { + "http://remotes/a.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema#", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/format": false + } + } + }, + "schema": { + "$schema": "http://remotes/a.json" + } + } +] diff --git a/validator/tests/invalid-schemas.rs b/validator/tests/invalid-schemas.rs new file mode 100644 index 0000000..590d065 --- /dev/null +++ b/validator/tests/invalid-schemas.rs @@ -0,0 +1,67 @@ +use std::{collections::HashMap, error::Error, fs::File}; + +use boon::{CompileError, Compiler, Schemas, UrlLoader}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +struct Test { + description: String, + remotes: Option>, + schema: Value, + errors: Option>, +} + +#[test] +fn test_invalid_schemas() -> Result<(), Box> { + let file = File::open("tests/invalid-schemas.json")?; + let tests: Vec = serde_json::from_reader(file)?; + for test in tests { + println!("{}", test.description); + match compile(&test) { + Ok(_) => { + if test.errors.is_some() { + Err("want compilation to fail")? + } + } + Err(e) => { + println!(" {e}"); + let error = format!("{e:?}"); + let Some(errors) = &test.errors else { + Err("want compilation to succeed")? + }; + for want in errors { + if !error.contains(want) { + println!(" got {error}"); + println!(" want {want}"); + panic!("error mismatch"); + } + } + } + } + } + Ok(()) +} + +fn compile(test: &Test) -> Result<(), CompileError> { + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + let url = "http://fake.com/schema.json"; + if let Some(remotes) = &test.remotes { + compiler.use_loader(Box::new(Remotes(remotes.clone()))); + } + compiler.add_resource(url, test.schema.clone())?; + compiler.compile(url, &mut schemas)?; + Ok(()) +} + +struct Remotes(HashMap); + +impl UrlLoader for Remotes { + fn load(&self, url: &str) -> Result> { + if let Some(v) = self.0.get(url) { + return Ok(v.clone()); + } + Err("remote not found")? + } +} diff --git a/validator/tests/output.rs b/validator/tests/output.rs new file mode 100644 index 0000000..98ffd22 --- /dev/null +++ b/validator/tests/output.rs @@ -0,0 +1,122 @@ +use std::{env, error::Error, fs::File, path::Path}; + +use boon::{Compiler, Draft, Schemas}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[test] +fn test_suites() -> Result<(), Box> { + if let Ok(suite) = env::var("TEST_SUITE") { + test_suite(&suite)?; + } else { + test_suite("tests/JSON-Schema-Test-Suite")?; + test_suite("tests/Extra-Suite")?; + } + Ok(()) +} + +fn test_suite(suite: &str) -> Result<(), Box> { + test_folder(suite, "draft2019-09", Draft::V2019_09)?; + test_folder(suite, "draft2020-12", Draft::V2020_12)?; + Ok(()) +} + +fn test_folder(suite: &str, folder: &str, draft: Draft) -> Result<(), Box> { + let output_schema_url = format!( + "https://json-schema.org/draft/{}/output/schema", + folder.strip_prefix("draft").unwrap() + ); + let prefix = Path::new(suite).join("output-tests"); + let folder = prefix.join(folder); + let content = folder.join("content"); + if !content.is_dir() { + return Ok(()); + } + let output_schema: Value = + serde_json::from_reader(File::open(folder.join("output-schema.json"))?)?; + for entry in content.read_dir()? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + }; + let entry_path = entry.path(); + println!("{}", entry_path.strip_prefix(&prefix)?.to_str().unwrap()); + let groups: Vec = serde_json::from_reader(File::open(entry_path)?)?; + for group in groups { + println!(" {}", group.description); + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.set_default_draft(draft); + let schema_url = "http://output-tests/schema"; + compiler.add_resource(schema_url, group.schema)?; + let sch = compiler.compile(schema_url, &mut schemas)?; + for test in group.tests { + println!(" {}", test.description); + match schemas.validate(&test.data, sch) { + Ok(_) => println!(" validation success"), + Err(e) => { + if let Some(sch) = test.output.basic { + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.set_default_draft(draft); + compiler.add_resource(&output_schema_url, output_schema.clone())?; + let schema_url = "http://output-tests/schema"; + compiler.add_resource(schema_url, sch)?; + let sch = compiler.compile(schema_url, &mut schemas)?; + let basic: Value = serde_json::from_str(&e.basic_output().to_string())?; + let result = schemas.validate(&basic, sch); + if let Err(e) = result { + println!("{basic:#}\n"); + for line in format!("{e}").lines() { + println!(" {line}"); + } + panic!("basic output did not match"); + } + } + if let Some(sch) = test.output.detailed { + let mut schemas = Schemas::new(); + let mut compiler = Compiler::new(); + compiler.set_default_draft(draft); + compiler.add_resource(&output_schema_url, output_schema.clone())?; + let schema_url = "http://output-tests/schema"; + compiler.add_resource(schema_url, sch)?; + let sch = compiler.compile(schema_url, &mut schemas)?; + let detailed: Value = + serde_json::from_str(&e.detailed_output().to_string())?; + let result = schemas.validate(&detailed, sch); + if let Err(e) = result { + println!("{detailed:#}\n"); + for line in format!("{e}").lines() { + println!(" {line}"); + } + panic!("detailed output did not match"); + } + } + } + } + } + } + } + + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +struct Group { + description: String, + schema: Value, + tests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Test { + description: String, + data: Value, + output: Output, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Output { + basic: Option, + detailed: Option, +} diff --git a/validator/tests/suite.rs b/validator/tests/suite.rs new file mode 100644 index 0000000..e2536d2 --- /dev/null +++ b/validator/tests/suite.rs @@ -0,0 +1,120 @@ +use std::{env, error::Error, ffi::OsStr, fs::File, path::Path}; + +use boon::{Compiler, Draft, Schemas, UrlLoader}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +static SKIP: [&str; 2] = [ + "zeroTerminatedFloats.json", // only draft4: this behavior is changed in later drafts + "float-overflow.json", +]; + +#[derive(Debug, Serialize, Deserialize)] +struct Group { + description: String, + schema: Value, + tests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Test { + description: String, + data: Value, + valid: bool, +} + +#[test] +fn test_suites() -> Result<(), Box> { + if let Ok(suite) = env::var("TEST_SUITE") { + test_suite(&suite)?; + } else { + test_suite("tests/JSON-Schema-Test-Suite")?; + test_suite("tests/Extra-Test-Suite")?; + } + Ok(()) +} + +fn test_suite(suite: &str) -> Result<(), Box> { + if !Path::new(suite).exists() { + Err(format!("test suite {suite} does not exist"))?; + } + test_dir(suite, "draft4", Draft::V4)?; + test_dir(suite, "draft6", Draft::V6)?; + test_dir(suite, "draft7", Draft::V7)?; + test_dir(suite, "draft2019-09", Draft::V2019_09)?; + test_dir(suite, "draft2020-12", Draft::V2020_12)?; + Ok(()) +} + +fn test_dir(suite: &str, path: &str, draft: Draft) -> Result<(), Box> { + let prefix = Path::new(suite).join("tests"); + let dir = prefix.join(path); + if !dir.is_dir() { + return Ok(()); + } + for entry in dir.read_dir()? { + let entry = entry?; + let file_type = entry.file_type()?; + let tmp_entry_path = entry.path(); + let entry_path = tmp_entry_path.strip_prefix(&prefix)?.to_str().unwrap(); + if file_type.is_file() { + if !SKIP.iter().any(|n| OsStr::new(n) == entry.file_name()) { + test_file(suite, entry_path, draft)?; + } + } else if file_type.is_dir() { + test_dir(suite, entry_path, draft)?; + } + } + Ok(()) +} + +fn test_file(suite: &str, path: &str, draft: Draft) -> Result<(), Box> { + println!("FILE: {path}"); + let path = Path::new(suite).join("tests").join(path); + let optional = path.components().any(|comp| comp.as_os_str() == "optional"); + let file = File::open(path)?; + + let url = "http://testsuite.com/schema.json"; + let groups: Vec = serde_json::from_reader(file)?; + for group in groups { + println!("{}", group.description); + let mut schemas = Schemas::default(); + let mut compiler = Compiler::default(); + compiler.set_default_draft(draft); + if optional { + compiler.enable_format_assertions(); + compiler.enable_content_assertions(); + } + compiler.use_loader(Box::new(RemotesLoader(suite.to_owned()))); + compiler.add_resource(url, group.schema)?; + let sch_index = compiler.compile(url, &mut schemas)?; + for test in group.tests { + println!(" {}", test.description); + let result = schemas.validate(&test.data, sch_index); + if let Err(e) = &result { + for line in format!("{e}").lines() { + println!(" {line}"); + } + for line in format!("{e:#}").lines() { + println!(" {line}"); + } + } + assert_eq!(result.is_ok(), test.valid); + } + } + Ok(()) +} + +struct RemotesLoader(String); +impl UrlLoader for RemotesLoader { + fn load(&self, url: &str) -> Result> { + // remotes folder -- + if let Some(path) = url.strip_prefix("http://localhost:1234/") { + let path = Path::new(&self.0).join("remotes").join(path); + let file = File::open(path)?; + let json: Value = serde_json::from_reader(file)?; + return Ok(json); + } + Err("no internet")? + } +}