From 7a0ac9fc42dfc76e67ea8ce4dbf697306f6c2ccf Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Sat, 28 Mar 2026 08:10:20 +0000 Subject: [PATCH 1/6] feat(validate): add --path option to target sub-schema via JSON Pointer Signed-off-by: Vaibhav mittal --- src/command_validate.cc | 33 ++++++++++++--- src/main.cc | 6 +++ test/CMakeLists.txt | 4 ++ test/validate/fail_path_not_found.sh | 38 ++++++++++++++++++ test/validate/fail_path_not_schema.sh | 35 ++++++++++++++++ test/validate/fail_path_with_template.sh | 51 ++++++++++++++++++++++++ test/validate/pass_path_openapi.sh | 33 +++++++++++++++ 7 files changed, 195 insertions(+), 5 deletions(-) create mode 100755 test/validate/fail_path_not_found.sh create mode 100755 test/validate/fail_path_not_schema.sh create mode 100755 test/validate/fail_path_with_template.sh create mode 100755 test/validate/pass_path_openapi.sh diff --git a/src/command_validate.cc b/src/command_validate.cc index ae31e737..08142e12 100644 --- a/src/command_validate.cc +++ b/src/command_validate.cc @@ -247,6 +247,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) schema_path, std::make_error_code(std::errc::is_a_directory)}; } + if (options.contains("path") && !options.at("path").empty() && + options.contains("template") && !options.at("template").empty()) { + throw OptionConflictError{ + "The --path option cannot be used with --template"}; + } + const auto schema_config_base{schema_from_stdin ? std::filesystem::current_path() : std::filesystem::path(schema_path)}; @@ -258,10 +264,27 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) read_configuration(options, configuration_path, schema_config_base)}; const auto dialect{default_dialect(options, configuration)}; - const auto schema{schema_from_stdin - ? read_from_stdin().document + sourcemeta::core::JSON schema{ + schema_from_stdin ? read_from_stdin().document : sourcemeta::core::read_yaml_or_json(schema_path)}; + if (options.contains("path") && !options.at("path").empty()) { + const auto path_string{std::string{options.at("path").front()}}; + const auto pointer{sourcemeta::core::to_pointer(path_string)}; + const auto *const result{sourcemeta::core::try_get(schema, pointer)}; + // We intentionally reuse NotSchemaError here to align with existing CLI + // error semantics without introducing a new error type. + if (!result) { + throw NotSchemaError{schema_from_stdin ? stdin_path() + : schema_resolution_base}; + } + // `result` points into `schema`, so we must copy before reassigning to + // avoid a use-after-free (the copy assignment destroys schema's storage + // before reading from other when they alias). + sourcemeta::core::JSON subschema{*result}; + schema = std::move(subschema); + } + if (!sourcemeta::core::is_schema(schema)) { throw NotSchemaError{schema_from_stdin ? stdin_path() : schema_resolution_base}; @@ -291,9 +314,9 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) const sourcemeta::core::JSON bundled{[&]() { try { - return sourcemeta::core::bundle(schema, sourcemeta::core::schema_walker, - custom_resolver, dialect, - schema_default_id); + return sourcemeta::core::bundle( + std::as_const(schema), sourcemeta::core::schema_walker, + custom_resolver, dialect, schema_default_id); } catch (const sourcemeta::core::SchemaKeywordError &error) { throw sourcemeta::core::FileError( schema_resolution_base, error); diff --git a/src/main.cc b/src/main.cc index 11448124..6880cec9 100644 --- a/src/main.cc +++ b/src/main.cc @@ -38,6 +38,7 @@ Global Options: [--benchmark/-b] [--loop ] [--extension/-e ] [--ignore/-i ] [--trace/-t] [--fast/-f] [--template/-m ] [--entrypoint/-p ] + [--path ] Validate one or more instances against the given schema. @@ -52,6 +53,10 @@ Global Options: for error reporting purposes. Make sure they match or you will get non-sense results. + Use --path to extract a sub-schema from the input document using + a JSON Pointer before validation. This is useful for validating + against schemas embedded in larger documents, such as OpenAPI. + metaschema [schemas-or-directories...] [--extension/-e ] [--ignore/-i ] [--trace/-t] @@ -182,6 +187,7 @@ auto jsonschema_main(const std::string &program, const std::string &command, app.option("template", {"m"}); app.option("loop", {"l"}); app.option("entrypoint", {"p"}); + app.option("path", {}); app.parse(argc, argv, {.skip = 1}); sourcemeta::jsonschema::validate(app); return EXIT_SUCCESS; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d067d7db..516685ac 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -242,6 +242,10 @@ add_jsonschema_test_unix(validate/fail_entrypoint_invalid_pointer_escape) add_jsonschema_test_unix(validate/fail_entrypoint_invalid_uri_parse) add_jsonschema_test_unix(validate/fail_entrypoint_mismatch) add_jsonschema_test_unix(validate/fail_entrypoint_with_template) +add_jsonschema_test_unix(validate/pass_path_openapi) +add_jsonschema_test_unix(validate/fail_path_not_found) +add_jsonschema_test_unix(validate/fail_path_not_schema) +add_jsonschema_test_unix(validate/fail_path_with_template) add_jsonschema_test_unix(validate/pass_config_ignore) add_jsonschema_test_unix(validate/pass_config_ignore_with_cli) add_jsonschema_test_unix(validate/pass_stdin_instance) diff --git a/test/validate/fail_path_not_found.sh b/test/validate/fail_path_not_found.sh new file mode 100755 index 00000000..9c9f8120 --- /dev/null +++ b/test/validate/fail_path_not_found.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/NonExistent" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: The schema file you provided does not represent a valid JSON Schema + at file path $(realpath "$TMP")/document.json +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_not_schema.sh b/test/validate/fail_path_not_schema.sh new file mode 100755 index 00000000..0db7f5df --- /dev/null +++ b/test/validate/fail_path_not_schema.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "User": [ "not", "a", "schema" ] + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "foo": "bar" } +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: The schema file you provided does not represent a valid JSON Schema + at file path $(realpath "$TMP")/document.json +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_with_template.sh b/test/validate/fail_path_with_template.sh new file mode 100755 index 00000000..b31d8ed0 --- /dev/null +++ b/test/validate/fail_path_with_template.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +cat << 'EOF' > "$TMP/template.json" +[] +EOF + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path "/foo" --template "$TMP/template.json" \ + > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +error: The --path option cannot be used with --template +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path "/foo" --template "$TMP/template.json" --json \ + > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The --path option cannot be used with --template" +} +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/validate/pass_path_openapi.sh b/test/validate/pass_path_openapi.sh new file mode 100755 index 00000000..86dc7307 --- /dev/null +++ b/test/validate/pass_path_openapi.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/openapi.json" +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "name": "John", "age": 30 } +EOF + +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" From 2162b2eb3e82ca35de3ecc03254f8d9f09783d4c Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Sat, 28 Mar 2026 08:31:21 +0000 Subject: [PATCH 2/6] fix(validate): address review feedback (includes, pointer handling, tests, and behavior documentation) Signed-off-by: Vaibhav mittal --- src/command_validate.cc | 9 ++++- test/CMakeLists.txt | 1 + .../validate/fail_path_ref_outside_subtree.sh | 37 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100755 test/validate/fail_path_ref_outside_subtree.sh diff --git a/src/command_validate.cc b/src/command_validate.cc index 08142e12..c63d6af9 100644 --- a/src/command_validate.cc +++ b/src/command_validate.cc @@ -13,6 +13,7 @@ #include // std::cerr #include // std::string #include // std::string_view +#include // std::as_const #include "command.h" #include "configuration.h" @@ -269,15 +270,19 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) : sourcemeta::core::read_yaml_or_json(schema_path)}; if (options.contains("path") && !options.at("path").empty()) { + // Invalid pointer syntax is handled by to_pointer(), consistent with + // --entrypoint behavior. const auto path_string{std::string{options.at("path").front()}}; const auto pointer{sourcemeta::core::to_pointer(path_string)}; const auto *const result{sourcemeta::core::try_get(schema, pointer)}; // We intentionally reuse NotSchemaError here to align with existing CLI // error semantics without introducing a new error type. if (!result) { - throw NotSchemaError{schema_from_stdin ? stdin_path() - : schema_resolution_base}; + throw NotSchemaError{schema_resolution_base}; } + // Note: extracting a sub-schema may break $ref references outside the + // selected subtree. This is expected behavior for --path given the current + // CLI design. // `result` points into `schema`, so we must copy before reassigning to // avoid a use-after-free (the copy assignment destroys schema's storage // before reading from other when they alias). diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 516685ac..3a8d32f9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -246,6 +246,7 @@ add_jsonschema_test_unix(validate/pass_path_openapi) add_jsonschema_test_unix(validate/fail_path_not_found) add_jsonschema_test_unix(validate/fail_path_not_schema) add_jsonschema_test_unix(validate/fail_path_with_template) +add_jsonschema_test_unix(validate/fail_path_ref_outside_subtree) add_jsonschema_test_unix(validate/pass_config_ignore) add_jsonschema_test_unix(validate/pass_config_ignore_with_cli) add_jsonschema_test_unix(validate/pass_stdin_instance) diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh new file mode 100755 index 00000000..46da7c79 --- /dev/null +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "Address": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + }, + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/components/schemas/Address" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" \ + > /dev/null 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Validation should fail when $ref points outside extracted subtree +test "$EXIT_CODE" -ne "0" + From caaa025e5f1185ee320697ee149fed1790a88244 Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Sat, 28 Mar 2026 09:10:10 +0000 Subject: [PATCH 3/6] fix(validate): address review feedbacks of cubic bot Signed-off-by: Vaibhav mittal --- .../validate/fail_path_ref_outside_subtree.sh | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh index 46da7c79..4796245d 100755 --- a/test/validate/fail_path_ref_outside_subtree.sh +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -29,9 +29,35 @@ cat << 'EOF' > "$TMP/instance.json" EOF "$1" validate "$TMP/document.json" "$TMP/instance.json" \ - --path "/components/schemas/User" \ - > /dev/null 2>&1 \ + --path "/components/schemas/User" 2> "$TMP/stderr.txt" \ && EXIT_CODE="$?" || EXIT_CODE="$?" -# Validation should fail when $ref points outside extracted subtree -test "$EXIT_CODE" -ne "0" +# Schema input error: $ref outside extracted subtree cannot be resolved during bundling +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: Could not resolve schema reference + at identifier file://$(realpath "$TMP")/document.json#/components/schemas/Address + at file path $(realpath "$TMP")/document.json + at location "/\$ref" +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "Could not resolve schema reference", + "identifier": "file://$(realpath "$TMP")/document.json#/components/schemas/Address", + "filePath": "$(realpath "$TMP")/document.json", + "location": "/\$ref" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" From de9cd5e04bd2d3f60c94ee6bd9b9c3843aa9a246 Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Sun, 5 Apr 2026 18:54:34 +0000 Subject: [PATCH 4/6] fix(validate): address review feedback for --path option Signed-off-by: Vaibhav mittal --- docs/validate.markdown | 22 +++++++++ src/command_validate.cc | 35 +++++++------- src/error.h | 34 ++++++++++++++ src/main.cc | 9 ++-- test/CMakeLists.txt | 1 + test/validate/fail_path_invalid_pointer.sh | 47 +++++++++++++++++++ test/validate/fail_path_not_found.sh | 23 +++++++-- test/validate/fail_path_not_schema.sh | 15 ++++++ .../validate/fail_path_ref_outside_subtree.sh | 3 +- test/validate/pass_path_openapi.sh | 23 +++++++++ 10 files changed, 186 insertions(+), 26 deletions(-) create mode 100755 test/validate/fail_path_invalid_pointer.sh diff --git a/docs/validate.markdown b/docs/validate.markdown index 8a750ffc..d84471f9 100644 --- a/docs/validate.markdown +++ b/docs/validate.markdown @@ -11,6 +11,7 @@ jsonschema validate ] [--extension/-e ] [--ignore/-i ] [--trace/-t] [--fast/-f] [--template/-m ] [--json/-j] [--entrypoint/-p ] + [--path/-P ] ``` The most popular use case of JSON Schema is to validate JSON documents. The @@ -190,3 +191,24 @@ jsonschema validate path/to/my/schema.json path/to/instances/ \ jsonschema validate path/to/my/schema.json path/to/my/instance.json \ --entrypoint '/$defs/MyType' ``` + +### Extract and validate against a sub-schema using a JSON Pointer + +The `--path`/`-P` option extracts a sub-schema from the input document using a +[JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) before validation. This +is useful for validating instances against schemas embedded in larger documents, +such as OpenAPI specifications. + +```sh +jsonschema validate path/to/openapi.json path/to/instance.json \ + --path '/components/schemas/User' +``` + +The JSON Pointer must resolve to a value in the document that is a valid JSON +Schema. If the pointer does not resolve, the CLI will report an error with the +attempted pointer path. + +> [!WARNING] +> Extracting a sub-schema with `--path` may break `$ref` references that point +> outside the selected subtree, since only the targeted sub-schema is used for +> validation. This option cannot be used together with `--template`/`-m`. diff --git a/src/command_validate.cc b/src/command_validate.cc index c63d6af9..c8277350 100644 --- a/src/command_validate.cc +++ b/src/command_validate.cc @@ -265,27 +265,30 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) read_configuration(options, configuration_path, schema_config_base)}; const auto dialect{default_dialect(options, configuration)}; - sourcemeta::core::JSON schema{ - schema_from_stdin ? read_from_stdin().document - : sourcemeta::core::read_yaml_or_json(schema_path)}; + auto schema = schema_from_stdin + ? read_from_stdin().document + : sourcemeta::core::read_yaml_or_json(schema_path); if (options.contains("path") && !options.at("path").empty()) { - // Invalid pointer syntax is handled by to_pointer(), consistent with - // --entrypoint behavior. - const auto path_string{std::string{options.at("path").front()}}; - const auto pointer{sourcemeta::core::to_pointer(path_string)}; - const auto *const result{sourcemeta::core::try_get(schema, pointer)}; - // We intentionally reuse NotSchemaError here to align with existing CLI - // error semantics without introducing a new error type. + sourcemeta::core::Pointer pointer; + try { + pointer = + sourcemeta::core::to_pointer(std::string{options.at("path").front()}); + } catch (const sourcemeta::core::PointerParseError &) { + throw PositionalArgumentError{ + "The JSON Pointer is not valid", + "jsonschema validate path/to/schema.json path/to/instance.json " + "--path '/components/schemas/User'"}; + } + + const auto *const result = sourcemeta::core::try_get(schema, pointer); if (!result) { - throw NotSchemaError{schema_resolution_base}; + throw PathResolutionError{schema_resolution_base, + sourcemeta::core::to_string(pointer)}; } - // Note: extracting a sub-schema may break $ref references outside the - // selected subtree. This is expected behavior for --path given the current - // CLI design. + // `result` points into `schema`, so we must copy before reassigning to - // avoid a use-after-free (the copy assignment destroys schema's storage - // before reading from other when they alias). + // avoid a use-after-free. sourcemeta::core::JSON subschema{*result}; schema = std::move(subschema); } diff --git a/src/error.h b/src/error.h index 75c5649c..8bb7f9b6 100644 --- a/src/error.h +++ b/src/error.h @@ -76,6 +76,26 @@ class NotSchemaError : public std::runtime_error { std::filesystem::path path_; }; +class PathResolutionError : public std::runtime_error { +public: + PathResolutionError(std::filesystem::path path, std::string pointer) + : std::runtime_error{"The JSON Pointer does not resolve to a value in " + "the document"}, + path_{std::move(path)}, pointer_{std::move(pointer)} {} + + [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { + return this->path_; + } + + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + +private: + std::filesystem::path path_; + std::string pointer_; +}; + class YAMLInputError : public std::runtime_error { public: YAMLInputError(std::string message, std::filesystem::path path) @@ -446,6 +466,16 @@ inline auto print_exception(const bool is_json, const Exception &exception) } } + if constexpr (requires(const Exception ¤t) { + { current.pointer() } -> std::convertible_to; + }) { + if (is_json) { + error_json.assign("pointer", sourcemeta::core::JSON{exception.pointer()}); + } else { + std::cerr << " at path " << exception.pointer() << "\n"; + } + } + if constexpr (requires(const Exception ¤t) { current.location(); }) { if (is_json) { error_json.assign("location", @@ -560,6 +590,10 @@ inline auto try_catch(const sourcemeta::core::Options &options, const auto is_json{options.contains("json")}; print_exception(is_json, error); return EXIT_OTHER_INPUT_ERROR; + } catch (const PathResolutionError &error) { + const auto is_json{options.contains("json")}; + print_exception(is_json, error); + return EXIT_OTHER_INPUT_ERROR; } catch (const NotSchemaError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); diff --git a/src/main.cc b/src/main.cc index 6880cec9..72615a45 100644 --- a/src/main.cc +++ b/src/main.cc @@ -38,7 +38,7 @@ Global Options: [--benchmark/-b] [--loop ] [--extension/-e ] [--ignore/-i ] [--trace/-t] [--fast/-f] [--template/-m ] [--entrypoint/-p ] - [--path ] + [--path/-P ] Validate one or more instances against the given schema. @@ -53,9 +53,8 @@ Global Options: for error reporting purposes. Make sure they match or you will get non-sense results. - Use --path to extract a sub-schema from the input document using - a JSON Pointer before validation. This is useful for validating - against schemas embedded in larger documents, such as OpenAPI. + Use --path/-P to extract a sub-schema using a JSON Pointer + before validation. metaschema [schemas-or-directories...] [--extension/-e ] [--ignore/-i ] [--trace/-t] @@ -187,7 +186,7 @@ auto jsonschema_main(const std::string &program, const std::string &command, app.option("template", {"m"}); app.option("loop", {"l"}); app.option("entrypoint", {"p"}); - app.option("path", {}); + app.option("path", {"P"}); app.parse(argc, argv, {.skip = 1}); sourcemeta::jsonschema::validate(app); return EXIT_SUCCESS; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3a8d32f9..bba96602 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -247,6 +247,7 @@ add_jsonschema_test_unix(validate/fail_path_not_found) add_jsonschema_test_unix(validate/fail_path_not_schema) add_jsonschema_test_unix(validate/fail_path_with_template) add_jsonschema_test_unix(validate/fail_path_ref_outside_subtree) +add_jsonschema_test_unix(validate/fail_path_invalid_pointer) add_jsonschema_test_unix(validate/pass_config_ignore) add_jsonschema_test_unix(validate/pass_config_ignore_with_cli) add_jsonschema_test_unix(validate/pass_stdin_instance) diff --git a/test/validate/fail_path_invalid_pointer.sh b/test/validate/fail_path_invalid_pointer.sh new file mode 100755 index 00000000..21671ca8 --- /dev/null +++ b/test/validate/fail_path_invalid_pointer.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path 'invalid~pointer' > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +error: The JSON Pointer is not valid + +For example: jsonschema validate path/to/schema.json path/to/instance.json --path '/components/schemas/User' +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path 'invalid~pointer' --json > "$TMP/stdout.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The JSON Pointer is not valid" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_not_found.sh b/test/validate/fail_path_not_found.sh index 9c9f8120..a278b129 100755 --- a/test/validate/fail_path_not_found.sh +++ b/test/validate/fail_path_not_found.sh @@ -27,12 +27,29 @@ EOF "$1" validate "$TMP/document.json" "$TMP/instance.json" \ --path "/components/schemas/NonExistent" 2> "$TMP/stderr.txt" \ && EXIT_CODE="$?" || EXIT_CODE="$?" -# Schema input error -test "$EXIT_CODE" = "4" +# Other input error (path not found) +test "$EXIT_CODE" = "6" cat << EOF > "$TMP/expected.txt" -error: The schema file you provided does not represent a valid JSON Schema +error: The JSON Pointer does not resolve to a value in the document at file path $(realpath "$TMP")/document.json + at path /components/schemas/NonExistent EOF diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/NonExistent" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "6" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The JSON Pointer does not resolve to a value in the document", + "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/NonExistent" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_not_schema.sh b/test/validate/fail_path_not_schema.sh index 0db7f5df..3f699830 100755 --- a/test/validate/fail_path_not_schema.sh +++ b/test/validate/fail_path_not_schema.sh @@ -33,3 +33,18 @@ error: The schema file you provided does not represent a valid JSON Schema EOF diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The schema file you provided does not represent a valid JSON Schema", + "filePath": "$(realpath "$TMP")/document.json" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh index 4796245d..b2ca40d9 100755 --- a/test/validate/fail_path_ref_outside_subtree.sh +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -59,5 +59,4 @@ cat << EOF > "$TMP/expected.txt" } EOF -diff "$TMP/stdout.txt" "$TMP/expected.txt" - +diff "$TMP/stdout.txt" "$TMP/expected.txt" \ No newline at end of file diff --git a/test/validate/pass_path_openapi.sh b/test/validate/pass_path_openapi.sh index 86dc7307..16df0493 100755 --- a/test/validate/pass_path_openapi.sh +++ b/test/validate/pass_path_openapi.sh @@ -31,3 +31,26 @@ EOF "$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ --path "/components/schemas/User" + +# Verbose run +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --verbose 2> "$TMP/stderr.txt" + +cat << EOF > "$TMP/expected_verbose.txt" +ok: $(realpath "$TMP")/instance.json + matches $(realpath "$TMP")/openapi.json +EOF + +diff "$TMP/stderr.txt" "$TMP/expected_verbose.txt" + +# JSON run +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" + +cat << 'EOF' > "$TMP/expected_json.txt" +{ + "valid": true +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected_json.txt" From f978b7d4d563581f33d2e82efa953a6e475669cc Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Wed, 8 Apr 2026 11:49:53 +0000 Subject: [PATCH 5/6] fix(validate): tighten CLI help text and fix trailing newline Signed-off-by: Vaibhav mittal --- src/main.cc | 3 +-- test/validate/fail_path_ref_outside_subtree.sh | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main.cc b/src/main.cc index 72615a45..dacbc35f 100644 --- a/src/main.cc +++ b/src/main.cc @@ -53,8 +53,7 @@ Global Options: for error reporting purposes. Make sure they match or you will get non-sense results. - Use --path/-P to extract a sub-schema using a JSON Pointer - before validation. + Use --path/-P to validate against a sub-schema by JSON Pointer. metaschema [schemas-or-directories...] [--extension/-e ] [--ignore/-i ] [--trace/-t] diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh index b2ca40d9..ea41aeb4 100755 --- a/test/validate/fail_path_ref_outside_subtree.sh +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -59,4 +59,4 @@ cat << EOF > "$TMP/expected.txt" } EOF -diff "$TMP/stdout.txt" "$TMP/expected.txt" \ No newline at end of file +diff "$TMP/stdout.txt" "$TMP/expected.txt" From 922ba3142057471e8d2ed2b435b0d729e9fc659f Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Wed, 8 Apr 2026 12:24:54 +0000 Subject: [PATCH 6/6] fix(validate): address all mentor review comments on --path option - Change PathResolutionError exit code from 6 to 5 (EXIT_INVALID_CLI_ARGUMENTS) - Add pointer field to NotSchemaError, print when --path is used - Create PathSchemaReferenceError for errors with --path context - Guard empty pointer strings in print_exception - Remove unnecessary use-after-free comment - Move is_schema check inside --path block for correct pointer propagation - Update all test expectations Signed-off-by: Vaibhav mittal --- src/command_validate.cc | 29 +++++++-- src/error.h | 59 ++++++++++++++++--- test/validate/fail_path_not_found.sh | 6 +- test/validate/fail_path_not_schema.sh | 4 +- .../validate/fail_path_ref_outside_subtree.sh | 2 + 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/command_validate.cc b/src/command_validate.cc index c8277350..01888797 100644 --- a/src/command_validate.cc +++ b/src/command_validate.cc @@ -269,6 +269,8 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) ? read_from_stdin().document : sourcemeta::core::read_yaml_or_json(schema_path); + std::string path_pointer_string; + if (options.contains("path") && !options.at("path").empty()) { sourcemeta::core::Pointer pointer; try { @@ -281,19 +283,22 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) "--path '/components/schemas/User'"}; } + path_pointer_string = sourcemeta::core::to_string(pointer); + const auto *const result = sourcemeta::core::try_get(schema, pointer); if (!result) { - throw PathResolutionError{schema_resolution_base, - sourcemeta::core::to_string(pointer)}; + throw PathResolutionError{schema_resolution_base, path_pointer_string}; } - // `result` points into `schema`, so we must copy before reassigning to - // avoid a use-after-free. sourcemeta::core::JSON subschema{*result}; schema = std::move(subschema); - } - if (!sourcemeta::core::is_schema(schema)) { + if (!sourcemeta::core::is_schema(schema)) { + throw NotSchemaError{schema_from_stdin ? stdin_path() + : schema_resolution_base, + path_pointer_string}; + } + } else if (!sourcemeta::core::is_schema(schema)) { throw NotSchemaError{schema_from_stdin ? stdin_path() : schema_resolution_base}; } @@ -332,6 +337,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) throw sourcemeta::core::FileError( schema_resolution_base, error); } catch (const sourcemeta::core::SchemaReferenceError &error) { + if (!path_pointer_string.empty()) { + throw PathSchemaReferenceError{ + schema_resolution_base, std::string{error.identifier()}, + error.location(), path_pointer_string, error.what()}; + } + throw sourcemeta::core::FileError( schema_resolution_base, std::string{error.identifier()}, error.location(), error.what()); @@ -430,6 +441,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) throw sourcemeta::core::FileError( schema_resolution_base, error); } catch (const sourcemeta::core::SchemaReferenceError &error) { + if (!path_pointer_string.empty()) { + throw PathSchemaReferenceError{ + schema_resolution_base, std::string{error.identifier()}, + error.location(), path_pointer_string, error.what()}; + } + throw sourcemeta::core::FileError( schema_resolution_base, std::string{error.identifier()}, error.location(), error.what()); diff --git a/src/error.h b/src/error.h index 8bb7f9b6..e9b35529 100644 --- a/src/error.h +++ b/src/error.h @@ -62,18 +62,23 @@ class InvalidOptionEnumerationValueError : public std::runtime_error { class NotSchemaError : public std::runtime_error { public: - NotSchemaError(std::filesystem::path path) + NotSchemaError(std::filesystem::path path, std::string pointer = {}) : std::runtime_error{"The schema file you provided does not represent a " "valid JSON " "Schema"}, - path_{std::move(path)} {} + path_{std::move(path)}, pointer_{std::move(pointer)} {} [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { return this->path_; } + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + private: std::filesystem::path path_; + std::string pointer_; }; class PathResolutionError : public std::runtime_error { @@ -96,6 +101,39 @@ class PathResolutionError : public std::runtime_error { std::string pointer_; }; +class PathSchemaReferenceError : public std::runtime_error { +public: + PathSchemaReferenceError(std::filesystem::path path, std::string identifier, + sourcemeta::core::Pointer location, + std::string pointer, std::string message) + : std::runtime_error{std::move(message)}, path_{std::move(path)}, + identifier_{std::move(identifier)}, location_{std::move(location)}, + pointer_{std::move(pointer)} {} + + [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { + return this->path_; + } + + [[nodiscard]] auto identifier() const noexcept -> const std::string & { + return this->identifier_; + } + + [[nodiscard]] auto location() const noexcept + -> const sourcemeta::core::Pointer & { + return this->location_; + } + + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + +private: + std::filesystem::path path_; + std::string identifier_; + sourcemeta::core::Pointer location_; + std::string pointer_; +}; + class YAMLInputError : public std::runtime_error { public: YAMLInputError(std::string message, std::filesystem::path path) @@ -469,10 +507,13 @@ inline auto print_exception(const bool is_json, const Exception &exception) if constexpr (requires(const Exception ¤t) { { current.pointer() } -> std::convertible_to; }) { - if (is_json) { - error_json.assign("pointer", sourcemeta::core::JSON{exception.pointer()}); - } else { - std::cerr << " at path " << exception.pointer() << "\n"; + if (!exception.pointer().empty()) { + if (is_json) { + error_json.assign("pointer", + sourcemeta::core::JSON{exception.pointer()}); + } else { + std::cerr << " at path " << exception.pointer() << "\n"; + } } } @@ -593,7 +634,7 @@ inline auto try_catch(const sourcemeta::core::Options &options, } catch (const PathResolutionError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); - return EXIT_OTHER_INPUT_ERROR; + return EXIT_INVALID_CLI_ARGUMENTS; } catch (const NotSchemaError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); @@ -697,6 +738,10 @@ inline auto try_catch(const sourcemeta::core::Options &options, "Try tools like https://regex101.com to debug further\n"; } + return EXIT_SCHEMA_INPUT_ERROR; + } catch (const PathSchemaReferenceError &error) { + const auto is_json{options.contains("json")}; + print_exception(is_json, error); return EXIT_SCHEMA_INPUT_ERROR; } catch ( const sourcemeta::core::FileError diff --git a/test/validate/fail_path_not_found.sh b/test/validate/fail_path_not_found.sh index a278b129..40fdf6a7 100755 --- a/test/validate/fail_path_not_found.sh +++ b/test/validate/fail_path_not_found.sh @@ -27,8 +27,8 @@ EOF "$1" validate "$TMP/document.json" "$TMP/instance.json" \ --path "/components/schemas/NonExistent" 2> "$TMP/stderr.txt" \ && EXIT_CODE="$?" || EXIT_CODE="$?" -# Other input error (path not found) -test "$EXIT_CODE" = "6" +# Invalid CLI arguments (path not found) +test "$EXIT_CODE" = "5" cat << EOF > "$TMP/expected.txt" error: The JSON Pointer does not resolve to a value in the document @@ -42,7 +42,7 @@ diff "$TMP/stderr.txt" "$TMP/expected.txt" "$1" validate "$TMP/document.json" "$TMP/instance.json" \ --path "/components/schemas/NonExistent" --json > "$TMP/stdout.txt" \ && EXIT_CODE="$?" || EXIT_CODE="$?" -test "$EXIT_CODE" = "6" +test "$EXIT_CODE" = "5" cat << EOF > "$TMP/expected.txt" { diff --git a/test/validate/fail_path_not_schema.sh b/test/validate/fail_path_not_schema.sh index 3f699830..4c86ca59 100755 --- a/test/validate/fail_path_not_schema.sh +++ b/test/validate/fail_path_not_schema.sh @@ -30,6 +30,7 @@ test "$EXIT_CODE" = "4" cat << EOF > "$TMP/expected.txt" error: The schema file you provided does not represent a valid JSON Schema at file path $(realpath "$TMP")/document.json + at path /components/schemas/User EOF diff "$TMP/stderr.txt" "$TMP/expected.txt" @@ -43,7 +44,8 @@ test "$EXIT_CODE" = "4" cat << EOF > "$TMP/expected.txt" { "error": "The schema file you provided does not represent a valid JSON Schema", - "filePath": "$(realpath "$TMP")/document.json" + "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/User" } EOF diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh index ea41aeb4..86b76ebf 100755 --- a/test/validate/fail_path_ref_outside_subtree.sh +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -38,6 +38,7 @@ cat << EOF > "$TMP/expected.txt" error: Could not resolve schema reference at identifier file://$(realpath "$TMP")/document.json#/components/schemas/Address at file path $(realpath "$TMP")/document.json + at path /components/schemas/User at location "/\$ref" EOF @@ -55,6 +56,7 @@ cat << EOF > "$TMP/expected.txt" "error": "Could not resolve schema reference", "identifier": "file://$(realpath "$TMP")/document.json#/components/schemas/Address", "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/User", "location": "/\$ref" } EOF