From 6da4e0168245caaf8b7ac260bd906704d71eadde Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 2 Aug 2026 10:18:33 -0300 Subject: [PATCH] Upgrade Core and Blaze Signed-off-by: Juan Cruz Viotti --- DEPENDENCIES | 4 +- vendor/blaze/DEPENDENCIES | 2 +- .../v1/2019-09/vocabulary.json | 4 + .../v1/2020-12/vocabulary.json | 4 + .../sourcemeta-extension/v1/README.markdown | 18 +- .../include/sourcemeta/blaze/output_jsonld.h | 19 +- vendor/blaze/src/output/output_jsonld.cc | 508 ++++++++++++----- .../core/cmake/common/compiler/options.cmake | 12 +- vendor/core/src/core/crypto/crypto_helpers.h | 21 +- vendor/core/src/core/crypto/crypto_pkcs8.h | 58 ++ .../src/core/crypto/crypto_rsa_oaep_apple.cc | 8 + .../src/core/crypto/crypto_sign_windows.cc | 11 +- .../src/core/crypto/crypto_verify_apple.cc | 4 +- .../src/core/crypto/crypto_verify_openssl.cc | 4 +- .../src/core/crypto/crypto_verify_windows.cc | 4 +- .../include/sourcemeta/core/crypto_secure.h | 16 + .../email/include/sourcemeta/core/email.h | 19 + vendor/core/src/core/html/escape.cc | 81 ++- .../include/sourcemeta/core/html_escape.h | 12 +- .../include/sourcemeta/core/html_writer.h | 10 +- vendor/core/src/core/http/CMakeLists.txt | 2 +- .../core/src/core/http/accept_includes_all.cc | 19 +- vendor/core/src/core/http/aws_sigv4.cc | 36 +- vendor/core/src/core/http/client_curl.cc | 58 +- vendor/core/src/core/http/client_darwin.mm | 6 +- vendor/core/src/core/http/client_windows.cc | 37 +- vendor/core/src/core/http/helpers.h | 198 ++++++- .../core/http/include/sourcemeta/core/http.h | 8 +- .../include/sourcemeta/core/http_message.h | 127 ++++- .../include/sourcemeta/core/http_syntax.h | 28 + .../include/sourcemeta/core/http_system.h | 112 +++- vendor/core/src/core/http/match_accept.cc | 20 +- vendor/core/src/core/http/serialize_cookie.cc | 9 +- vendor/core/src/core/idna/idna.cc | 25 +- vendor/core/src/core/ip/ip_helpers.h | 9 + vendor/core/src/core/ip/ipv6.cc | 22 + .../sourcemeta/core/jose_jwks_provider.h | 29 +- .../include/sourcemeta/core/jose_verify.h | 47 +- vendor/core/src/core/jose/jose_jwks.cc | 7 +- .../core/src/core/jose/jose_jwks_provider.cc | 14 + .../src/core/jose/jose_jwt_check_claims.cc | 30 +- vendor/core/src/core/jose/jose_jwt_sign.cc | 7 +- vendor/core/src/core/jose/jose_jwt_verify.cc | 6 +- .../core/json/include/sourcemeta/core/json.h | 14 + .../json/include/sourcemeta/core/json_value.h | 3 +- vendor/core/src/core/json/stringify.h | 31 +- .../jsonl/include/sourcemeta/core/jsonl.h | 9 +- .../include/sourcemeta/core/jsonl_iterator.h | 5 +- .../sourcemeta/core/jsonld_materialize.h | 8 +- vendor/core/src/core/jsonld/jsonld.cc | 1 + .../core/src/core/jsonld/jsonld_algorithms.h | 4 + .../core/src/core/jsonld/jsonld_compaction.cc | 21 +- .../core/jsonld/jsonld_context_processing.cc | 5 +- .../jsonld/jsonld_create_term_definition.cc | 15 +- .../src/core/jsonld/jsonld_iri_compaction.cc | 80 ++- .../src/core/jsonld/jsonld_materialize.cc | 95 +++- .../core/src/core/jsonld/jsonld_serialise.h | 7 +- .../sourcemeta/core/jsonpointer_token.h | 14 + vendor/core/src/core/jsonpointer/parser.h | 36 +- vendor/core/src/core/jsonpointer/position.cc | 20 +- vendor/core/src/core/oauth/CMakeLists.txt | 2 + .../include/sourcemeta/core/oauth_metadata.h | 194 ++++++- vendor/core/src/core/oauth/oauth_assertion.cc | 4 +- .../src/core/oauth/oauth_authorization.cc | 2 +- vendor/core/src/core/oauth/oauth_decode.h | 9 + vendor/core/src/core/oauth/oauth_dpop.cc | 8 +- vendor/core/src/core/oauth/oauth_metadata.cc | 515 ++++++++++++++++-- .../core/src/core/oauth/oauth_registration.cc | 24 +- vendor/core/src/core/oauth/oauth_syntax.h | 24 +- vendor/core/src/core/oidc/CMakeLists.txt | 4 +- .../sourcemeta/core/oidc_authentication.h | 9 +- .../include/sourcemeta/core/oidc_id_token.h | 14 +- .../include/sourcemeta/core/oidc_metadata.h | 40 ++ .../sourcemeta/core/oidc_request_object.h | 9 +- .../include/sourcemeta/core/oidc_userinfo.h | 10 +- .../core/src/core/oidc/oidc_authentication.cc | 90 ++- vendor/core/src/core/oidc/oidc_discovery.cc | 92 +++- vendor/core/src/core/oidc/oidc_id_token.cc | 40 +- vendor/core/src/core/oidc/oidc_logout.cc | 28 +- vendor/core/src/core/oidc/oidc_metadata.cc | 79 ++- .../core/src/core/oidc/oidc_registration.cc | 80 ++- .../core/src/core/oidc/oidc_request_object.cc | 19 +- vendor/core/src/core/oidc/oidc_userinfo.cc | 17 +- vendor/core/src/core/time/CMakeLists.txt | 2 +- vendor/core/src/core/time/asctime.cc | 7 +- vendor/core/src/core/time/clock_shift.cc | 55 ++ vendor/core/src/core/time/helpers.h | 18 + vendor/core/src/core/time/imf_fixdate.cc | 10 +- .../core/time/include/sourcemeta/core/time.h | 61 ++- vendor/core/src/core/time/iso8601_basic.cc | 8 +- vendor/core/src/core/uri/accessors.cc | 51 ++ vendor/core/src/core/uri/canonicalize.cc | 5 + .../core/uri/include/sourcemeta/core/uri.h | 58 ++ vendor/core/src/core/uritemplate/helpers.h | 48 +- .../include/sourcemeta/core/uritemplate.h | 29 +- .../core/src/core/uritemplate/uritemplate.cc | 8 +- .../core/yaml/include/sourcemeta/core/yaml.h | 11 +- vendor/core/src/core/yaml/lexer.h | 69 ++- vendor/core/src/core/yaml/parser.h | 213 ++++++-- vendor/core/src/core/yaml/yaml.cc | 12 +- vendor/core/src/lang/numeric/decimal.cc | 9 +- .../lang/text/include/sourcemeta/core/text.h | 24 +- vendor/core/src/lang/text/text.cc | 17 - 103 files changed, 3370 insertions(+), 701 deletions(-) create mode 100644 vendor/core/src/core/time/clock_shift.cc diff --git a/DEPENDENCIES b/DEPENDENCIES index 65d9ca5c8..05e23b0ab 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 42d50f3c1e2d63c456fbdf37d18854edab39b5a0 -blaze https://github.com/sourcemeta/blaze ca1949507ea5f4215f9a55ca796cd074602ff705 +core https://github.com/sourcemeta/core 176a5e0591777dcc60f32d6978ee7cf2acfa01c1 +blaze https://github.com/sourcemeta/blaze 0d51d31a4f2e08454c75b1a90a0b128943ceae19 bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index fbb14c129..08f9954d7 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 42d50f3c1e2d63c456fbdf37d18854edab39b5a0 +core https://github.com/sourcemeta/core b1780daacd7dce9039e411e088aa5a7fd64e243b jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 1acd90e53554fa24d2529b49fd7d50bab18f8b7e jsonschema-2020-12 https://github.com/json-schema-org/json-schema-spec 769daad75a9553562333a8937a187741cb708c72 jsonschema-2019-09 https://github.com/json-schema-org/json-schema-spec 41014ea723120ce70b314d72f863c6929d9f3cfd diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json index 4b470c9ef..5f11cd77c 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -38,8 +38,12 @@ "type": "null" }, { + "$comment": "A literal has the langString datatype exactly when it carries a language tag, which only x-jsonld-language declares", "x-format-assertion": true, "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" + }, "format": "iri" } ] diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json index bb7b41eae..efe43e3ff 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -38,8 +38,12 @@ "type": "null" }, { + "$comment": "A literal has the langString datatype exactly when it carries a language tag, which only x-jsonld-language declares", "x-format-assertion": true, "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" + }, "format": "iri" } ] diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown index 70d366eea..19a4153f8 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -99,7 +99,10 @@ apply to this keyword. #### 3.2.4. `x-jsonld-datatype` The value of this keyword MUST be a string representing an absolute IRI -[RFC3987]. +[RFC3987], and MUST NOT be the IRI +`http://www.w3.org/1999/02/22-rdf-syntax-ns#langString`, as a literal has +that datatype exactly when it carries a language tag, which only +`x-jsonld-language` declares. This keyword declares the datatype IRI of the typed literal that the annotated instance location materializes as, such as @@ -172,9 +175,16 @@ This keyword mints the identifier of the node that the annotated instance location materializes as, giving an object its `@id` or promoting a scalar to an identified node. An object binds each template variable to the member of that name, and a scalar binds the reserved variable `this` to its own value. -Every binding MUST be a non-empty string, and the expanded template MUST be -an absolute IRI [RFC3987]. The location value MUST -NOT be an array, and the keyword MUST NOT be combined with +Every binding MUST be a non-empty string, and the expanded template MUST be an +absolute IRI [RFC3987]. Template expansion encodes against the IRI alphabet: +internationalized characters in bound values pass through unencoded instead of +being percent encoded, so an identifier minted from instance data is spelled +identically to the same IRI written as a constant. Expansion never decodes +anything. A percent-encoded triplet already present in a binding is copied +through by a reserved expansion such as `{+this}`, whereas a simple expansion +such as `{this}` percent encodes its percent sign like any other reserved +character, as [RFC6570] prescribes. The location value +MUST NOT be an array, and the keyword MUST NOT be combined with `x-jsonld-datatype`, `x-jsonld-language`, or `x-jsonld-direction` at the same location. diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h index 855f58fc6..b2799d47a 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h @@ -11,11 +11,12 @@ #include #include -#include // std::array -#include // std::uint8_t -#include // std::string -#include // std::variant -#include // std::vector +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string +#include // std::variant +#include // std::vector namespace sourcemeta::blaze { @@ -52,6 +53,14 @@ struct JSONLDResolutionError { sourcemeta::core::Pointer instance_location; JSONLDFacet facet; std::string message; + /// The schema location of the annotation the error is about + std::string schema_location; + /// The schema location of the annotation that the first one collides with, + /// on errors involving a diverging or incompatible pair + std::optional conflicting_schema_location; + /// The schema location of an x-jsonld-override mark that could not shadow + /// the conflicting annotation it does not enclose + std::optional inert_override_location; }; /// @ingroup output diff --git a/vendor/blaze/src/output/output_jsonld.cc b/vendor/blaze/src/output/output_jsonld.cc index b92097046..72c6dfa2e 100644 --- a/vendor/blaze/src/output/output_jsonld.cc +++ b/vendor/blaze/src/output/output_jsonld.cc @@ -6,22 +6,27 @@ #include #include -#include // std::ranges::sort, std::ranges::any_of -#include // assert -#include // std::ref, std::reference_wrapper -#include // std::optional -#include // std::string -#include // std::string_view -#include // std::tie, std::make_tuple -#include // std::unordered_map -#include // std::unordered_set -#include // std::move -#include // std::variant, std::get, std::holds_alternative -#include // std::vector +#include // std::ranges::sort, std::ranges::any_of +#include // assert +#include // std::ref, std::reference_wrapper +#include // std::initializer_list +#include // std::optional +#include // std::string +#include // std::string_view +#include // std::tie, std::make_tuple +#include // std::unordered_map +#include // std::unordered_set +#include // std::move +#include // std::variant, std::get, std::holds_alternative +#include // std::vector namespace { -// The facts gathered at one instance location before its kind is known +// The facts gathered at one instance location before its kind is known. Each +// facet remembers the schema location of the annotation that last changed it, +// never of a redundant duplicate, so a later error about the facet can cite +// its origin. The origins are plain pointers into the collected annotation +// entries, so carrying them costs no allocation struct Facts { std::vector edges; std::vector types; @@ -32,6 +37,16 @@ struct Facts { std::optional self; bool json{false}; bool graph{false}; + const std::string *edges_origin{nullptr}; + const std::string *reverse_origin{nullptr}; + const std::string *types_origin{nullptr}; + const std::string *datatype_origin{nullptr}; + const std::string *language_origin{nullptr}; + const std::string *direction_origin{nullptr}; + const std::string *container_origin{nullptr}; + const std::string *self_origin{nullptr}; + const std::string *json_origin{nullptr}; + const std::string *graph_origin{nullptr}; }; using Accumulator = std::unordered_map &edges, const sourcemeta::core::JSON::String &predicate, - const bool reverse) -> void { + const bool reverse) -> bool { const auto exists{std::ranges::any_of( edges, [&predicate, reverse](const sourcemeta::core::JSONLDEdge &edge) -> bool { @@ -48,10 +63,12 @@ auto add_edge(std::vector &edges, if (!exists) { edges.push_back({.predicate = predicate, .reverse = reverse}); } + + return !exists; } auto add_type(std::vector &types, - const sourcemeta::core::JSON::String &type) -> void { + const sourcemeta::core::JSON::String &type) -> bool { const auto exists{std::ranges::any_of( types, [&type](const sourcemeta::core::JSON::String &existing) -> bool { return existing == type; @@ -59,6 +76,8 @@ auto add_type(std::vector &types, if (!exists) { types.push_back(type); } + + return !exists; } // Whether an annotation value is usable as an absolute IRI @@ -79,20 +98,43 @@ auto demote(Accumulator &accumulator, DirtyLocations &dirty, dirty.insert(location); } -auto type_iri_error(const sourcemeta::core::WeakPointer &instance_location) +// The first present schema origin among an error's counterpart annotations. +// Only runs on the error path +auto first_origin(std::initializer_list pointers) + -> std::optional { + for (const auto *origin : pointers) { + if (origin != nullptr) { + return *origin; + } + } + + return std::nullopt; +} + +auto type_iri_error(const sourcemeta::core::WeakPointer &instance_location, + const std::string &origin) -> sourcemeta::blaze::JSONLDResolutionError { return {.instance_location = sourcemeta::core::to_pointer(instance_location), .facet = sourcemeta::blaze::JSONLDFacet::Type, - .message = "The value of x-jsonld-type must be an absolute IRI"}; + .message = "The value of x-jsonld-type must be an absolute IRI", + .schema_location = origin, + .conflicting_schema_location = std::nullopt, + .inert_override_location = std::nullopt}; } -auto facet_error(const sourcemeta::core::WeakPointer &instance_location, - const sourcemeta::blaze::JSONLDFacet facet, - std::string message) +auto facet_error( + const sourcemeta::core::WeakPointer &instance_location, + const sourcemeta::blaze::JSONLDFacet facet, std::string message, + std::string schema_location, + std::optional conflicting_schema_location = std::nullopt, + std::optional inert_override_location = std::nullopt) -> sourcemeta::blaze::JSONLDResolutionError { return {.instance_location = sourcemeta::core::to_pointer(instance_location), .facet = facet, - .message = std::move(message)}; + .message = std::move(message), + .schema_location = std::move(schema_location), + .conflicting_schema_location = std::move(conflicting_schema_location), + .inert_override_location = std::move(inert_override_location)}; } auto parse_direction(const sourcemeta::core::JSON &value) @@ -141,7 +183,7 @@ auto parse_container(const sourcemeta::core::JSON &value) auto container_placement_error( const sourcemeta::core::WeakPointer &pointer, const sourcemeta::core::JSONLDContainer container, - const sourcemeta::core::JSON &value) + const sourcemeta::core::JSON &value, const std::string &origin) -> std::optional { if (container == sourcemeta::core::JSONLDContainer::List || container == sourcemeta::core::JSONLDContainer::Set) { @@ -149,7 +191,8 @@ auto container_placement_error( return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD list or set container can only be assigned to an array " - "value"); + "value", + origin); } return std::nullopt; @@ -159,7 +202,8 @@ auto container_placement_error( return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD language or index container can only be assigned to an " - "object value"); + "object value", + origin); } if (container == sourcemeta::core::JSONLDContainer::Language) { @@ -168,7 +212,8 @@ auto container_placement_error( !sourcemeta::core::is_canonical_langtag(entry.first)) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD language container requires canonical " - "BCP 47 language tag keys"); + "BCP 47 language tag keys", + origin); } // A null member, or a null item in an array member, is treated as absent @@ -183,7 +228,8 @@ auto container_placement_error( if (!usable) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD language container requires string or null members"); + "A JSON-LD language container requires string or null members", + origin); } } } @@ -202,13 +248,15 @@ auto placement_error(const sourcemeta::core::WeakPointer &pointer, if (!facts.types.empty() && !facts.self.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Type, - "A JSON-LD type can only be assigned to an object value"); + "A JSON-LD type can only be assigned to an object value", + *facts.types_origin); } if (facts.graph) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Graph, - "A JSON-LD graph flag can only be assigned to an object value"); + "A JSON-LD graph flag can only be assigned to an object value", + *facts.graph_origin); } } @@ -219,19 +267,22 @@ auto placement_error(const sourcemeta::core::WeakPointer &pointer, if (facts.datatype.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Datatype, - "A JSON-LD datatype can only be assigned to a scalar value"); + "A JSON-LD datatype can only be assigned to a scalar value", + *facts.datatype_origin); } if (facts.language.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Language, - "A JSON-LD language can only be assigned to a scalar value"); + "A JSON-LD language can only be assigned to a scalar value", + *facts.language_origin); } if (facts.direction.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Direction, - "A JSON-LD direction can only be assigned to a scalar value"); + "A JSON-LD direction can only be assigned to a scalar value", + *facts.direction_origin); } return std::nullopt; @@ -247,19 +298,23 @@ auto literal_error(const sourcemeta::core::WeakPointer &pointer, (facts.language.has_value() || facts.direction.has_value())) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Datatype, - "A JSON-LD datatype cannot carry a language or direction"); + "A JSON-LD datatype cannot carry a language or direction", + *facts.datatype_origin, + first_origin({facts.language_origin, facts.direction_origin})); } if (facts.language.has_value() && !value.is_string()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Language, - "A JSON-LD language can only be assigned to a string value"); + "A JSON-LD language can only be assigned to a string value", + *facts.language_origin); } if (facts.direction.has_value() && !value.is_string()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Direction, - "A JSON-LD direction can only be assigned to a string value"); + "A JSON-LD direction can only be assigned to a string value", + *facts.direction_origin); } return std::nullopt; @@ -269,16 +324,19 @@ auto literal_error(const sourcemeta::core::WeakPointer &pointer, // binds each variable to the member of that name, and a scalar binds the // reserved variable this to its own value. Only a non-empty string can bind, // and a binding that is not usable, or a result that is not an absolute IRI, -// is a fail-loud resolution error +// is a fail-loud resolution error. Expansion runs in IRI mode so that +// internationalized characters flowing through a template mint the same raw +// term that constant identities emit, as RDF compares IRIs by simple string +// comparison (RDF 1.1 Concepts Section 3.2) auto expand_self(const sourcemeta::core::WeakPointer &pointer, const sourcemeta::core::JSON::String &pattern, - const sourcemeta::core::JSON &value) + const sourcemeta::core::JSON &value, const std::string &origin) -> std::variant { std::optional failure; const sourcemeta::core::URITemplate uri_template{pattern}; auto expanded{uri_template.expand( - [&value, &pointer, &failure]( + [&value, &pointer, &failure, &origin]( const std::string_view name) -> sourcemeta::core::URITemplateValue { const sourcemeta::core::JSON *bound{nullptr}; if (value.is_object()) { @@ -287,12 +345,37 @@ auto expand_self(const sourcemeta::core::WeakPointer &pointer, bound = &value; } - if (bound == nullptr || !bound->is_string()) { + if (bound == nullptr) { + if (!failure.has_value()) { + failure = facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity template variable must bind to an " + "instance value", + origin); + } + + return std::nullopt; + } + + if (bound->is_null()) { + if (!failure.has_value()) { + failure = facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity template variable cannot bind to a " + "null value", + origin); + } + + return std::nullopt; + } + + if (!bound->is_string()) { if (!failure.has_value()) { failure = facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity template variable must bind to a " - "string"); + "A JSON-LD self identity template variable can only bind to a " + "string value", + origin); } return std::nullopt; @@ -304,23 +387,25 @@ auto expand_self(const sourcemeta::core::WeakPointer &pointer, failure = facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, "A JSON-LD self identity template variable cannot bind to an " - "empty string"); + "empty string", + origin); } return std::nullopt; } return std::make_tuple(std::string_view{text}, std::nullopt, false); - })}; + }, + sourcemeta::core::URITemplateExpansionMode::IRI)}; if (failure.has_value()) { return failure.value(); } if (!sourcemeta::core::URI::is_iri(expanded)) { - return facet_error( - pointer, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity must expand to an absolute IRI"); + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity must expand to an absolute IRI", + origin); } return sourcemeta::core::JSON::String{std::move(expanded)}; @@ -359,17 +444,25 @@ auto encloses(const sourcemeta::core::WeakPointer &outer, return outer.size() < inner.size() && inner.starts_with_initial(outer); } +// An x-jsonld-override annotation that evaluated to true at a location, +// paired with the schema location of the mark itself for error citations +struct Mark { + const sourcemeta::core::WeakPointer *path; + const std::string *origin; +}; + // A collected keyword value pending resolution, paired with the evaluate -// path of the annotation that carried it +// path and schema origin of the annotation that carried it, plus the +// override mark sharing its schema object, if any struct Candidate { const sourcemeta::core::JSON *value; const sourcemeta::core::WeakPointer *path; - bool marked{false}; + const std::string *origin; + const Mark *mark{nullptr}; }; // The candidates gathered at one instance location before resolution, plus -// the evaluate paths of the x-jsonld-override annotations that evaluated to -// true at that location +// the x-jsonld-override annotations that evaluated to true at that location struct Pending { std::vector ids; std::vector reverses; @@ -381,7 +474,7 @@ struct Pending { std::vector graphs; std::vector containers; std::vector selves; - std::vector marks; + std::vector marks; [[nodiscard]] auto annotated() const -> bool { return !this->ids.empty() || !this->reverses.empty() || @@ -395,33 +488,33 @@ struct Pending { using PendingMap = std::unordered_map; -// Whether the annotation at the given evaluate path shares its schema object -// with one of its location's x-jsonld-override marks -auto marked_by(const std::vector &marks, - const sourcemeta::core::WeakPointer &path) -> bool { - for (const auto *mark : marks) { - if (mark->size() == path.size() && path.starts_with_initial(*mark)) { - return true; +// The x-jsonld-override mark sharing its schema object with the annotation +// at the given evaluate path, if any +auto find_mark(const std::vector &marks, + const sourcemeta::core::WeakPointer &path) -> const Mark * { + for (const auto &mark : marks) { + if (mark.path->size() == path.size() && + path.starts_with_initial(*mark.path)) { + return &mark; } } - return false; + return nullptr; } // A null that no override mark licenses declares nothing, as if the keyword // were absent auto unmarked_null(const Candidate &candidate) -> bool { - return candidate.value->is_null() && !candidate.marked; + return candidate.value->is_null() && candidate.mark == nullptr; } // Attach the location's override marks to a facet's candidates and drop the // nulls that no mark licenses. This cannot happen at collection time, as the // sibling mark may be collected in any order relative to the null -auto prepare(std::vector &candidates, - const std::vector &marks) +auto prepare(std::vector &candidates, const std::vector &marks) -> void { for (auto &candidate : candidates) { - candidate.marked = marked_by(marks, *candidate.path); + candidate.mark = find_mark(marks, *candidate.path); } std::erase_if(candidates, unmarked_null); @@ -451,10 +544,14 @@ auto key_boolean(const sourcemeta::core::JSON &value) -> NormalizedKey { // The resolution of a single-valued facet at one instance location. An // absent winner without a conflict means the facet resolves to its absent -// state, either because no candidate exists or because a tombstone won +// state, either because no candidate exists or because a tombstone won. A +// conflict is witnessed by its first diverging pair of surviving candidates, +// mirroring the fail-first semantics of resolution errors struct Election { const Candidate *winner{nullptr}; bool conflict{false}; + const Candidate *first{nullptr}; + const Candidate *second{nullptr}; }; // Resolve a single-valued facet: shadowed values are discarded, identical @@ -482,7 +579,7 @@ auto elect(const std::vector &candidates, for (std::size_t index = 0; index < candidates.size(); index += 1) { bool shadowed{false}; for (std::size_t other = 0; other < candidates.size(); other += 1) { - if (candidates[other].marked && keys[other] != keys[index] && + if (candidates[other].mark != nullptr && keys[other] != keys[index] && encloses(*candidates[other].path, *candidates[index].path)) { shadowed = true; break; @@ -505,7 +602,10 @@ auto elect(const std::vector &candidates, const auto &elected{keys[survivors.front()]}; for (const auto index : survivors) { if (keys[index] != elected) { - return {.winner = nullptr, .conflict = true}; + return {.winner = nullptr, + .conflict = true, + .first = &candidates[survivors.front()], + .second = &candidates[index]}; } } @@ -515,7 +615,27 @@ auto elect(const std::vector &candidates, // Every survivor carries the elected key, and equal keys mean equal // values, so any survivor serves as the winner - return {.winner = &candidates[survivors.front()], .conflict = false}; + return {.winner = &candidates[survivors.front()], + .conflict = false, + .first = nullptr, + .second = nullptr}; +} + +// The error for a facet whose surviving candidates diverge, citing the +// witnessing pair. A surviving marked candidate failed to shadow the +// diverging competitor it does not enclose, so its mark is cited as inert +auto conflict_error(const sourcemeta::core::WeakPointer &instance_location, + const sourcemeta::blaze::JSONLDFacet facet, + std::string message, const Election &election) + -> sourcemeta::blaze::JSONLDResolutionError { + const auto *marked{election.first->mark != nullptr ? election.first + : election.second->mark != nullptr ? election.second + : nullptr}; + return facet_error(instance_location, facet, std::move(message), + *election.first->origin, *election.second->origin, + marked == nullptr + ? std::nullopt + : std::optional{*marked->mark->origin}); } // A false boolean never enters resolution, as the absent state it restates @@ -539,19 +659,24 @@ auto removed_by_tombstone(const std::vector &candidates, } auto resolve_edges(const std::vector &candidates, const bool reverse, - std::vector &edges) -> void { + Facts &facts) -> void { for (const auto &candidate : candidates) { if (candidate.value->is_null() || removed_by_tombstone(candidates, candidate)) { continue; } - add_edge(edges, candidate.value->to_string(), reverse); + if (add_edge(facts.edges, candidate.value->to_string(), reverse)) { + facts.edges_origin = candidate.origin; + if (reverse) { + facts.reverse_origin = candidate.origin; + } + } } } -auto resolve_types(const std::vector &candidates, - std::vector &types) -> void { +auto resolve_types(const std::vector &candidates, Facts &facts) + -> void { for (const auto &candidate : candidates) { if (candidate.value->is_null() || removed_by_tombstone(candidates, candidate)) { @@ -559,11 +684,16 @@ auto resolve_types(const std::vector &candidates, } if (candidate.value->is_array()) { + bool contributed{false}; for (const auto &element : candidate.value->as_array()) { - add_type(types, element.to_string()); + contributed = add_type(facts.types, element.to_string()) || contributed; } - } else { - add_type(types, candidate.value->to_string()); + + if (contributed) { + facts.types_origin = candidate.origin; + } + } else if (add_type(facts.types, candidate.value->to_string())) { + facts.types_origin = candidate.origin; } } } @@ -594,11 +724,13 @@ auto collect(const sourcemeta::blaze::SimpleOutput::AnnotationEntry &entry, Pending &pending) -> void { const auto &keyword{entry.evaluate_path.back()}; const Candidate candidate{.value = &entry.value, - .path = &entry.evaluate_path}; + .path = &entry.evaluate_path, + .origin = &entry.schema_location.get()}; if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { if (entry.value.to_boolean()) { - pending.marks.push_back(&entry.evaluate_path); + pending.marks.push_back({.path = &entry.evaluate_path, + .origin = &entry.schema_location.get()}); } } else if (keyword.property_equals("x-jsonld-id", HASH_ID)) { pending.ids.push_back(candidate); @@ -646,12 +778,13 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &keyword{entry.evaluate_path.back()}; const auto &instance_location{entry.instance_location}; const auto &value{entry.value}; + const auto *const origin{&entry.schema_location.get()}; if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { if (!value.is_boolean()) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::Override, - "The value of x-jsonld-override must be a boolean"); + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Override, + "The value of x-jsonld-override must be a boolean", *origin); } // A mark only matters when it shadows a diverging value or licenses a @@ -666,43 +799,55 @@ auto resolve(const sourcemeta::core::JSON &instance, return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Predicate, reverse ? "The value of x-jsonld-reverse must be an absolute IRI" - : "The value of x-jsonld-id must be an absolute IRI"); + : "The value of x-jsonld-id must be an absolute IRI", + *origin); } if (value.is_null()) { demote(accumulator, dirty, instance_location); } else if (!dirty.contains(instance_location)) { - add_edge(accumulator[instance_location].edges, value.to_string(), - reverse); + auto &facts{accumulator[instance_location]}; + if (add_edge(facts.edges, value.to_string(), reverse)) { + facts.edges_origin = origin; + if (reverse) { + facts.reverse_origin = origin; + } + } } } else if (keyword.property_equals("x-jsonld-type", HASH_TYPE)) { if (value.is_array()) { for (const auto &element : value.as_array()) { if (!is_iri_value(element)) { - return type_iri_error(instance_location); + return type_iri_error(instance_location, *origin); } } } else if (!value.is_null() && !is_iri_value(value)) { - return type_iri_error(instance_location); + return type_iri_error(instance_location, *origin); } if (value.is_null()) { demote(accumulator, dirty, instance_location); } else if (!dirty.contains(instance_location)) { - auto &types{accumulator[instance_location].types}; + auto &facts{accumulator[instance_location]}; if (value.is_array()) { + bool contributed{false}; for (const auto &element : value.as_array()) { - add_type(types, element.to_string()); + contributed = + add_type(facts.types, element.to_string()) || contributed; + } + + if (contributed) { + facts.types_origin = origin; } - } else { - add_type(types, value.to_string()); + } else if (add_type(facts.types, value.to_string())) { + facts.types_origin = origin; } } } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { if (!value.is_null() && !is_iri_value(value)) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, - "The value of x-jsonld-datatype must be an absolute IRI"); + "The value of x-jsonld-datatype must be an absolute IRI", *origin); } if (value.is_null()) { @@ -712,8 +857,9 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &text{value.to_string()}; if (facts.datatype.has_value() && facts.datatype.value() != text) { demote(accumulator, dirty, instance_location); - } else { + } else if (!facts.datatype.has_value()) { facts.datatype = text; + facts.datatype_origin = origin; } } } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { @@ -723,7 +869,8 @@ auto resolve(const sourcemeta::core::JSON &instance, return facet_error(instance_location, sourcemeta::blaze::JSONLDFacet::Language, "The value of x-jsonld-language must be a " - "canonical BCP 47 language tag"); + "canonical BCP 47 language tag", + *origin); } if (value.is_null()) { @@ -733,8 +880,9 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &text{value.to_string()}; if (facts.language.has_value() && facts.language.value() != text) { demote(accumulator, dirty, instance_location); - } else { + } else if (!facts.language.has_value()) { facts.language = text; + facts.language_origin = origin; } } } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { @@ -742,7 +890,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (!value.is_null() && !direction.has_value()) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Direction, - R"(The value of x-jsonld-direction must be "ltr" or "rtl")"); + R"(The value of x-jsonld-direction must be "ltr" or "rtl")", + *origin); } if (value.is_null()) { @@ -751,20 +900,22 @@ auto resolve(const sourcemeta::core::JSON &instance, auto &facts{accumulator[instance_location]}; if (facts.direction.has_value() && facts.direction != direction) { demote(accumulator, dirty, instance_location); - } else { + } else if (!facts.direction.has_value()) { facts.direction = direction; + facts.direction_origin = origin; } } } else if (keyword.property_equals("x-jsonld-json", HASH_JSON) || keyword.property_equals("x-jsonld-graph", HASH_GRAPH)) { const bool graph{keyword.property_equals("x-jsonld-graph", HASH_GRAPH)}; if (!value.is_null() && !value.is_boolean()) { - return facet_error( - instance_location, - graph ? sourcemeta::blaze::JSONLDFacet::Graph - : sourcemeta::blaze::JSONLDFacet::JSON, - graph ? "The value of x-jsonld-graph must be a boolean" - : "The value of x-jsonld-json must be a boolean"); + return facet_error(instance_location, + graph ? sourcemeta::blaze::JSONLDFacet::Graph + : sourcemeta::blaze::JSONLDFacet::JSON, + graph + ? "The value of x-jsonld-graph must be a boolean" + : "The value of x-jsonld-json must be a boolean", + *origin); } // A false declares nothing anywhere, as the absent state it restates @@ -774,9 +925,13 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (value.to_boolean() && !dirty.contains(instance_location)) { auto &facts{accumulator[instance_location]}; if (graph) { - facts.graph = true; - } else { + if (!facts.graph) { + facts.graph = true; + facts.graph_origin = origin; + } + } else if (!facts.json) { facts.json = true; + facts.json_origin = origin; } } } else if (keyword.property_equals("x-jsonld-container", HASH_CONTAINER)) { @@ -784,7 +939,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (!value.is_null() && !container.has_value()) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Container, - R"(The value of x-jsonld-container must be "@list", "@set", "@language", or "@index")"); + R"(The value of x-jsonld-container must be "@list", "@set", "@language", or "@index")", + *origin); } if (value.is_null()) { @@ -793,17 +949,18 @@ auto resolve(const sourcemeta::core::JSON &instance, auto &facts{accumulator[instance_location]}; if (facts.container.has_value() && facts.container != container) { demote(accumulator, dirty, instance_location); - } else { + } else if (!facts.container.has_value()) { facts.container = container; + facts.container_origin = origin; } } } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { if (!value.is_null() && (!value.is_string() || !sourcemeta::core::URITemplate::is_uritemplate(value.to_string()))) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::Self, - "The value of x-jsonld-self must be a URI Template"); + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Self, + "The value of x-jsonld-self must be a URI Template", *origin); } if (value.is_null()) { @@ -813,8 +970,9 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &text{value.to_string()}; if (facts.self.has_value() && facts.self.value() != text) { demote(accumulator, dirty, instance_location); - } else { + } else if (!facts.self.has_value()) { facts.self = text; + facts.self_origin = origin; } } } @@ -853,72 +1011,85 @@ auto resolve(const sourcemeta::core::JSON &instance, } Facts facts; - resolve_edges(entry.ids, false, facts.edges); - resolve_edges(entry.reverses, true, facts.edges); - resolve_types(entry.types, facts.types); + resolve_edges(entry.ids, false, facts); + resolve_edges(entry.reverses, true, facts); + resolve_types(entry.types, facts); const auto datatype{elect(entry.datatypes, key_exact)}; if (datatype.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Datatype, - "A JSON-LD datatype cannot be assigned more than one value"); + "A JSON-LD datatype cannot be assigned more than one value", + datatype); } else if (datatype.winner != nullptr) { facts.datatype = datatype.winner->value->to_string(); + facts.datatype_origin = datatype.winner->origin; } const auto language{elect(entry.languages, key_exact)}; if (language.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Language, - "A JSON-LD language cannot be assigned more than one value"); + "A JSON-LD language cannot be assigned more than one value", + language); } else if (language.winner != nullptr) { facts.language = language.winner->value->to_string(); + facts.language_origin = language.winner->origin; } const auto direction{elect(entry.directions, key_exact)}; if (direction.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Direction, - "A JSON-LD direction cannot be assigned more than one value"); + "A JSON-LD direction cannot be assigned more than one value", + direction); } else if (direction.winner != nullptr) { facts.direction = parse_direction(*direction.winner->value); + facts.direction_origin = direction.winner->origin; } const auto json{elect(entry.jsons, key_boolean)}; if (json.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::JSON, "A JSON-LD JSON literal flag cannot be assigned more than one " - "value"); + "value", + json); } else if (json.winner != nullptr) { facts.json = true; + facts.json_origin = json.winner->origin; } const auto graph{elect(entry.graphs, key_boolean)}; if (graph.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Graph, - "A JSON-LD graph flag cannot be assigned more than one value"); + "A JSON-LD graph flag cannot be assigned more than one value", graph); } else if (graph.winner != nullptr) { facts.graph = true; + facts.graph_origin = graph.winner->origin; } const auto container{elect(entry.containers, key_exact)}; if (container.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD container cannot be assigned more than one value"); + "A JSON-LD container cannot be assigned more than one value", + container); } else if (container.winner != nullptr) { facts.container = parse_container(*container.winner->value); + facts.container_origin = container.winner->origin; } const auto self{elect(entry.selves, key_exact)}; if (self.conflict) { - return facet_error( + return conflict_error( location, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity cannot be assigned more than one value"); + "A JSON-LD self identity cannot be assigned more than one value", + self); } else if (self.winner != nullptr) { facts.self = self.winner->value->to_string(); + facts.self_origin = self.winner->origin; } accumulator.emplace(location, std::move(facts)); @@ -941,10 +1112,26 @@ auto resolve(const sourcemeta::core::JSON &instance, for (const auto &entry : members.as_object()) { auto member{container_location}; member.push_back(std::cref(entry.first)); - if (accumulator.contains(member)) { - return facet_error(member, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD language container member cannot carry a " - "JSON-LD annotation"); + const auto member_facts{accumulator.find(member)}; + if (member_facts != accumulator.cend()) { + const auto &offender{member_facts->second}; + // The null gate applies to members too: a null member is treated as + // absent, so its annotations declare nothing, except a JSON literal, + // where the null is the data itself + if (entry.second.is_null() && !offender.json) { + continue; + } + + return facet_error( + member, sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD language container member cannot carry a " + "JSON-LD annotation", + *container_facts.container_origin, + first_origin({offender.edges_origin, offender.types_origin, + offender.datatype_origin, offender.language_origin, + offender.direction_origin, offender.container_origin, + offender.self_origin, offender.json_origin, + offender.graph_origin})); } } } @@ -952,6 +1139,16 @@ auto resolve(const sourcemeta::core::JSON &instance, sourcemeta::core::JSONLDWeakAnnotationList annotations; annotations.reserve(accumulator.size()); for (auto &[pointer, facts] : accumulator) { + const auto &value{sourcemeta::core::get(instance, pointer)}; + + // A null value is treated as if its entry were absent, and an absent + // entry has no node and no annotations, so every fact at the location + // drops before any placement check, except a JSON literal, where the + // null is the data itself + if (value.is_null() && !facts.json) { + continue; + } + std::ranges::sort(facts.edges, [](const sourcemeta::core::JSONLDEdge &left, const sourcemeta::core::JSONLDEdge &right) -> bool { @@ -960,21 +1157,26 @@ auto resolve(const sourcemeta::core::JSON &instance, }); std::ranges::sort(facts.types); - const auto &value{sourcemeta::core::get(instance, pointer)}; - // A container sets the collection shape and stands alone, excluding every // other fact and choosing the value shape it ranges over if (facts.container.has_value()) { if (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || facts.json || facts.self.has_value()) { - return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD container cannot be combined with any " - "other JSON-LD annotation"); + return facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD container can only be combined with " + "predicate annotations", + *facts.container_origin, + first_origin({facts.types_origin, facts.graph_origin, + facts.datatype_origin, facts.language_origin, + facts.direction_origin, facts.json_origin, + facts.self_origin})); } - if (const auto error{container_placement_error( - pointer, facts.container.value(), value)}; + if (const auto error{ + container_placement_error(pointer, facts.container.value(), value, + *facts.container_origin)}; error.has_value()) { return error.value(); } @@ -986,9 +1188,14 @@ auto resolve(const sourcemeta::core::JSON &instance, (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || facts.self.has_value())) { - return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::JSON, - "A JSON-LD JSON literal cannot be combined with any " - "other JSON-LD annotation"); + return facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::JSON, + "A JSON-LD JSON literal can only be combined with " + "predicate annotations", + *facts.json_origin, + first_origin({facts.types_origin, facts.graph_origin, + facts.datatype_origin, facts.language_origin, + facts.direction_origin, facts.self_origin})); } // A self identity mints an @id, promoting a scalar to a reference and @@ -997,15 +1204,20 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.self.has_value()) { if (facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value()) { - return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity cannot carry a datatype, " - "language, or direction"); + return facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity cannot carry a datatype, " + "language, or direction", + *facts.self_origin, + first_origin({facts.datatype_origin, facts.language_origin, + facts.direction_origin})); } if (value.is_array()) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Self, "A JSON-LD self identity can only be assigned to an " - "object or scalar value"); + "object or scalar value", + *facts.self_origin); } } @@ -1028,7 +1240,8 @@ auto resolve(const sourcemeta::core::JSON &instance, pointer, sourcemeta::blaze::JSONLDFacet::Predicate, pointer.empty() ? "A JSON-LD predicate cannot be assigned to the document root" - : "A JSON-LD predicate cannot be assigned to an array element"); + : "A JSON-LD predicate cannot be assigned to an array element", + *facts.edges_origin); } // A container member belongs to a collection, not a node, so it has no @@ -1041,7 +1254,8 @@ auto resolve(const sourcemeta::core::JSON &instance, parent_facts->second.container.has_value()) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Predicate, - "A JSON-LD predicate cannot be assigned to a container member"); + "A JSON-LD predicate cannot be assigned to a container member", + *facts.edges_origin, *parent_facts->second.container_origin); } } @@ -1058,13 +1272,15 @@ auto resolve(const sourcemeta::core::JSON &instance, if (!points_to_node) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Predicate, "A JSON-LD reverse predicate can only point to a " - "node or an array of nodes"); + "node or an array of nodes", + *facts.reverse_origin); } } std::optional identifier; if (facts.self.has_value()) { - auto expanded{expand_self(pointer, facts.self.value(), value)}; + auto expanded{ + expand_self(pointer, facts.self.value(), value, *facts.self_origin)}; if (std::holds_alternative( expanded)) { return std::get( diff --git a/vendor/core/cmake/common/compiler/options.cmake b/vendor/core/cmake/common/compiler/options.cmake index 20c05eaa9..d0548ad6b 100644 --- a/vendor/core/cmake/common/compiler/options.cmake +++ b/vendor/core/cmake/common/compiler/options.cmake @@ -3,7 +3,7 @@ # Flag categories handled here: # - Diagnostics (-W...): always on, every config # - Language semantics (-fwrapv, -fstrict-aliasing, -fno-rtti on GCC): always on -# - Optimization-related (loop unrolling, vectorization, fast-math relaxations): +# - Optimization-related (loop unrolling, vectorization): # gated to non-Debug configs because they have no effect at -O0 but still # cost Clang/GCC pipeline time # @@ -59,15 +59,13 @@ function(sourcemeta_add_default_options visibility target) # See https://users.cs.utah.edu/~regehr/papers/overflow12.pdf # See https://www.postgresql.org/message-id/1689.1134422394@sss.pgh.pa.us -fwrapv - # Fast-math relaxations relax IEEE conformance (errno after math.h, - # signed-zero handling, reassociation), so they affect observable - # behavior and must apply to every config to keep Debug and Release - # semantics aligned + # Fast-math relaxations, applied to every config to keep Debug and + # Release semantics aligned. Signed zeros and reassociation stay at + # their IEEE defaults: the sign of zero is observable in serialised + # output, and GCC disables reassociation without -fno-signed-zeros -fno-math-errno -fno-trapping-math - -fno-signed-zeros -freciprocal-math - -fassociative-math # Optimization-only: emitted only when not building Debug. At -O0 these # run analyses that never reach codegen, costing build time for no diff --git a/vendor/core/src/core/crypto/crypto_helpers.h b/vendor/core/src/core/crypto/crypto_helpers.h index 96e66a940..366dee45b 100644 --- a/vendor/core/src/core/crypto/crypto_helpers.h +++ b/vendor/core/src/core/crypto/crypto_helpers.h @@ -80,14 +80,21 @@ inline auto octets_below_fixed(const std::string_view value, return borrow == 1; } -// Whether a signature representative, as a big-endian integer, is strictly -// less than the modulus. RFC 8017 Section 5.2.2 requires this range check, so -// that an unreduced signature, which an attacker forges by adding the modulus -// without changing the modular exponentiation result, is rejected -inline auto rsa_signature_in_range(const std::string_view signature, - const std::string_view modulus) noexcept +// Whether a signature is a well-formed representative for the modulus. RFC 8017 +// Section 8.2.2 step 1 and Section 8.1.2 step 1: "If the length of the +// signature S is not k octets, output "invalid signature" and stop". The length +// is checked because the range comparison reads both operands as bare integers, +// so a signature that merely dropped a leading zero octet would denote the same +// value and verify, giving a second encoding of one signature. Section 5.2.2 +// then requires the range check, so that an unreduced signature, which an +// attacker forges by adding the modulus without changing the modular +// exponentiation result, is rejected. The modulus is stripped for the length so +// that a stored ASN.1 sign octet cannot inflate k +inline auto rsa_signature_acceptable(const std::string_view signature, + const std::string_view modulus) noexcept -> bool { - return octets_below(signature, modulus); + return signature.size() == strip_left(modulus, '\x00').size() && + octets_below(signature, modulus); } // Whether an RSA public exponent is acceptable for the modulus: odd and in the diff --git a/vendor/core/src/core/crypto/crypto_pkcs8.h b/vendor/core/src/core/crypto/crypto_pkcs8.h index cf95c1084..e0d6d98f1 100644 --- a/vendor/core/src/core/crypto/crypto_pkcs8.h +++ b/vendor/core/src/core/crypto/crypto_pkcs8.h @@ -61,6 +61,60 @@ struct PKCS8Key { bool rsa_pss_restricted{false}; }; +// Whether a PKCS#1 RSAPrivateKey carries usable components (RFC 8017 Appendix +// A.1.2). The modulus and both exponents are decoded as canonical non-negative +// DER INTEGERs, so a negative or non-canonically encoded one cannot be silently +// reinterpreted as a different positive number and signed with, and the public +// exponent is range-checked per RFC 8017 Section 3.1. The version is required +// to be present as an INTEGER but its value is left to the backends, which +// differ on whether they accept the multi-prime form. Checked here rather than +// in each backend, since only the reference one reads these components itself +// and the rest hand the blob straight to a platform that does not apply the +// rule +inline auto rsa_private_key_acceptable(const std::string_view key) -> bool { + // A canonical RSAPrivateKey is exactly one SEQUENCE, so bytes trailing it + // mark a malformed encoding (X.690 Section 10.1), the same rule the enclosing + // PrivateKeyInfo is held to + const auto sequence{der_read(key)}; + if (!sequence.has_value() || sequence->tag != 0x30 || + !sequence->rest.empty()) { + return false; + } + + const auto version{der_read(sequence->content)}; + if (!version.has_value() || version->tag != 0x02) { + return false; + } + + const auto modulus{der_read(version->rest)}; + if (!modulus.has_value() || modulus->tag != 0x02) { + return false; + } + + const auto public_exponent{der_read(modulus->rest)}; + if (!public_exponent.has_value() || public_exponent->tag != 0x02) { + return false; + } + + const auto private_exponent{der_read(public_exponent->rest)}; + if (!private_exponent.has_value() || private_exponent->tag != 0x02) { + return false; + } + + const auto modulus_value{der_unsigned_integer(modulus->content)}; + const auto public_exponent_value{ + der_unsigned_integer(public_exponent->content)}; + const auto private_exponent_value{ + der_unsigned_integer(private_exponent->content)}; + return modulus_value.has_value() && public_exponent_value.has_value() && + private_exponent_value.has_value() && !modulus_value->empty() && + !private_exponent_value->empty() && + modulus_value->size() <= MAXIMUM_KEY_BYTES && + private_exponent_value->size() <= MAXIMUM_KEY_BYTES && + rsa_public_exponent_acceptable(public_exponent_value.value(), + modulus_value.value()); +} + // Parse an RFC 5958 PrivateKeyInfo, identifying the algorithm from its object // identifier and returning the raw privateKey octets inline auto parse_pkcs8(const std::string_view der) -> std::optional { @@ -106,6 +160,10 @@ inline auto parse_pkcs8(const std::string_view der) -> std::optional { // NOLINTEND(modernize-raw-string-literal) if (oid->content == rsa || oid->content == rsa_pss) { + if (!rsa_private_key_acceptable(private_key->content)) { + return std::nullopt; + } + return PKCS8Key{.kind = PKCS8KeyKind::RSA, .curve = {}, .edwards_curve = {}, diff --git a/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc b/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc index 888ef531d..973e782a7 100644 --- a/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc +++ b/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc @@ -69,6 +69,14 @@ auto rsa_oaep_decrypt(const PrivateKey &key, const RSAOAEPHash hash, return std::nullopt; } + // RFC 8017 Section 7.1.2: "If the length of the ciphertext C is not k octets, + // output "decryption error" and stop". The framework reads the ciphertext as + // a raw integer, so an input with a dropped or added leading zero would + // otherwise decrypt as the same value + if (ciphertext.size() != SecKeyGetBlockSize(internal->key)) { + return std::nullopt; + } + auto ciphertext_data{make_data(ciphertext)}; if (ciphertext_data == nullptr) { return std::nullopt; diff --git a/vendor/core/src/core/crypto/crypto_sign_windows.cc b/vendor/core/src/core/crypto/crypto_sign_windows.cc index d1b6896ee..f23b0b8f9 100644 --- a/vendor/core/src/core/crypto/crypto_sign_windows.cc +++ b/vendor/core/src/core/crypto/crypto_sign_windows.cc @@ -119,13 +119,20 @@ auto native_rsa_private_key(const std::string_view rsa_private_key) -> KeyPair { return {.algorithm = nullptr, .key = nullptr}; } - field = sourcemeta::core::strip_left(element->content, '\x00'); + // Decoded as a canonical non-negative DER INTEGER rather than merely having + // its leading zeros stripped, so a value whose leading octet sets the high + // bit, which X.690 Section 8.3 makes negative, cannot be read as a large + // positive one + const auto value{sourcemeta::core::der_unsigned_integer(element->content)}; // Every field feeds a blob length or padding width, so oversize input is // rejected before it drives a cast or an allocation - if (field.size() > sourcemeta::core::MAXIMUM_KEY_BYTES) { + if (!value.has_value() || + value.value().size() > sourcemeta::core::MAXIMUM_KEY_BYTES) { return {.algorithm = nullptr, .key = nullptr}; } + field = value.value(); + rest = element->rest; } diff --git a/vendor/core/src/core/crypto/crypto_verify_apple.cc b/vendor/core/src/core/crypto/crypto_verify_apple.cc index d70cde04c..5959ee8a7 100644 --- a/vendor/core/src/core/crypto/crypto_verify_apple.cc +++ b/vendor/core/src/core/crypto/crypto_verify_apple.cc @@ -298,7 +298,7 @@ auto rsassa_pkcs1_v15_verify(const PublicKey &key, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } @@ -311,7 +311,7 @@ auto rsassa_pss_verify(const PublicKey &key, const SignatureHashFunction hash, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } diff --git a/vendor/core/src/core/crypto/crypto_verify_openssl.cc b/vendor/core/src/core/crypto/crypto_verify_openssl.cc index a564bf6bd..a3ac20886 100644 --- a/vendor/core/src/core/crypto/crypto_verify_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_verify_openssl.cc @@ -311,7 +311,7 @@ auto rsassa_pkcs1_v15_verify(const PublicKey &key, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } @@ -323,7 +323,7 @@ auto rsassa_pss_verify(const PublicKey &key, const SignatureHashFunction hash, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } diff --git a/vendor/core/src/core/crypto/crypto_verify_windows.cc b/vendor/core/src/core/crypto/crypto_verify_windows.cc index 7490bf165..f6e516697 100644 --- a/vendor/core/src/core/crypto/crypto_verify_windows.cc +++ b/vendor/core/src/core/crypto/crypto_verify_windows.cc @@ -298,7 +298,7 @@ auto rsassa_pkcs1_v15_verify(const PublicKey &key, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } @@ -310,7 +310,7 @@ auto rsassa_pss_verify(const PublicKey &key, const SignatureHashFunction hash, const std::string_view signature) -> bool { const auto *internal{key.internal()}; if (internal == nullptr || internal->kind != PublicKey::Type::RSA || - !rsa_signature_in_range(signature, internal->modulus)) { + !rsa_signature_acceptable(signature, internal->modulus)) { return false; } diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h index f7feb179f..99e3841c8 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h @@ -194,6 +194,11 @@ class SecureString { this->buffer_.reserve(capacity); } + /// The number of bytes that can be held before growing the storage. + [[nodiscard]] auto capacity() const noexcept -> size_type { + return this->buffer_.capacity(); + } + /// Resize to the given number of bytes, padding new ones with the given /// value. auto resize(const size_type count, const char value) -> void { @@ -249,9 +254,20 @@ class SecureString { /// A view over the held bytes. [[nodiscard]] operator std::string_view() const noexcept { + // An empty buffer may expose a null data pointer, so a default view is + // returned rather than constructing one from a possibly-null pointer + if (this->buffer_.empty()) { + return {}; + } + return {this->buffer_.data(), this->buffer_.size()}; } + /// A pointer to the held bytes, valid until the next mutation. + [[nodiscard]] auto data() const noexcept -> const char * { + return this->buffer_.data(); + } + /// Whether the held bytes equal the given view. [[nodiscard]] auto operator==(const std::string_view other) const noexcept -> bool { diff --git a/vendor/core/src/core/email/include/sourcemeta/core/email.h b/vendor/core/src/core/email/include/sourcemeta/core/email.h index 907d98191..e2566a30b 100644 --- a/vendor/core/src/core/email/include/sourcemeta/core/email.h +++ b/vendor/core/src/core/email/include/sourcemeta/core/email.h @@ -21,6 +21,25 @@ namespace sourcemeta::core { /// @ingroup email /// Check whether the given string is a valid `Mailbox` per RFC 5321 /// Section 4.1.2, under the length constraints from Section 4.5.3.1. +/// +/// The domain is validated slightly more strictly than the bare RFC 5321 +/// Section 4.1.2 grammar, and one address-literal case follows the section +/// prose over its ABNF. Both are deliberate stances rather than accidents. +/// +/// A domain label beginning with the `xn--` ACE prefix must be a valid IDNA +/// A-label, not merely a letter-digit-hyphen string. The Section 4.1.2 grammar +/// would accept any such label, but one that does not decode to a real +/// internationalized label can never name a deliverable domain, so it is +/// rejected. This is intentionally stricter than the grammar. +/// +/// An `[IPv6:...]` address literal is validated per RFC 4291. The Section 4.1.3 +/// prose specifies the IPv6 syntax as that of RFC 4291, while the `IPv6-addr` +/// ABNF in the same section is stricter and conflicts with it, so the prose is +/// followed. A bracketed `[IPv6:...]` whose body is not a valid address is +/// still accepted, because Section 4.1.3 also permits any +/// General-address-literal (a registered tag, a colon, and content) and ABNF +/// alternatives are unordered. +/// /// For example: /// /// ```cpp diff --git a/vendor/core/src/core/html/escape.cc b/vendor/core/src/core/html/escape.cc index 5c0bc1c27..0615fd447 100644 --- a/vendor/core/src/core/html/escape.cc +++ b/vendor/core/src/core/html/escape.cc @@ -9,8 +9,20 @@ auto html_escape(std::string &text) -> void { // First pass: count how much space we need std::size_t required_size{0}; - for (char character : text) { - switch (character) { + for (std::string::size_type position{0}; position < text.size(); + position += 1) { + // The no-break space is replaced by its named entity, so its two UTF-8 + // bytes become six output bytes (HTML Living Standard "escaping a string" + // step 2) + if (static_cast(text[position]) == 0xC2 && + position + 1 < text.size() && + static_cast(text[position + 1]) == 0xA0) { + required_size += 6; //   + position += 1; + continue; + } + + switch (text[position]) { case '&': required_size += 5; // & break; @@ -43,6 +55,24 @@ auto html_escape(std::string &text) -> void { while (read_position > 0) { --read_position; + + // The no-break space (its two trailing UTF-8 bytes seen back to + // front) is replaced by its named entity (HTML Living Standard + // "escaping a string" step 2) + if (static_cast(buffer[read_position]) == 0xA0 && + read_position > 0 && + static_cast(buffer[read_position - 1]) == 0xC2) { + --read_position; + write_position -= 6; + buffer[write_position] = '&'; + buffer[write_position + 1] = 'n'; + buffer[write_position + 2] = 'b'; + buffer[write_position + 3] = 's'; + buffer[write_position + 4] = 'p'; + buffer[write_position + 5] = ';'; + continue; + } + const auto character = buffer[read_position]; switch (character) { @@ -96,8 +126,17 @@ auto html_escape(std::string &text) -> void { } static auto needs_escape(const std::string_view input) -> bool { - for (const char character : input) { - switch (character) { + for (std::string_view::size_type position{0}; position < input.size(); + position += 1) { + // The no-break space also requires escaping (HTML Living Standard "escaping + // a string" step 2) + if (static_cast(input[position]) == 0xC2 && + position + 1 < input.size() && + static_cast(input[position + 1]) == 0xA0) { + return true; + } + + switch (input[position]) { case '&': case '<': case '>': @@ -119,8 +158,19 @@ auto html_escape_append(std::string &output, const std::string_view input) return; } - for (const char character : input) { - switch (character) { + for (std::string_view::size_type position{0}; position < input.size(); + position += 1) { + // The no-break space is replaced by its named entity (HTML Living Standard + // "escaping a string" step 2) + if (static_cast(input[position]) == 0xC2 && + position + 1 < input.size() && + static_cast(input[position + 1]) == 0xA0) { + output += " "; + position += 1; + continue; + } + + switch (input[position]) { case '&': output += "&"; break; @@ -137,7 +187,7 @@ auto html_escape_append(std::string &output, const std::string_view input) output += "'"; break; default: - output += character; + output += input[position]; } } } @@ -149,8 +199,19 @@ auto html_escape_append(HTMLBuffer &output, const std::string_view input) return; } - for (const char character : input) { - switch (character) { + for (std::string_view::size_type position{0}; position < input.size(); + position += 1) { + // The no-break space is replaced by its named entity (HTML Living Standard + // "escaping a string" step 2) + if (static_cast(input[position]) == 0xC2 && + position + 1 < input.size() && + static_cast(input[position + 1]) == 0xA0) { + output.append(" "); + position += 1; + continue; + } + + switch (input[position]) { case '&': output.append("&"); break; @@ -167,7 +228,7 @@ auto html_escape_append(HTMLBuffer &output, const std::string_view input) output.append("'"); break; default: - output.append(character); + output.append(input[position]); } } } diff --git a/vendor/core/src/core/html/include/sourcemeta/core/html_escape.h b/vendor/core/src/core/html/include/sourcemeta/core/html_escape.h index b8e560c74..d9aad0bcf 100644 --- a/vendor/core/src/core/html/include/sourcemeta/core/html_escape.h +++ b/vendor/core/src/core/html/include/sourcemeta/core/html_escape.h @@ -16,9 +16,9 @@ namespace sourcemeta::core { /// HTML character escaping implementation per HTML Living Standard. /// See: https://html.spec.whatwg.org/multipage/parsing.html#escapingString /// -/// This function escapes the five HTML special characters in-place: the -/// ampersand, less-than sign, greater-than sign, double quote, and apostrophe -/// each become their corresponding HTML entity. +/// This function escapes the five HTML special characters in-place, the +/// ampersand, less-than sign, greater-than sign, double quote, and apostrophe, +/// along with the no-break space, each becoming its corresponding HTML entity. /// /// For example: /// @@ -35,12 +35,14 @@ auto html_escape(std::string &text) -> void; /// @ingroup html /// Append the HTML-escaped form of `input` directly to `output`, -/// without allocating a temporary string. +/// without allocating a temporary string. The input must not reference the +/// output, since appending to the output may relocate its storage. SOURCEMETA_CORE_HTML_EXPORT auto html_escape_append(std::string &output, std::string_view input) -> void; /// @ingroup html -/// Append the HTML-escaped form of `input` directly to a buffer. +/// Append the HTML-escaped form of `input` directly to a buffer. The input must +/// not reference the buffer, since appending to it may relocate its storage. SOURCEMETA_CORE_HTML_EXPORT auto html_escape_append(HTMLBuffer &output, std::string_view input) -> void; diff --git a/vendor/core/src/core/html/include/sourcemeta/core/html_writer.h b/vendor/core/src/core/html/include/sourcemeta/core/html_writer.h index 777408112..28f2ad576 100644 --- a/vendor/core/src/core/html/include/sourcemeta/core/html_writer.h +++ b/vendor/core/src/core/html/include/sourcemeta/core/html_writer.h @@ -66,7 +66,12 @@ class SOURCEMETA_CORE_HTML_EXPORT HTMLWriter { return *this; } - /// Write HTML-escaped text content + /// Write HTML-escaped text content. The single-argument element shorthand + /// routes through this and therefore also escapes. The HTML serialization + /// emits the content of a raw-text element literally, so escaping its content + /// would corrupt it. This writer does not special-case content by element, so + /// the content of a raw-text element must be written unescaped rather than as + /// escaped text, and it must not contain that element's closing-tag sequence. SOURCEMETA_FORCEINLINE inline auto text(std::string_view content) -> HTMLWriter & { this->flush_open_tag(); @@ -74,7 +79,8 @@ class SOURCEMETA_CORE_HTML_EXPORT HTMLWriter { return *this; } - /// Write raw HTML content (not escaped) + /// Write content without HTML-escaping. This is how the content of a raw-text + /// element is emitted, since escaped text would corrupt it. SOURCEMETA_FORCEINLINE inline auto raw(std::string_view content) -> HTMLWriter & { this->flush_open_tag(); diff --git a/vendor/core/src/core/http/CMakeLists.txt b/vendor/core/src/core/http/CMakeLists.txt index e3fe59f4e..513233b53 100644 --- a/vendor/core/src/core/http/CMakeLists.txt +++ b/vendor/core/src/core/http/CMakeLists.txt @@ -26,7 +26,7 @@ target_link_libraries(sourcemeta_core_http PUBLIC sourcemeta::core::json) target_link_libraries(sourcemeta_core_http PUBLIC sourcemeta::core::text) target_link_libraries(sourcemeta_core_http PRIVATE sourcemeta::core::time) target_link_libraries(sourcemeta_core_http PRIVATE sourcemeta::core::dns) -target_link_libraries(sourcemeta_core_http PRIVATE sourcemeta::core::crypto) +target_link_libraries(sourcemeta_core_http PUBLIC sourcemeta::core::crypto) target_link_libraries(sourcemeta_core_http PRIVATE sourcemeta::core::uri) if(SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL) diff --git a/vendor/core/src/core/http/accept_includes_all.cc b/vendor/core/src/core/http/accept_includes_all.cc index d64145b38..3808b6d4b 100644 --- a/vendor/core/src/core/http/accept_includes_all.cc +++ b/vendor/core/src/core/http/accept_includes_all.cc @@ -19,19 +19,24 @@ auto http_accept_includes_all( } for (const auto media_type : media_types) { assert(!media_type.empty()); - [[maybe_unused]] const auto slash{media_type.find('/')}; + // RFC 9110 §12.5.1: a candidate may itself carry media-type parameters + // (`text/plain;format=flowed`), so the type and its parameters are split + // apart to be matched against each media range separately + const auto [media_type_bare, media_type_parameters] = + http_split_entry(media_type); + [[maybe_unused]] const auto slash{media_type_bare.find('/')}; assert(slash != std::string_view::npos); assert(slash > 0); - assert(slash < media_type.size() - 1); - assert(media_type.find_first_of(" \t,;*") == std::string_view::npos); + assert(slash < media_type_bare.size() - 1); + assert(media_type_bare.find_first_of(" \t,;*") == std::string_view::npos); float best_quality{0.0f}; std::uint8_t best_specificity{0}; - http_for_each_accept_entry( + http_for_each_media_range( accept_header, - [&](const std::string_view value, + [&](const std::string_view value, const std::string_view parameters, const float quality) noexcept -> void { - const std::uint8_t specificity{ - http_media_specificity(value, media_type)}; + const std::uint8_t specificity{http_media_range_specificity( + value, parameters, media_type_bare, media_type_parameters)}; if (specificity == 0) { return; } diff --git a/vendor/core/src/core/http/aws_sigv4.cc b/vendor/core/src/core/http/aws_sigv4.cc index c0329c9f3..5d45e2e56 100644 --- a/vendor/core/src/core/http/aws_sigv4.cc +++ b/vendor/core/src/core/http/aws_sigv4.cc @@ -53,15 +53,16 @@ auto sorted_headers( return entries; } -auto append_lowercased(std::string &output, const std::string_view name) - -> void { +template +auto append_lowercased(Output &output, const std::string_view name) -> void { for (const auto character : name) { output.push_back(sourcemeta::core::to_lowercase(character)); } } +template auto append_canonical_headers( - std::string &output, + Output &output, const std::vector> &entries) -> void { std::size_t index{0}; @@ -85,8 +86,9 @@ auto append_canonical_headers( } } +template auto append_signed_headers( - std::string &output, + Output &output, const std::vector> &entries) -> void { std::string_view previous; @@ -106,7 +108,8 @@ auto append_signed_headers( } } -auto append_canonical_uri(std::string &output, std::string_view path, +template +auto append_canonical_uri(Output &output, std::string_view path, const bool normalize) -> void { std::string normalized; if (normalize) { @@ -139,7 +142,8 @@ auto append_canonical_uri(std::string &output, std::string_view path, } } -auto append_canonical_query(std::string &output, const std::string_view query) +template +auto append_canonical_query(Output &output, const std::string_view query) -> void { if (query.empty()) { return; @@ -182,7 +186,10 @@ auto http_aws_sigv4_canonical_request( headers, const std::string_view payload_hash, const bool normalize) -> std::string { const auto entries{sorted_headers(headers)}; - std::string result; + // A signed header value may live in wiping storage, so the assembly happens + // in wiping storage as well, keeping every intermediate growth buffer out + // of ordinary freed memory. The single returned copy is the caller's to wipe + SecureString result; result.append(method); result.push_back('\n'); append_canonical_uri(result, path, normalize); @@ -194,7 +201,7 @@ auto http_aws_sigv4_canonical_request( append_signed_headers(result, entries); result.push_back('\n'); result.append(payload_hash); - return result; + return std::string{std::string_view{result}}; } auto http_aws_sigv4_signed_headers( @@ -287,9 +294,9 @@ auto HTTPSystemRequest::sign_aws_sigv4( const auto amz_date{to_iso8601_basic(moment)}; const std::string_view date{std::string_view{amz_date}.substr(0, 8)}; - const auto payload_hash{sha256( - this->body_.has_value() ? std::string_view{this->body_.value().data} - : std::string_view{})}; + const auto payload_hash{sha256(this->body_.has_value() + ? this->body_.value().bytes() + : std::string_view{})}; // Drop any signing headers left over from a previous signature so that // re-signing replaces them rather than appending duplicates @@ -314,12 +321,15 @@ auto HTTPSystemRequest::sign_aws_sigv4( } for (const auto &[name, value] : this->headers_) { - headers.emplace_back(name, value); + headers.emplace_back(name, value.bytes()); } - const auto canonical{http_aws_sigv4_canonical_request( + // The canonical request spells out every signed header value, which may + // include one held in wiping storage, so it is wiped once consumed + auto canonical{http_aws_sigv4_canonical_request( http_method_string(this->method_), path, query, headers, payload_hash, service != "s3")}; + const SecureStringScope canonical_scope{canonical}; const auto scope{http_aws_sigv4_credential_scope(date, region, service)}; const auto string_to_sign{ http_aws_sigv4_string_to_sign(amz_date, scope, canonical)}; diff --git a/vendor/core/src/core/http/client_curl.cc b/vendor/core/src/core/http/client_curl.cc index 0b7c89d94..ae4ea0ea8 100644 --- a/vendor/core/src/core/http/client_curl.cc +++ b/vendor/core/src/core/http/client_curl.cc @@ -6,6 +6,7 @@ #include // std::size_t #include // std::uint16_t +#include // std::strlen #include // std::optional #include // std::string #include // std::string_view @@ -33,7 +34,10 @@ using CURLoption = int; using CURLINFO = int; using curl_off_t = long long; -struct curl_slist; +struct curl_slist { + char *data; + curl_slist *next; +}; auto curl_global_init(long flags) -> CURLcode; auto curl_easy_init() -> CURL *; @@ -62,6 +66,7 @@ constexpr CURLoption CURLOPT_WRITEFUNCTION{20011}; // FUNCTIONPOINT + 11 constexpr CURLoption CURLOPT_WRITEDATA{10001}; // CBPOINT + 1 constexpr CURLoption CURLOPT_HEADERFUNCTION{20079}; // FUNCTIONPOINT + 79 constexpr CURLoption CURLOPT_HEADERDATA{10029}; // CBPOINT + 29 +constexpr CURLoption CURLOPT_POST{47}; // LONG + 47 constexpr CURLoption CURLOPT_POSTFIELDSIZE_LARGE{30120}; // OFF_T + 120 constexpr CURLoption CURLOPT_POSTFIELDS{10015}; // OBJECTPOINT + 15 constexpr CURLoption CURLOPT_HTTPHEADER{10023}; // SLISTPOINT + 23 @@ -122,6 +127,15 @@ class CurlHeaderList { explicit CurlHeaderList(const CurlApi &api) : api_{api} {} ~CurlHeaderList() { if (this->list_) { + // libcurl duplicates every appended line and does not clear its copy + // on release, so wipe each one first to keep a secret header value + // out of freed memory + for (auto *entry{this->list_}; entry; entry = entry->next) { + if (entry->data) { + sourcemeta::core::secure_zero(entry->data, std::strlen(entry->data)); + } + } + this->api_.slist_free_all(this->list_); } } @@ -349,14 +363,19 @@ auto HTTPSystemRequest::send() const -> HTTPResponse { CurlHeaderList header_list{api}; for (const auto &[name, value] : this->headers_) { - std::string line{name}; + // Reserve the whole line up front, so appending a possibly secret value + // can never reallocate storage that is wiped only at scope exit + std::string line; + line.reserve(name.size() + value.bytes().size() + 2); + const SecureStringScope line_scope{line}; + line += name; // The semicolon form is how cURL distinguishes a header with an // empty value from a header to suppress - if (value.empty()) { + if (value.bytes().empty()) { line += ";"; } else { line += ": "; - line += value; + line += value.bytes(); } header_list.append(line); @@ -366,10 +385,19 @@ auto HTTPSystemRequest::send() const -> HTTPResponse { std::string content_type_line{"Content-Type: "}; content_type_line += this->body_.value().content_type; header_list.append(content_type_line); - api.easy_setopt(handle.get(), CURLOPT_POSTFIELDSIZE_LARGE, - static_cast(this->body_.value().data.size())); + api.easy_setopt( + handle.get(), CURLOPT_POSTFIELDSIZE_LARGE, + static_cast(this->body_.value().bytes().size())); api.easy_setopt(handle.get(), CURLOPT_POSTFIELDS, - this->body_.value().data.data()); + this->body_.value().bytes().data()); + } else if (this->method_ == HTTPMethod::POST && + !this->header("Content-Type").has_value()) { + // A bodyless POST carries no representation, so libcurl's default + // Content-Type of application/x-www-form-urlencoded, added when the POST + // data is set, is unsolicited. It is suppressed unless the caller supplied + // a Content-Type, matching the other backends. The bare-colon form removes + // an internally generated header + header_list.append("Content-Type:"); } if (header_list.get()) { @@ -379,6 +407,22 @@ auto HTTPSystemRequest::send() const -> HTTPResponse { const std::string method{http_method_string(this->method_)}; if (this->method_ == HTTPMethod::HEAD) { api.easy_setopt(handle.get(), CURLOPT_NOBODY, 1L); + } else if (this->method_ == HTTPMethod::POST) { + // RFC 9110 §15.4.4: a 303 (See Other) redirect makes the user agent + // retrieve the target with GET. Marking the request as a POST with + // CURLOPT_POST, instead of pinning the verb with CURLOPT_CUSTOMREQUEST, + // lets libcurl apply its default 301/302/303 POST-to-GET transition while + // still preserving the method on 307/308, matching the NSURLSession and + // WinHTTP backends. CURLOPT_CUSTOMREQUEST would override that transition + // and wrongly keep POST across a 303 + api.easy_setopt(handle.get(), CURLOPT_POST, 1L); + if (!this->body_.has_value()) { + // A bodyless POST must still pin an empty body, otherwise libcurl reads + // the POST data from its default input source + api.easy_setopt(handle.get(), CURLOPT_POSTFIELDSIZE_LARGE, + static_cast(0)); + api.easy_setopt(handle.get(), CURLOPT_POSTFIELDS, ""); + } } else if (this->method_ != HTTPMethod::GET || this->body_.has_value()) { api.easy_setopt(handle.get(), CURLOPT_CUSTOMREQUEST, method.c_str()); } diff --git a/vendor/core/src/core/http/client_darwin.mm b/vendor/core/src/core/http/client_darwin.mm index 84d4b731c..872c23ac6 100644 --- a/vendor/core/src/core/http/client_darwin.mm +++ b/vendor/core/src/core/http/client_darwin.mm @@ -136,7 +136,7 @@ - (void)URLSession:(NSURLSession *)session for (const auto &[name, value] : this->headers_) { // Repeated headers are folded into a single comma-separated field // line, which is semantically equivalent per RFC 9110 - [url_request addValue:to_nsstring(value) + [url_request addValue:to_nsstring(value.bytes()) forHTTPHeaderField:to_nsstring(name)]; } @@ -144,8 +144,8 @@ - (void)URLSession:(NSURLSession *)session [url_request setValue:to_nsstring(this->body_.value().content_type) forHTTPHeaderField:@"Content-Type"]; url_request.HTTPBody = - [NSData dataWithBytes:this->body_.value().data.data() - length:this->body_.value().data.size()]; + [NSData dataWithBytes:this->body_.value().bytes().data() + length:this->body_.value().bytes().size()]; } NSURLSessionConfiguration *configuration{ diff --git a/vendor/core/src/core/http/client_windows.cc b/vendor/core/src/core/http/client_windows.cc index 609115db2..44a261f41 100644 --- a/vendor/core/src/core/http/client_windows.cc +++ b/vendor/core/src/core/http/client_windows.cc @@ -189,11 +189,16 @@ auto HTTPSystemRequest::send() const -> HTTPResponse { WinHttpSetOption(request_handle.get(), WINHTTP_OPTION_DECOMPRESSION, &decompression, sizeof(decompression)); - auto serialized_headers{http_serialize_headers(this->headers_)}; + // The possibly secret header lines are appended last, so this buffer never + // grows once it holds a secret, which would leave the previous storage in + // freed memory without a wipe + std::string serialized_headers; + const SecureStringScope serialized_headers_scope{serialized_headers}; LPVOID body_data{WINHTTP_NO_REQUEST_DATA}; DWORD body_size{0}; if (this->body_.has_value()) { - if (this->body_.value().data.size() > std::numeric_limits::max()) { + if (this->body_.value().bytes().size() > + std::numeric_limits::max()) { throw HTTPError{this->method_, this->url_, "The request body is too large"}; } @@ -201,20 +206,26 @@ auto HTTPSystemRequest::send() const -> HTTPResponse { serialized_headers += "Content-Type: "; serialized_headers += this->body_.value().content_type; serialized_headers += "\r\n"; - body_data = const_cast(this->body_.value().data.data()); - body_size = static_cast(this->body_.value().data.size()); + body_data = const_cast(this->body_.value().bytes().data()); + body_size = static_cast(this->body_.value().bytes().size()); } - const auto request_headers{ - sourcemeta::core::utf8_to_wide(serialized_headers)}; + { + auto header_lines{http_serialize_headers(this->headers_)}; + const SecureStringScope header_lines_scope{header_lines}; + serialized_headers += header_lines; + } + + auto request_headers{sourcemeta::core::utf8_to_wide(serialized_headers)}; - if (!WinHttpSendRequest( - request_handle.get(), - request_headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS - : request_headers.c_str(), - request_headers.empty() ? 0 - : static_cast(request_headers.size()), - body_data, body_size, body_size, 0)) { + const auto sent{WinHttpSendRequest( + request_handle.get(), + request_headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS + : request_headers.c_str(), + request_headers.empty() ? 0 : static_cast(request_headers.size()), + body_data, body_size, body_size, 0)}; + secure_zero(request_headers.data(), request_headers.size() * sizeof(wchar_t)); + if (!sent) { throw HTTPError{this->method_, this->url_, "Failed to send the HTTP request"}; } diff --git a/vendor/core/src/core/http/helpers.h b/vendor/core/src/core/http/helpers.h index 1d5776646..9b6982e7f 100644 --- a/vendor/core/src/core/http/helpers.h +++ b/vendor/core/src/core/http/helpers.h @@ -86,7 +86,23 @@ inline auto http_for_each_list_entry(const std::string_view header, inline auto http_split_entry(const std::string_view entry) noexcept -> std::pair { std::size_t semicolon{0}; - while (semicolon < entry.size() && entry[semicolon] != ';') { + bool in_quotes{false}; + // RFC 9110 §8.8.3: "etagc = %x21 / %x23-7E / obs-text", so a semicolon + // inside a double-quoted value is content and must not split the entry from + // its parameters + while (semicolon < entry.size()) { + const char current{entry[semicolon]}; + if (in_quotes) { + if (current == '\\' && semicolon + 1 < entry.size()) { + ++semicolon; + } else if (current == '"') { + in_quotes = false; + } + } else if (current == '"') { + in_quotes = true; + } else if (current == ';') { + break; + } ++semicolon; } return {http_trim_trailing_ows(http_subview(entry, 0, semicolon)), @@ -140,6 +156,108 @@ inline auto http_for_each_parameter(const std::string_view parameters, } } +// RFC 9110 §5.6.4: a parameter value is either a token or a quoted-string, and +// §5.6.6 states "The quoted and unquoted values are equivalent". So the decoded +// content is compared rather than the raw syntax, treating charset="utf-8" and +// charset=utf-8 as the same value. Inside a quoted-string a quoted-pair carries +// only its second octet. RFC 2978 §2.3: charset names "are case-insensitive", +// so that one parameter folds case, while all others compare octet for octet +// because their case sensitivity depends on the parameter semantics. +inline auto http_parameter_value_equal(const std::string_view name, + std::string_view left, + std::string_view right) noexcept + -> bool { + const bool fold_case{equals_ignore_case(name, "charset")}; + const bool left_quoted{left.size() >= 2 && left.front() == '"' && + left.back() == '"'}; + const bool right_quoted{right.size() >= 2 && right.front() == '"' && + right.back() == '"'}; + if (left_quoted) { + left = http_subview(left, 1, left.size() - 2); + } + if (right_quoted) { + right = http_subview(right, 1, right.size() - 2); + } + std::size_t left_index{0}; + std::size_t right_index{0}; + while (left_index < left.size() && right_index < right.size()) { + char left_character{left[left_index]}; + if (left_quoted && left_character == '\\' && left_index + 1 < left.size()) { + left_character = left[++left_index]; + } + char right_character{right[right_index]}; + if (right_quoted && right_character == '\\' && + right_index + 1 < right.size()) { + right_character = right[++right_index]; + } + if (fold_case) { + left_character = to_lowercase(left_character); + right_character = to_lowercase(right_character); + } + if (left_character != right_character) { + return false; + } + ++left_index; + ++right_index; + } + return left_index == left.size() && right_index == right.size(); +} + +// RFC 9110 §12.5.1 lets a media range carry media-type parameters, and "a +// parameter value that matches the [media-range] parameter" is required for +// the range to apply. A named parameter of the range is satisfied only when the +// candidate media type carries the same parameter. Parameter names are +// case-insensitive per RFC 9110 §5.6.6, while values are matched by their +// decoded content. +inline auto +http_candidate_has_parameter(const std::string_view candidate_parameters, + const std::string_view name, + const std::string_view value) noexcept -> bool { + bool found{false}; + http_for_each_parameter( + candidate_parameters, + [&](const std::string_view candidate_name, + const std::string_view candidate_value) noexcept -> void { + if (equals_ignore_case(candidate_name, name) && + http_parameter_value_equal(name, candidate_value, value)) { + found = true; + } + }); + return found; +} + +// RFC 9110 §12.5.1: "Media ranges can be overridden by more specific media +// ranges or specific media types. If more than one media range applies to a +// given type, the most specific reference has precedence." The base tier ranks +// */* below type/* below type/subtype, and each media-type parameter the range +// pins that the candidate also carries makes the range strictly more specific. +// A range that pins a parameter the candidate lacks does not match at all. +inline auto http_media_range_specificity( + const std::string_view range, const std::string_view range_parameters, + const std::string_view candidate, + const std::string_view candidate_parameters) noexcept -> std::uint8_t { + const std::uint8_t base{http_media_specificity(range, candidate)}; + if (base == 0) { + return 0; + } + std::uint8_t matched{0}; + bool all_present{true}; + http_for_each_parameter( + range_parameters, + [&](const std::string_view name, + const std::string_view value) noexcept -> void { + if (http_candidate_has_parameter(candidate_parameters, name, value)) { + ++matched; + } else { + all_present = false; + } + }); + if (!all_present) { + return 0; + } + return static_cast(base + matched); +} + // RFC 9110 §12.4.2 q-value. A malformed weight is a fail-safe refusal, so it // is treated as 0 rather than maximal preference. An absent weight is not // routed here and keeps its 1.0 default at the call site. @@ -190,6 +308,66 @@ inline auto http_extract_quality(const std::string_view parameters) noexcept return quality; } +// RFC 9110 §12.5.1: "media-range = ( "*/*" / ( type "/" "*" ) / ( type "/" +// subtype ) ) parameters" followed by an optional "weight". The "q" parameter +// both carries the weight and closes the media-type parameter list, so every +// parameter before "q" is a media-type parameter that participates in matching, +// while "q" and any accept-ext after it do not. This returns the media-type +// parameter span (the prefix up to but excluding the "q" parameter) together +// with the parsed weight, which defaults to 1.0 when no "q" is present. +inline auto http_split_media_range(const std::string_view parameters) noexcept + -> std::pair { + std::size_t position{0}; + std::size_t media_parameters_end{parameters.size()}; + float quality{1.0f}; + while (position < parameters.size()) { + const std::size_t separator{position}; + if (parameters[position] == ';') { + ++position; + } + while (position < parameters.size() && http_is_ows(parameters[position])) { + ++position; + } + std::size_t end_position{position}; + bool in_quotes{false}; + while (end_position < parameters.size()) { + const char current{parameters[end_position]}; + if (in_quotes) { + if (current == '\\' && end_position + 1 < parameters.size()) { + ++end_position; + } else if (current == '"') { + in_quotes = false; + } + } else if (current == '"') { + in_quotes = true; + } else if (current == ';') { + break; + } + ++end_position; + } + const auto raw{http_subview(parameters, position, end_position - position)}; + position = end_position; + if (raw.empty()) { + continue; + } + std::size_t equals{0}; + while (equals < raw.size() && raw[equals] != '=') { + ++equals; + } + const auto name{http_trim_trailing_ows(http_subview(raw, 0, equals))}; + if (name.size() == 1 && (name[0] == 'q' || name[0] == 'Q')) { + media_parameters_end = separator; + const auto value{(equals == raw.size()) + ? std::string_view{} + : http_trim_trailing_ows(http_subview( + raw, equals + 1, raw.size() - equals - 1))}; + quality = http_parse_qvalue(value); + break; + } + } + return {http_subview(parameters, 0, media_parameters_end), quality}; +} + template inline auto http_for_each_accept_entry(const std::string_view header, Visitor visit) -> void { @@ -202,6 +380,24 @@ inline auto http_for_each_accept_entry(const std::string_view header, }); } +// RFC 9110 §12.5.1 media ranges, exposing the media type, its media-type +// parameters, and the weight separately so a parameterized range is matched +// against a candidate's own parameters rather than being collapsed to its type. +template +inline auto http_for_each_media_range(const std::string_view header, + Visitor visit) -> void { + http_for_each_list_entry( + header, [&visit](const std::string_view entry) -> auto { + const auto [value, parameters] = http_split_entry(entry); + if (value.empty()) { + return; + } + const auto [media_parameters, quality] = + http_split_media_range(parameters); + visit(value, media_parameters, quality); + }); +} + template inline auto http_for_each_field_value(const std::string_view header, Visitor visit) -> void { diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http.h b/vendor/core/src/core/http/include/sourcemeta/core/http.h index c59121bea..9a8073e1f 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http.h @@ -104,9 +104,11 @@ auto http_content_type_matches(const std::string_view content_type_header, /// @ingroup http /// Pick the best language-tag candidate against an `Accept-Language` header -/// per RFC 9110 §12.5.4 using the RFC 4647 §3.4 Lookup scheme. Returns an -/// empty value when no candidate is acceptable. The returned view borrows from -/// `candidates`. For example: +/// per RFC 9110 §12.5.4, which lets an implementation choose its matching +/// scheme. This uses a q-aware Basic-Filtering-style scheme (RFC 4647 §3.3.1, +/// the scheme RFC 9110 §12.5.4 points to) that also honors `q=0` exclusions. +/// Returns an empty value when no candidate is acceptable. The returned view +/// borrows from `candidates`. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http_message.h b/vendor/core/src/core/http/include/sourcemeta/core/http_message.h index c5e87526a..8f83ec9cf 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http_message.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http_message.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_CORE_HTTP_MESSAGE_H_ #define SOURCEMETA_CORE_HTTP_MESSAGE_H_ +#include #include #include // std::convertible_to, std::invocable @@ -112,6 +113,13 @@ inline auto http_parse_headers(const std::string_view input, Callback callback) line.remove_suffix(1); } + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value + // MUST either reject the message or replace each of those characters + // with SP", so a continuation carrying a bare one is discarded + if (http_field_line_has_forbidden_byte(line)) { + continue; + } + callback(std::string_view{}, line); continue; } @@ -141,6 +149,15 @@ inline auto http_parse_headers(const std::string_view input, Callback callback) value.remove_suffix(1); } + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP", + // so a field line whose name or value carries a bare one that survived the + // split on the line terminator is discarded + if (http_field_line_has_forbidden_byte(name) || + http_field_line_has_forbidden_byte(value)) { + continue; + } + callback(name, value); } } @@ -151,21 +168,26 @@ inline auto http_parse_headers(const std::string_view input, Callback callback) /// with the name and value. A request cookie header carries only names and /// values, never attributes. Surrounding whitespace is trimmed, values are /// otherwise reported verbatim, and pairs that lack a `=` or have an empty name -/// are skipped. For example: +/// are skipped. Neither argument allocates, as both are borrowed from the +/// input, so anything the callback keeps must not outlive it. For example: /// /// ```cpp /// #include /// #include /// #include /// -/// std::string_view last_value; +/// std::size_t count{0}; /// sourcemeta::core::http_parse_cookies( /// "session=abc; theme=dark", -/// [&last_value](const std::string_view, const std::string_view value) { -/// last_value = value; +/// [&count](const std::string_view, const std::string_view) { +/// count += 1; /// }); -/// assert(last_value == "dark"); +/// assert(count == 2); /// ``` +/// +/// A header may carry several cookies under one name, so a callback that +/// assigns to a single variable keeps whichever happens to come last. Use +/// `http_cookie_values` to look one up by name. template requires std::invocable inline auto http_parse_cookies(const std::string_view input, Callback callback) @@ -199,9 +221,8 @@ inline auto http_parse_cookies(const std::string_view input, Callback callback) /// @ingroup http /// Parse the value of an RFC 6265 §4.2 `Cookie` request header, given without /// the field name, into any container of name and value pairs. The names and -/// values are forwarded as views that borrow from `input`, so a container of -/// `std::string_view` pairs collects them without copying, while a container of -/// `std::string` pairs owns them. For example: +/// values are borrowed from `input` rather than copied, so a container of views +/// must not outlive it, while an owning container may. For example: /// /// ```cpp /// #include @@ -229,6 +250,49 @@ inline auto http_parse_cookies(const std::string_view input, Container &cookies) }); } +/// @ingroup http +/// Collect every value carried under the given cookie name, in the order the +/// header presents them. A request may carry several cookies with one name, +/// since a parent domain and the host itself can each set one and RFC 6265 +/// §4.2.2 notes the server "cannot determine from the Cookie header alone [...] +/// for which hosts the cookie is valid". That section also warns that servers +/// "SHOULD NOT rely upon the order in which these cookies appear", so a caller +/// verifying a signed cookie tries every value rather than any single one. +/// Naming a cookie with the RFC 6265bis §4.1.3.2 `__Host-` prefix prevents the +/// collision at the source. +/// +/// The values are borrowed from `input` rather than copied, so a container of +/// views must not outlive it, while an owning container may. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// std::vector values; +/// sourcemeta::core::http_cookie_values("session=abc; theme=dark; session=xyz", +/// "session", values); +/// assert(values.size() == 2); +/// assert(values.at(0) == "abc"); +/// assert(values.at(1) == "xyz"); +/// ``` +template + requires requires(Container container, std::string_view entry) { + container.emplace_back(entry); + } +inline auto http_cookie_values(const std::string_view input, + const std::string_view name, Container &values) + -> void { + http_parse_cookies(input, + [&name, &values](const std::string_view cookie, + const std::string_view value) -> void { + if (cookie == name) { + values.emplace_back(value); + } + }); +} + /// @ingroup http /// Parse the field lines of a raw message header block, skipping the start /// line, into any container of name and value pairs, normalising names to @@ -302,18 +366,57 @@ template inline auto http_serialize_headers(const Headers &headers) -> std::string { std::size_t total_size{0}; for (const auto &[name, value] : headers) { + // A field dropped for a forbidden byte contributes no bytes, so the reserve + // is sized from only the fields that are actually emitted + if (http_field_line_has_forbidden_byte(name)) { + continue; + } // Account for the colon, the space, and the trailing CRLF - total_size += name.size() + value.size() + 4; + if constexpr (requires { value.bytes(); }) { + if (http_field_line_has_forbidden_byte(value.bytes())) { + continue; + } + total_size += name.size() + value.bytes().size() + 4; + } else { + if (http_field_line_has_forbidden_byte(value)) { + continue; + } + total_size += name.size() + value.size() + 4; + } } std::string result; result.reserve(total_size); for (const auto &[name, value] : headers) { + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP + // before further processing or forwarding of that message", so a field + // whose name carries one is dropped rather than written to the wire as a + // header-injection defense + if (http_field_line_has_forbidden_byte(name)) { + continue; + } + // RFC 9112 §5.1 notes that "a single SP preceding the field line // value is preferred for consistent readability by humans" - result += name; - result += ": "; - result += value; + if constexpr (requires { value.bytes(); }) { + if (http_field_line_has_forbidden_byte(value.bytes())) { + continue; + } + + result += name; + result += ": "; + result += value.bytes(); + } else { + if (http_field_line_has_forbidden_byte(value)) { + continue; + } + + result += name; + result += ": "; + result += value; + } + result += "\r\n"; } diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h b/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h index 467805620..135da803a 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h @@ -180,6 +180,34 @@ inline auto http_encode_quoted_string(const std::string_view value, return true; } +/// @ingroup http +/// Whether a field name or value carries a byte that must never appear on a +/// field line, a carriage return, a line feed, or a NUL (RFC 9110 +/// Section 5.5). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_field_line_has_forbidden_byte("a\r\nb")); +/// assert(!sourcemeta::core::http_field_line_has_forbidden_byte("plain")); +/// ``` +inline auto +http_field_line_has_forbidden_byte(const std::string_view value) noexcept + -> bool { + // RFC 9110 Section 5.5: "a recipient of CR, LF, or NUL within a field value + // MUST either reject the message or replace each of those characters with SP + // before further processing or forwarding of that message", so a field + // carrying one is refused as a header-injection defense + for (const auto character : value) { + if (character == '\r' || character == '\n' || character == '\0') { + return true; + } + } + + return false; +} + /// @ingroup http /// The view with any leading optional whitespace removed (RFC 9110 /// Section 5.6.3). For example: diff --git a/vendor/core/src/core/http/include/sourcemeta/core/http_system.h b/vendor/core/src/core/http/include/sourcemeta/core/http_system.h index 6e2c7431b..fa6496b6d 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http_system.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http_system.h @@ -5,9 +5,11 @@ #include #endif +#include #include #include #include +#include #include #include // std::chrono::milliseconds, std::chrono::seconds @@ -17,6 +19,7 @@ #include // std::string #include // std::string_view #include // std::move, std::pair +#include // std::variant, std::visit #include // std::vector namespace sourcemeta::core { @@ -44,9 +47,10 @@ struct HTTPResponse { /// The response status code HTTPStatus status{}; /// The response headers, with names normalised to lowercase. Repeated - /// headers are preserved as separate entries, except on backends that fold - /// them into a single comma-separated entry, which is semantically - /// equivalent per RFC 9110 + /// headers are preserved as separate entries, though some backends fold them + /// into a single comma-separated entry, which is lossy and incorrect for a + /// `Set-Cookie` header that "cannot be combined into a single field value" + /// (RFC 9110 §5.3) std::vector> headers; /// The response body, owned by this result std::string body; @@ -123,9 +127,55 @@ class SOURCEMETA_CORE_HTTP_EXPORT HTTPSystemRequest { return *this; } - /// Add a request header. Repeated names are permitted + /// A request header value, holding ordinary storage for a plain value and + /// wiping storage for one set from a secret + struct HeaderValue { + /// The held bytes. A plain value carries no secret and keeps the ordinary + /// string storage, while a value set from wiping storage stays in it, so + /// the common request pays nothing and a secret one is never retained in + /// an ordinary string + std::variant data; + + /// Get a view of the held bytes + [[nodiscard]] auto bytes() const -> std::string_view { + return std::visit( + [](const auto &value) -> std::string_view { return value; }, + this->data); + } + }; + + /// Add a request header. Repeated names are permitted. A name or value + /// carrying a carriage return, line feed, or NUL is refused, leaving the + /// request unchanged auto header(std::string name, std::string value) -> HTTPSystemRequest & { - this->headers_.emplace_back(std::move(name), std::move(value)); + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP", + // so a header carrying one is not added as a header-injection defense + if (http_field_line_has_forbidden_byte(name) || + http_field_line_has_forbidden_byte(value)) { + return *this; + } + + this->headers_.emplace_back(std::move(name), + HeaderValue{.data = std::move(value)}); + return *this; + } + + /// Add a request header from wiping storage. The value is held in the wiping + /// storage so a secret it carries, such as a client credential, is never + /// retained in an ordinary string, and the transient serialisation a backend + /// builds at send time is wiped + auto header(std::string name, SecureString value) -> HTTPSystemRequest & { + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP", + // so a header carrying one is not added as a header-injection defense + if (http_field_line_has_forbidden_byte(name) || + http_field_line_has_forbidden_byte(value)) { + return *this; + } + + this->headers_.emplace_back(std::move(name), + HeaderValue{.data = std::move(value)}); return *this; } @@ -144,7 +194,7 @@ class SOURCEMETA_CORE_HTTP_EXPORT HTTPSystemRequest { -> std::optional { for (const auto &[key, value] : this->headers_) { if (equals_ignore_case(key, name)) { - return value; + return value.bytes(); } } @@ -156,8 +206,38 @@ class SOURCEMETA_CORE_HTTP_EXPORT HTTPSystemRequest { return this->headers_; } - /// Set the request body, sent along with the given `Content-Type` header + /// Set the request body, sent along with the given `Content-Type` header. A + /// content type carrying a carriage return, line feed, or NUL is refused, + /// leaving the request unchanged auto body(std::string data, std::string content_type) -> HTTPSystemRequest & { + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP", + // so a content type carrying one is refused, since a backend writes it + // straight into the Content-Type field on the wire + if (http_field_line_has_forbidden_byte(content_type)) { + return *this; + } + + this->body_ = + Body{.data = std::move(data), .content_type = std::move(content_type)}; + return *this; + } + + /// Set the request body from wiping storage, sent along with the given + /// `Content-Type` header. The body is held in the wiping storage so a secret + /// it carries, such as a client secret or PKCE code verifier, is never copied + /// into an ordinary string. A content type carrying a carriage return, line + /// feed, or NUL is refused, leaving the request unchanged + auto body(SecureString data, std::string content_type) + -> HTTPSystemRequest & { + // RFC 9110 §5.5: "a recipient of CR, LF, or NUL within a field value MUST + // either reject the message or replace each of those characters with SP", + // so a content type carrying one is refused, since a backend writes it + // straight into the Content-Type field on the wire + if (http_field_line_has_forbidden_byte(content_type)) { + return *this; + } + this->body_ = Body{.data = std::move(data), .content_type = std::move(content_type)}; return *this; @@ -219,18 +299,30 @@ class SOURCEMETA_CORE_HTTP_EXPORT HTTPSystemRequest { -> HTTPSystemRequest &; /// Perform the request. A failure to obtain a response is reported as an - /// error, while unsuccessful status codes are returned on the result + /// error, while unsuccessful status codes are returned on the result. This + /// blocks the calling thread until the response arrives or a timeout elapses, + /// so it must not run on an event loop or any other thread serving unrelated + /// work. [[nodiscard]] auto send() const -> HTTPResponse; private: struct Body { - std::string data; + // A plain body carries no secret and keeps the ordinary string storage, + // while a body set from wiping storage stays in it, so the common request + // pays nothing and a secret one is never copied into an ordinary string + std::variant data; std::string content_type; + + [[nodiscard]] auto bytes() const -> std::string_view { + return std::visit( + [](const auto &value) -> std::string_view { return value; }, + this->data); + } }; std::string url_; HTTPMethod method_; - std::vector> headers_; + std::vector> headers_; std::optional body_; bool follow_redirects_{true}; std::size_t maximum_redirects_{20}; diff --git a/vendor/core/src/core/http/match_accept.cc b/vendor/core/src/core/http/match_accept.cc index c066e9b3b..5231f81ed 100644 --- a/vendor/core/src/core/http/match_accept.cc +++ b/vendor/core/src/core/http/match_accept.cc @@ -27,18 +27,24 @@ auto http_match_accept(const std::string_view accept_header, std::size_t order{0}; for (const auto candidate : candidates) { assert(!candidate.empty()); - [[maybe_unused]] const auto candidate_slash{candidate.find('/')}; + // RFC 9110 §12.5.1: a candidate may itself carry media-type parameters + // (`text/plain;format=flowed`), so the type and its parameters are split + // apart to be matched against each media range separately + const auto [candidate_type, candidate_parameters] = + http_split_entry(candidate); + [[maybe_unused]] const auto candidate_slash{candidate_type.find('/')}; assert(candidate_slash != std::string_view::npos); assert(candidate_slash > 0); - assert(candidate_slash < candidate.size() - 1); - assert(candidate.find_first_of(" \t,;*") == std::string_view::npos); + assert(candidate_slash < candidate_type.size() - 1); + assert(candidate_type.find_first_of(" \t,;*") == std::string_view::npos); float candidate_quality{0.0f}; std::uint8_t candidate_specificity{0}; - http_for_each_accept_entry( + http_for_each_media_range( accept_header, - [&](const std::string_view value, const float quality) -> void { - const std::uint8_t specificity{ - http_media_specificity(value, candidate)}; + [&](const std::string_view value, const std::string_view parameters, + const float quality) -> void { + const std::uint8_t specificity{http_media_range_specificity( + value, parameters, candidate_type, candidate_parameters)}; if (specificity == 0) { return; } diff --git a/vendor/core/src/core/http/serialize_cookie.cc b/vendor/core/src/core/http/serialize_cookie.cc index 21e342c30..efcc687c6 100644 --- a/vendor/core/src/core/http/serialize_cookie.cc +++ b/vendor/core/src/core/http/serialize_cookie.cc @@ -133,9 +133,12 @@ auto http_cookie_valid(const HTTPCookie &cookie) -> bool { return false; } - // RFC 6265bis §4.1.1 defines the max age attribute value as one or more - // digits, so a negative expiry cannot be serialised. Zero or a positive value - // is valid, and zero is the canonical way to expire a cookie + // RFC 6265 §4.1.1 grammar is "non-zero-digit *DIGIT", which technically + // excludes 0, but RFC 6265 §5.2.2 defines the expire-now semantics of a + // delta-seconds value less than or equal to 0, and Max-Age=0 is the universal + // idiom every user agent honors to delete a cookie immediately (Max-Age=1 is + // not equivalent, as it delays expiry by one second). Only a negative expiry + // is rejected here, since it cannot be serialised if (cookie.max_age.has_value() && cookie.max_age->count() < 0) { return false; } diff --git a/vendor/core/src/core/idna/idna.cc b/vendor/core/src/core/idna/idna.cc index 41926b54a..9a3989dca 100644 --- a/vendor/core/src/core/idna/idna.cc +++ b/vendor/core/src/core/idna/idna.cc @@ -300,13 +300,28 @@ auto idna_is_valid_u_label(const std::u32string_view label) -> bool { } } - // RFC 5890 §2.3.2.1: the corresponding A-label (the "xn--" prefix plus the - // Punycode-encoded body) must not exceed 63 octets - try { - if (4 + utf32_to_punycode(label).size() > MAXIMUM_LABEL_OCTETS) { + // RFC 5890 §2.3.2.1: the 63-octet limit is a property of "a label in A-label + // form". A label whose codepoints are all ASCII stays in that form and is + // measured by its own octet length, whereas a label carrying a non-ASCII + // codepoint is measured by its A-label form (the "xn--" prefix plus the + // Punycode-encoded body) + bool has_non_ascii{false}; + for (const auto codepoint : label) { + if (codepoint > 0x7F) { + has_non_ascii = true; + break; + } + } + + if (has_non_ascii) { + try { + if (4 + utf32_to_punycode(label).size() > MAXIMUM_LABEL_OCTETS) { + return false; + } + } catch (const PunycodeError &) { return false; } - } catch (const PunycodeError &) { + } else if (label.size() > MAXIMUM_LABEL_OCTETS) { return false; } diff --git a/vendor/core/src/core/ip/ip_helpers.h b/vendor/core/src/core/ip/ip_helpers.h index abd5c6cc0..7b94c53a5 100644 --- a/vendor/core/src/core/ip/ip_helpers.h +++ b/vendor/core/src/core/ip/ip_helpers.h @@ -37,6 +37,7 @@ inline auto ipv4_classify_octets(const std::array &octets) const auto first{octets[0]}; const auto second{octets[1]}; const auto third{octets[2]}; + const auto fourth{octets[3]}; // RFC 1122 Section 3.2.1.3: "this host on this network", 0.0.0.0/8 if (first == 0) { @@ -64,6 +65,14 @@ inline auto ipv4_classify_octets(const std::array &octets) return IPAddressClass::LinkLocal; } + // RFC 7723 Section 4.1 and RFC 8155: the anycast addresses 192.0.0.9 and + // 192.0.0.10 are registered as globally reachable, carved out of the + // surrounding not-globally-reachable 192.0.0.0/24 block that follows + if (first == 192 && second == 0 && third == 0 && + (fourth == 9 || fourth == 10)) { + return IPAddressClass::Public; + } + // RFC 6890, RFC 5737, and RFC 2544: special-purpose ranges that are not // globally reachable if ((first == 192 && second == 0 && third == 0) || diff --git a/vendor/core/src/core/ip/ipv6.cc b/vendor/core/src/core/ip/ipv6.cc index e5faac213..880cf5cfd 100644 --- a/vendor/core/src/core/ip/ipv6.cc +++ b/vendor/core/src/core/ip/ipv6.cc @@ -234,6 +234,28 @@ auto ipv6_classify(const std::string_view address) return IPAddressClass::UniqueLocal; } + // RFC 6052 Section 2.1: the NAT64 well-known prefix 64:ff9b::/96 embeds an + // IPv4 address in its low 32 bits. The IANA IPv6 Special-Purpose Address + // Registry marks the prefix as globally reachable, but classifying it as + // Public unconditionally would be a request-forgery hole because the embedded + // address can be loopback or private, so 64:ff9b::7f00:1 embeds 127.0.0.1. It + // is instead classified as its embedded IPv4 address, like the other + // IPv4-embedding forms handled above, so such an address cannot bypass a + // check + if (bytes[0] == 0x00 && bytes[1] == 0x64 && bytes[2] == 0xff && + bytes[3] == 0x9b && bytes[4] == 0 && bytes[5] == 0 && bytes[6] == 0 && + bytes[7] == 0 && bytes[8] == 0 && bytes[9] == 0 && bytes[10] == 0 && + bytes[11] == 0) { + return ipv4_classify_octets(embedded); + } + + // RFC 8215 Section 3: the NAT64 local-use prefix 64:ff9b:1::/48 is a distinct + // block reserved for local NAT64 deployments and is not globally reachable + if (bytes[0] == 0x00 && bytes[1] == 0x64 && bytes[2] == 0xff && + bytes[3] == 0x9b && bytes[4] == 0x00 && bytes[5] == 0x01) { + return IPAddressClass::Reserved; + } + // RFC 4291 Section 2.4: only global unicast, 2000::/3, is globally reachable, // so every other prefix, including the IETF-reserved 0000::/8, is not if ((bytes[0] & 0xe0) != 0x20) { diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h index a9c4529d9..98b9cf826 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h @@ -67,6 +67,9 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKSProvider { /// identifier. std::chrono::seconds unknown_kid_cooldown{std::chrono::minutes{5}}; /// The tolerance applied to time-based claims, uniform or per claim. + /// Unlike the other fields, the default is zero tolerance, so set it + /// deliberately when validating tokens minted by a clock that is not + /// your own. JWTClockSkew clock_skew{}; }; @@ -92,15 +95,33 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKSProvider { /// keys as needed. Returns no value when the token is fully valid, otherwise /// the first failing step. The current time and the clock skew tolerance are /// the provider's own concern, so a caller supplies only what identifies the - /// token: an optional expected subject and an optional expected type for the - /// access token profile. + /// token: the expected subject, or no value to accept any, and the expected + /// `typ` header, or no value to accept any. + /// + /// Neither is defaulted. RFC 9068 Section 4 requires a resource server to + /// "verify that the `typ` header value is `at+jwt` or `application/at+jwt` + /// and reject tokens carrying any other value", and Section 5 explains that + /// this is what keeps an OpenID Connect ID Token from being accepted as an + /// access token. A default of no value would make skipping that check the + /// thing a caller gets for writing less, so the decision is spelled at the + /// call site instead. Prefer `verify_access_token` when the token is an + /// access token. [[nodiscard]] auto verify(const JWT &token, const std::span allowed_algorithms, const std::string_view expected_issuer, const std::string_view expected_audience, - const std::optional expected_subject = std::nullopt, - const std::optional expected_type = std::nullopt) + const std::optional expected_subject, + const std::optional expected_type) + -> std::optional; + + /// Verify a token as an RFC 9068 JWT access token, pinning the `typ` header + /// to `at+jwt` so the profile's Section 4 requirement cannot be omitted. + [[nodiscard]] auto verify_access_token( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject = std::nullopt) -> std::optional; /// Verify a token exactly as the other overload does, additionally exposing diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h index 2788f2cdc..132b87384 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h @@ -79,6 +79,32 @@ struct JWTClockSkew { std::chrono::seconds issued_at{0}; }; +/// @ingroup jose +/// Reduce a caller-supplied clock skew to the grace period token validation +/// honours, one mean Gregorian year, treating a negative tolerance as none. +/// Every path that validates a token applies this, so the same tolerance means +/// the same thing whichever one runs. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// assert(sourcemeta::core::jwt_bounded_clock_skew( +/// std::chrono::seconds{30}) == std::chrono::seconds{30}); +/// assert(sourcemeta::core::jwt_bounded_clock_skew( +/// std::chrono::seconds{-30}) == std::chrono::seconds{0}); +/// ``` +inline auto jwt_bounded_clock_skew(const std::chrono::seconds skew) noexcept + -> std::chrono::seconds { + // A mean Gregorian year, the widest grace period any deployment plausibly + // needs, so an extreme value cannot widen the acceptance window without bound + constexpr std::chrono::seconds maximum{31556952}; + return skew < std::chrono::seconds::zero() ? std::chrono::seconds::zero() + : skew > maximum ? maximum + : skew; +} + /// @ingroup jose /// Validate the registered claims of a JSON Web Token against the expected /// issuer and audience at a given time, returning the first failing check or no @@ -202,6 +228,7 @@ enum class JWTVerificationError : std::uint8_t { /// #include /// #include /// #include +/// #include /// #include /// /// const std::string input{ @@ -214,19 +241,19 @@ enum class JWTVerificationError : std::uint8_t { /// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; /// const auto error{sourcemeta::core::jwt_verify( /// token.value(), keys.value(), allowed, "acme", "client", -/// std::chrono::system_clock::from_time_t(1500000000))}; +/// std::chrono::system_clock::from_time_t(1500000000), {}, std::nullopt, +/// std::nullopt)}; /// assert(error.has_value()); /// ``` SOURCEMETA_CORE_JOSE_EXPORT -auto jwt_verify( - const JWT &token, const JWKS &keys, - const std::span allowed_algorithms, - const std::string_view expected_issuer, - const std::string_view expected_audience, - const std::chrono::system_clock::time_point now, - const JWTClockSkew clock_skew = {}, - const std::optional expected_subject = std::nullopt, - const std::optional expected_type = std::nullopt) +auto jwt_verify(const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::chrono::system_clock::time_point now, + const JWTClockSkew clock_skew, + const std::optional expected_subject, + const std::optional expected_type) -> std::optional; } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwks.cc b/vendor/core/src/core/jose/jose_jwks.cc index 89213a75a..11d5668c6 100644 --- a/vendor/core/src/core/jose/jose_jwks.cc +++ b/vendor/core/src/core/jose/jose_jwks.cc @@ -33,8 +33,11 @@ auto JWKS::parse(const JSON &value, JWKS &result) -> bool { } } - // An empty set, or one whose keys all failed to parse, is not usable - return !result.keys_.empty(); + // RFC 7517 Section 5: the keys member is a required array with no minimum + // size, so an empty set is valid. Having no usable key is a verification-time + // outcome, not a parse error, so a successfully parsed set is accepted here + // even when it carries zero keys + return true; } JWKS::JWKS(const JSON &value) { diff --git a/vendor/core/src/core/jose/jose_jwks_provider.cc b/vendor/core/src/core/jose/jose_jwks_provider.cc index d2293c3e3..528d4051e 100644 --- a/vendor/core/src/core/jose/jose_jwks_provider.cc +++ b/vendor/core/src/core/jose/jose_jwks_provider.cc @@ -134,6 +134,20 @@ auto JWKSProvider::verify( this->clock_()); } +auto JWKSProvider::verify_access_token( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject) + -> std::optional { + // RFC 9068 Section 2.1: "it is RECOMMENDED that the "application/" prefix be + // omitted. Therefore, the "typ" value used SHOULD be "at+jwt"". The + // comparison accepts either spelling, so pinning the short one satisfies + // Section 4 + return this->verify(token, allowed_algorithms, expected_issuer, + expected_audience, expected_subject, "at+jwt"); +} + auto JWKSProvider::verify( const JWT &token, const std::span allowed_algorithms, const std::string_view expected_issuer, diff --git a/vendor/core/src/core/jose/jose_jwt_check_claims.cc b/vendor/core/src/core/jose/jose_jwt_check_claims.cc index 28f702f17..e44cf90f3 100644 --- a/vendor/core/src/core/jose/jose_jwt_check_claims.cc +++ b/vendor/core/src/core/jose/jose_jwt_check_claims.cc @@ -1,6 +1,7 @@ #include -#include // std::clamp +#include + #include // std::chrono::seconds, std::chrono::system_clock #include // std::optional, std::nullopt #include // std::string_view @@ -11,32 +12,19 @@ using Clock = std::chrono::system_clock; // The skew is applied to the server clock rather than the attacker-controlled // claim, so a NumericDate near the representable bound cannot overflow the -// comparison (the two forms are otherwise equivalent). The skew is also -// clamped to a non-negative, bounded grace period and the shift saturates, so -// an extreme caller-supplied clock or skew stays well-defined too -auto skew_ticks(const std::chrono::seconds skew) -> Clock::duration { - return std::chrono::duration_cast(std::clamp( - skew, std::chrono::seconds::zero(), std::chrono::seconds{31556952})); -} - +// comparison (the two forms are otherwise equivalent). The shift itself +// saturates, and the skew is bounded first, so an extreme caller-supplied clock +// or skew stays well-defined too auto shift_backward(const Clock::time_point now, const std::chrono::seconds skew) -> Clock::time_point { - const auto ticks{skew_ticks(skew)}; - if (now.time_since_epoch() < Clock::duration::min() + ticks) { - return Clock::time_point{Clock::duration::min()}; - } - - return now - ticks; + return sourcemeta::core::clock_shift_backward( + now, sourcemeta::core::jwt_bounded_clock_skew(skew)); } auto shift_forward(const Clock::time_point now, const std::chrono::seconds skew) -> Clock::time_point { - const auto ticks{skew_ticks(skew)}; - if (now.time_since_epoch() > Clock::duration::max() - ticks) { - return Clock::time_point{Clock::duration::max()}; - } - - return now + ticks; + return sourcemeta::core::clock_shift_forward( + now, sourcemeta::core::jwt_bounded_clock_skew(skew)); } } // namespace diff --git a/vendor/core/src/core/jose/jose_jwt_sign.cc b/vendor/core/src/core/jose/jose_jwt_sign.cc index 960f2f08c..252b3b87e 100644 --- a/vendor/core/src/core/jose/jose_jwt_sign.cc +++ b/vendor/core/src/core/jose/jose_jwt_sign.cc @@ -26,7 +26,12 @@ namespace sourcemeta::core { auto jwt_sign(const JSON &header, const JSON &payload, const JWKPrivate &key) -> std::optional { - if (!header.is_object() || !payload.is_object()) { + // Header parameter names and claim names must each be unique, so a producer + // does not emit an object carrying duplicates (RFC 7515 Section 4 "The Header + // Parameter names within the JOSE Header MUST be unique" and RFC 7519 Section + // 4 "The Claim Names within a JWT Claims Set MUST be unique") + if (!header.is_object() || !payload.is_object() || !header.unique_keys() || + !payload.unique_keys()) { return std::nullopt; } diff --git a/vendor/core/src/core/jose/jose_jwt_verify.cc b/vendor/core/src/core/jose/jose_jwt_verify.cc index 448b07f2d..b1dc8d4cf 100644 --- a/vendor/core/src/core/jose/jose_jwt_verify.cc +++ b/vendor/core/src/core/jose/jose_jwt_verify.cc @@ -44,8 +44,10 @@ auto jwt_verify(const JWT &token, const JWKS &keys, const std::optional expected_subject, const std::optional expected_type) -> std::optional { - // The algorithm allow-list is enforced before any key is touched, per step 3 - // of the Sourcemeta One validation algorithm + // RFC 8725 Section 3.1: "Libraries MUST enable the caller to specify a + // supported set of algorithms and MUST NOT use any other algorithms when + // performing cryptographic operations", so the allow-list is enforced before + // any key is touched const auto algorithm{token.algorithm()}; if (!algorithm.has_value() || std::ranges::find(allowed_algorithms, algorithm.value()) == diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json.h b/vendor/core/src/core/json/include/sourcemeta/core/json.h index 5044823f4..fb8934af6 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json.h @@ -53,6 +53,13 @@ namespace sourcemeta::core { /// ``` /// /// If parsing fails, sourcemeta::core::JSONParseError will be thrown. +/// +/// A string escape that denotes a lone surrogate, such as `"\uDEAD"`, is +/// rejected. RFC 8259 Section 8.2 considers such input grammar-conforming, but +/// a lone surrogate cannot be represented in valid UTF-8, and every string this +/// parser produces is valid UTF-8. Rejecting it is a deliberate exercise of the +/// RFC 8259 Section 9 allowance that an implementation may set limits on the +/// character contents of strings. SOURCEMETA_CORE_JSON_EXPORT auto parse_json(std::basic_istream &stream) -> JSON; @@ -72,6 +79,13 @@ auto parse_json(std::basic_istream &stream) /// ``` /// /// If parsing fails, sourcemeta::core::JSONParseError will be thrown. +/// +/// A string escape that denotes a lone surrogate, such as `"\uDEAD"`, is +/// rejected. RFC 8259 Section 8.2 considers such input grammar-conforming, but +/// a lone surrogate cannot be represented in valid UTF-8, and every string this +/// parser produces is valid UTF-8. Rejecting it is a deliberate exercise of the +/// RFC 8259 Section 9 allowance that an implementation may set limits on the +/// character contents of strings. SOURCEMETA_CORE_JSON_EXPORT auto parse_json( const std::basic_string_view input) -> JSON; diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h index caa84f2e9..fd0d17679 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h @@ -1270,7 +1270,8 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { /// /// const sourcemeta::core::JSON my_string{ /// sourcemeta::core::parse_json("\"\\uD83D\\uDCA9\"")}; - /// assert(my_string.size() == 2); + /// assert(my_string.size() == 1); + /// assert(my_string.byte_size() == 4); /// ``` [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto byte_size() const -> std::size_t { diff --git a/vendor/core/src/core/json/stringify.h b/vendor/core/src/core/json/stringify.h index 93c5f9290..bb52f485a 100644 --- a/vendor/core/src/core/json/stringify.h +++ b/vendor/core/src/core/json/stringify.h @@ -5,17 +5,17 @@ #include "grammar.h" -#include // std::transform, std::sort -#include // std::array -#include // assert -#include // std::to_chars -#include // std::size_t -#include // std::int64_t -#include // std::next, std::cbegin, std::cend, std::back_inserter -#include // std::basic_ostream -#include // std::ostringstream -#include // std::basic_string -#include // std::vector +#include // std::array +#include // assert +#include // std::to_chars +#include // std::signbit +#include // std::size_t +#include // std::int64_t +#include // std::next, std::cbegin, std::cend, std::back_inserter +#include // std::basic_ostream +#include // std::ostringstream +#include // std::basic_string +#include // std::vector namespace sourcemeta::core::internal { constexpr auto LINE_WIDTH{80}; @@ -76,8 +76,15 @@ auto stringify( const double value, const bool is_integral, std::basic_ostream &stream) -> void { + // RFC 8259 Section 6 permits the -0.0 number syntax and parsing preserves + // the sign of a zero, so serialisation keeps the sign as well and the + // round trip is lossless if (value == static_cast(0.0)) { - stream.write("0.0", 3); + if (std::signbit(value)) { + stream.write("-0.0", 4); + } else { + stream.write("0.0", 3); + } } else if (is_integral) { // Write the integer digits followed by an explicit ".0" to preserve the // real type. Using to_chars rather than a formatted stream keeps the diff --git a/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl.h b/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl.h index 6cdded6f0..fdbf8d05f 100644 --- a/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl.h +++ b/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl.h @@ -17,9 +17,12 @@ /// @defgroup jsonl JSONL /// @brief A JSON Lines (https://jsonlines.org) implementation with iterator -/// support. Each line in a JSONL stream must be a complete, valid JSON value. -/// Lines are separated by newline characters (U+000A). Multi-line JSON values -/// are not supported, as per the JSONL specification. +/// support. Every non-empty line in a JSONL stream is a complete, valid JSON +/// value, and lines are separated by newline characters (U+000A). Multi-line +/// JSON values are not supported, as per the JSONL specification. Blank and +/// whitespace-only lines are skipped rather than treated as errors. JSON Lines +/// is a convention rather than a formal specification, so tolerating stray +/// blank lines is friendlier to real-world input. /// /// This functionality is included as follows: /// diff --git a/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl_iterator.h b/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl_iterator.h index 2ef65cecd..a3cfd5d71 100644 --- a/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl_iterator.h +++ b/vendor/core/src/core/jsonl/include/sourcemeta/core/jsonl_iterator.h @@ -16,7 +16,10 @@ namespace sourcemeta::core { /// @ingroup jsonl -/// A forward iterator to parse JSON documents out of a JSON Lines stream. +/// A forward iterator to parse JSON documents out of a JSON Lines stream. Blank +/// and whitespace-only lines are skipped rather than treated as errors, as JSON +/// Lines is a convention rather than a formal specification and tolerating +/// stray blank lines is friendlier to real-world input. class SOURCEMETA_CORE_JSONL_EXPORT ConstJSONLIterator { public: /// Construct an iterator over the JSON documents in a stream. diff --git a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h index d1eb1e1f5..9ae12344e 100644 --- a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h +++ b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h @@ -137,8 +137,8 @@ using JSONLDWeakAnnotationList = JSONLDBasicAnnotationList; /// /// Materialize an instance into expanded JSON-LD using an annotation list that /// assigns JSON-LD semantics to instance positions. An undescribed member of a -/// collection defaults to a plain literal, or to an unordered collection for a -/// nested array. The result is always a JSON array. For example: +/// collection defaults to a plain literal, or to a collection of the enclosing +/// kind for a nested array. The result is always a JSON array. For example: /// /// ```cpp /// #include @@ -174,8 +174,8 @@ auto jsonld_materialize(const JSON &instance, /// Materialize an instance into expanded JSON-LD using a weak annotation list /// whose positions are non-owning views into strings owned elsewhere. The /// backing strings must outlive the call. An undescribed member of a -/// collection defaults to a plain literal, or to an unordered collection for a -/// nested array. The result is always a JSON array. For example: +/// collection defaults to a plain literal, or to a collection of the enclosing +/// kind for a nested array. The result is always a JSON array. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/jsonld/jsonld.cc b/vendor/core/src/core/jsonld/jsonld.cc index f86877480..1a775036d 100644 --- a/vendor/core/src/core/jsonld/jsonld.cc +++ b/vendor/core/src/core/jsonld/jsonld.cc @@ -49,6 +49,7 @@ auto initialise_expansion(const JSONLDResolver &resolver, ActiveContext &active_context) -> void { state.resolver = &resolver; state.processing_1_0 = version == JSONLDVersion::V1_0; + active_context.processing_1_0 = state.processing_1_0; if (!base_iri.empty()) { active_context.base = JSON::String{base_iri}; state.document_base = JSON::String{base_iri}; diff --git a/vendor/core/src/core/jsonld/jsonld_algorithms.h b/vendor/core/src/core/jsonld/jsonld_algorithms.h index 693f41377..0cc530a8b 100644 --- a/vendor/core/src/core/jsonld/jsonld_algorithms.h +++ b/vendor/core/src/core/jsonld/jsonld_algorithms.h @@ -41,6 +41,10 @@ struct ActiveContext { std::shared_ptr previous; // Whether compaction may relativise absolute IRIs against the base IRI. bool compact_to_relative{true}; + // Whether the document is processed under the JSON-LD 1.0 processing mode, + // which the compaction container selection consults without access to the + // expansion state. + bool processing_1_0{false}; }; // The mutable state shared by the expansion algorithms for the duration of a diff --git a/vendor/core/src/core/jsonld/jsonld_compaction.cc b/vendor/core/src/core/jsonld/jsonld_compaction.cc index 78bf1b8c0..ac960b683 100644 --- a/vendor/core/src/core/jsonld/jsonld_compaction.cc +++ b/vendor/core/src/core/jsonld/jsonld_compaction.cc @@ -54,6 +54,20 @@ auto add_value(JSON &result, const JSON::String &key, JSON &&value, existing.push_back(std::move(value)); } +// A value object carrying a JSON literal whose value is null. +auto is_json_literal_null(const JSON &element) -> bool { + if (!element.is_object()) { + return false; + } + const auto *const contents{element.try_at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)}; + if (contents == nullptr || !contents->is_null()) { + return false; + } + const auto *const type{element.try_at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; + return type != nullptr && type->is_string() && + type->to_string() == KEYWORD_JSON; +} + // The object a property is written into: the result itself, or a nesting // container when the term carries an @nest mapping. auto nest_target(JSON &result, const TermDefinition *const definition, @@ -106,7 +120,12 @@ auto compact(ExpansionState &state, const ActiveContext &active_context, for (const auto &item : element.as_array()) { auto compacted{compact(state, active_context, inverse_context, active_property, item, compact_arrays)}; - if (!compacted.is_null()) { + // A JSON literal holding null compacts to a bare null under a term + // coerced to @json, and that null is data rather than absence, so it + // survives even though the algorithm otherwise drops null items + // (JSON-LD 1.1 API Section 6.1.2: "If compacted item is not null, then + // append it to result"). Dropping it would silently lose the literal. + if (!compacted.is_null() || is_json_literal_null(item)) { result.push_back(std::move(compacted)); } } diff --git a/vendor/core/src/core/jsonld/jsonld_context_processing.cc b/vendor/core/src/core/jsonld/jsonld_context_processing.cc index b7ce348e2..6c7e370f3 100644 --- a/vendor/core/src/core/jsonld/jsonld_context_processing.cc +++ b/vendor/core/src/core/jsonld/jsonld_context_processing.cc @@ -53,12 +53,13 @@ auto process_context(ExpansionState &state, ActiveContext &active_context, } } // Nullifying the context resets to the initial context, whose base is - // the document base. Whether to relativise is a processing option rather - // than context state, so it survives the reset. + // the document base. The relativise option and the processing mode are + // processing state rather than context state, so they survive the reset. const bool relativise{active_context.compact_to_relative}; active_context = ActiveContext{}; active_context.base = state.document_base; active_context.compact_to_relative = relativise; + active_context.processing_1_0 = state.processing_1_0; continue; } diff --git a/vendor/core/src/core/jsonld/jsonld_create_term_definition.cc b/vendor/core/src/core/jsonld/jsonld_create_term_definition.cc index c91cdb6b6..2b098efed 100644 --- a/vendor/core/src/core/jsonld/jsonld_create_term_definition.cc +++ b/vendor/core/src/core/jsonld/jsonld_create_term_definition.cc @@ -340,6 +340,11 @@ auto create_term_definition(ExpansionState &state, if (const auto *container_entry{ value.try_at(KEYWORD_CONTAINER, KEYWORD_CONTAINER_HASH)}) { const auto &container{*container_entry}; + // A reverse property accepts only an @set, @index, or null container, and + // a null container leaves no container mapping (JSON-LD 1.1 API Section + // 4.2) + const bool reverse_null_container{definition.reverse && + container.is_null()}; if (container.is_array()) { // Array containers are a 1.1 feature. if (state.processing_1_0) { @@ -379,7 +384,7 @@ auto create_term_definition(ExpansionState &state, {KEYWORD_CONTAINER}); } definition.container.push_back(container_string); - } else { + } else if (!reverse_null_container) { throw JSONLDError("Invalid container mapping", term_pointer, {KEYWORD_CONTAINER}); } @@ -419,7 +424,7 @@ auto create_term_definition(ExpansionState &state, // single keyword, or @graph with exactly one of @id or @index optionally // with @set, or @set combined with any of @index, @graph, @id, @type, or // @language. - if (definition.container.size() != 1) { + if (!reverse_null_container && definition.container.size() != 1) { const bool graph_form{ container_graph && (container_id != container_index) && !container_list && !container_type && !container_language}; @@ -503,8 +508,12 @@ auto create_term_definition(ExpansionState &state, definition.context_base = state.context_resolution_base(); } + // The @reverse step of Create Term Definition sets the term definition and + // returns before any @prefix processing, so a reverse property never takes + // a prefix flag. (JSON-LD 1.1 API Section 4.2) if (const auto *prefix_entry{ - value.try_at(KEYWORD_PREFIX, KEYWORD_PREFIX_HASH)}) { + value.try_at(KEYWORD_PREFIX, KEYWORD_PREFIX_HASH)}; + prefix_entry != nullptr && !definition.reverse) { if (state.processing_1_0 || term.find(':') != JSON::String::npos || term.find('/') != JSON::String::npos) { throw JSONLDError("Invalid term definition", term_pointer, diff --git a/vendor/core/src/core/jsonld/jsonld_iri_compaction.cc b/vendor/core/src/core/jsonld/jsonld_iri_compaction.cc index 5da2a5026..d16b5e187 100644 --- a/vendor/core/src/core/jsonld/jsonld_iri_compaction.cc +++ b/vendor/core/src/core/jsonld/jsonld_iri_compaction.cc @@ -111,9 +111,10 @@ auto compact_iri(const ActiveContext &active_context, type_language_value = KEYWORD_REVERSE; containers.emplace_back(KEYWORD_SET); containers.emplace_back(KEYWORD_NONE); - // A reverse value that is a node without an explicit index may still - // match a reverse term that declares an index container. - if (!has_index) { + // JSON-LD 1.1 API Section 6.2.3: the @index and @index@set candidates are + // added only when the processing mode is not json-ld-1.0 and the value + // carries no @index entry + if (!active_context.processing_1_0 && !has_index) { containers.emplace_back(KEYWORD_INDEX); containers.emplace_back(JSON::String{KEYWORD_INDEX} + JSON::String{KEYWORD_SET}); @@ -121,19 +122,37 @@ auto compact_iri(const ActiveContext &active_context, } else if (is_graph) { const JSON::String graph{KEYWORD_GRAPH}; const JSON::String set{KEYWORD_SET}; - // A graph object may match any graph container the term declares, mapping - // by @id or @index when present and by @none otherwise. - containers.emplace_back(graph + JSON::String{KEYWORD_ID}); - containers.emplace_back(graph + JSON::String{KEYWORD_ID} + set); - containers.emplace_back(graph + JSON::String{KEYWORD_INDEX}); - containers.emplace_back(graph + JSON::String{KEYWORD_INDEX} + set); - containers.emplace_back(graph); - containers.emplace_back(graph + set); + const bool has_id{value->defines(KEYWORD_ID, KEYWORD_ID_HASH)}; + // Container preference for a graph object: the @graph@index and @graph@id + // variants rank first only when the value carries the matching entry, + // otherwise the plain graph containers rank ahead of them (JSON-LD 1.1 + // API Section 6.2.3) if (has_index) { - containers.emplace_back(KEYWORD_INDEX); - containers.emplace_back(JSON::String{KEYWORD_INDEX} + set); + containers.emplace_back(graph + JSON::String{KEYWORD_INDEX}); + containers.emplace_back(graph + JSON::String{KEYWORD_INDEX} + set); + } + if (has_id) { + containers.emplace_back(graph + JSON::String{KEYWORD_ID}); + containers.emplace_back(graph + JSON::String{KEYWORD_ID} + set); } + containers.emplace_back(graph); + containers.emplace_back(graph + set); containers.emplace_back(KEYWORD_SET); + if (!has_index) { + containers.emplace_back(graph + JSON::String{KEYWORD_INDEX}); + containers.emplace_back(graph + JSON::String{KEYWORD_INDEX} + set); + } + if (!has_id) { + containers.emplace_back(graph + JSON::String{KEYWORD_ID}); + containers.emplace_back(graph + JSON::String{KEYWORD_ID} + set); + } + containers.emplace_back(KEYWORD_INDEX); + containers.emplace_back(JSON::String{KEYWORD_INDEX} + set); + // The graph-object candidate list ends with @none as a last resort, after + // every graph container above, so a graph object still compacts against a + // term with no container mapping when the active context defines no + // graph-container term for its property (W3C JSON-LD 1.1 compaction suite + // cases 0090 through 0094) containers.emplace_back(KEYWORD_NONE); type_language = KEYWORD_TYPE; type_language_value = KEYWORD_ID; @@ -239,33 +258,42 @@ auto compact_iri(const ActiveContext &active_context, } containers.emplace_back(KEYWORD_SET); containers.emplace_back(KEYWORD_NONE); - if (!has_index) { + // A value with no @index entry may also match an @index container, but + // never under the 1.0 processing mode (JSON-LD 1.1 API Section 6.2.3) + if (!active_context.processing_1_0 && !has_index) { containers.emplace_back(KEYWORD_INDEX); containers.emplace_back(JSON::String{KEYWORD_INDEX} + JSON::String{KEYWORD_SET}); } - // A value object carrying only @value also matches a language container - // term as a last resort, after a no-container term has been considered. - if (value->object_size() == 1) { + // JSON-LD 1.1 API Section 6.2.3: a value object carrying only @value also + // matches a language container term as a last resort, but only when the + // processing mode is not json-ld-1.0 + if (!active_context.processing_1_0 && value->object_size() == 1) { containers.emplace_back(KEYWORD_LANGUAGE); containers.emplace_back(language_set); } } else { type_language = KEYWORD_TYPE; type_language_value = KEYWORD_ID; + // Container preference for a node reference or null: the @id and @type + // containers first, then a plain @set, then no container, and an @index + // container last (JSON-LD 1.1 API Section 6.2.3) containers.emplace_back(KEYWORD_ID); containers.emplace_back(JSON::String{KEYWORD_ID} + JSON::String{KEYWORD_SET}); containers.emplace_back(KEYWORD_TYPE); - // The inverse context stores container keys sorted, so @set precedes - // @type. containers.emplace_back(JSON::String{KEYWORD_SET} + JSON::String{KEYWORD_TYPE}); - containers.emplace_back(KEYWORD_INDEX); - containers.emplace_back(JSON::String{KEYWORD_INDEX} + - JSON::String{KEYWORD_SET}); containers.emplace_back(KEYWORD_SET); containers.emplace_back(KEYWORD_NONE); + // JSON-LD 1.1 API Section 6.2.3: the @index and @index@set candidates are + // added only when the processing mode is not json-ld-1.0 and the value + // carries no @index entry + if (!active_context.processing_1_0 && !has_index) { + containers.emplace_back(KEYWORD_INDEX); + containers.emplace_back(JSON::String{KEYWORD_INDEX} + + JSON::String{KEYWORD_SET}); + } } std::vector preferred; @@ -294,8 +322,12 @@ auto compact_iri(const ActiveContext &active_context, } } } else if (value == nullptr) { - // A bare vocabulary reference may match a term that coerces @id or - // @vocab. + // For a bare value the preferred list is the type or language value + // followed by @none (JSON-LD 1.1 API Section 6.2.3). The @vocab candidate + // is not literally listed there for this case, but it is load-bearing: + // the W3C compaction suite case 0114 regresses without it, so a bare + // vocabulary reference must also be allowed to match a term coercing + // @vocab, exactly as the @none candidate is required for the graph branch preferred.emplace_back(KEYWORD_ID); preferred.emplace_back(KEYWORD_VOCAB); preferred.emplace_back(KEYWORD_NONE); diff --git a/vendor/core/src/core/jsonld/jsonld_materialize.cc b/vendor/core/src/core/jsonld/jsonld_materialize.cc index 24bd629a9..baa97db5f 100644 --- a/vendor/core/src/core/jsonld/jsonld_materialize.cc +++ b/vendor/core/src/core/jsonld/jsonld_materialize.cc @@ -12,7 +12,7 @@ #include // std::optional, std::nullopt #include // std::is_same_v #include // std::move, std::unreachable -#include // std::holds_alternative, std::get +#include // std::get, std::get_if #include // std::vector namespace sourcemeta::core { @@ -64,7 +64,8 @@ auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, template auto materialize_member(const JSON &value, PointerT &pointer, const AnnotationRange &range, - std::vector &standalone) -> std::optional; + std::vector &standalone, const bool ordered) + -> std::optional; // Append an object key to the pointer, copying it for an owning pointer and // taking a non-owning view for a weak pointer. @@ -215,7 +216,7 @@ auto build_collection(const JSON &value, PointerT &pointer, pointer.push_back(index); auto element{materialize_member(value.at(index), pointer, child_range(iterator, range.end, pointer), - standalone)}; + standalone, ordered)}; pointer.pop_back(); if (!element.has_value()) { continue; @@ -292,7 +293,18 @@ auto build_language_collection(const JSON &value) -> JSON { return elements; } -// The index keys carry no RDF and are dropped. +auto assign_index(JSON &element, const JSON::String &index) -> void { + if (!element.defines(KEYWORD_INDEX, KEYWORD_INDEX_HASH)) { + element.assign_assume_new(JSON::String{KEYWORD_INDEX}, JSON{index}, + KEYWORD_INDEX_HASH); + } +} + +// Each index map key is retained on its expanded members unless the key is +// the reserved @none (JSON-LD 1.1 API Section 5.1.2: "if container mapping +// includes @index, item does not have an entry @index, and expanded index is +// not @none, add the key-value pair (@index-index) to item"). Only the final +// deserialization to RDF discards the keys. template auto build_index_collection(const JSON &value, PointerT &pointer, const AnnotationRange &range, @@ -303,18 +315,26 @@ auto build_index_collection(const JSON &value, PointerT &pointer, push_property(pointer, key.get()); auto element{materialize_member(value.at(key.get()), pointer, child_range(iterator, range.end, pointer), - standalone)}; + standalone, false)}; pointer.pop_back(); if (!element.has_value()) { continue; } + const bool none{key.get() == KEYWORD_NONE}; + // A nested set flattens into the enclosing collection. if (element->is_array()) { for (auto &nested : element->as_array()) { + if (!none) { + assign_index(nested, key.get()); + } elements.push_back(std::move(nested)); } } else { + if (!none) { + assign_index(element.value(), key.get()); + } elements.push_back(std::move(element.value())); } } @@ -322,13 +342,17 @@ auto build_index_collection(const JSON &value, PointerT &pointer, } // An undescribed collection member still materializes with a default kind, a -// scalar as a plain literal and a nested array as an unordered collection. -// An undescribed object member keeps the anonymous node treatment of any -// other position. +// scalar as a plain literal and a nested array as a collection of the +// enclosing kind, so that arrays nested in a list are themselves lists +// (JSON-LD 1.1 API Section 5.1.2: "If the container mapping of active +// property includes @list, and expanded item is an array, set expanded item +// to a new map containing the entry @list"). An undescribed object member +// keeps the anonymous node treatment of any other position. template auto materialize_member(const JSON &value, PointerT &pointer, const AnnotationRange &range, - std::vector &standalone) -> std::optional { + std::vector &standalone, const bool ordered) + -> std::optional { const auto described{range.begin != range.end && (*range.begin)->pointer.size() == pointer.size()}; if (described || value.is_object()) { @@ -340,7 +364,7 @@ auto materialize_member(const JSON &value, PointerT &pointer, } if (value.is_array()) { - return build_collection(value, pointer, range, standalone, false); + return build_collection(value, pointer, range, standalone, ordered); } return materialize_literal(JSONLDLiteral{}, value); @@ -378,8 +402,13 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, if (inner.object_size() > (descriptor.id.has_value() ? 1 : 0)) { graph.push_back(std::move(inner)); } + // The free-floating drop applies inside a named graph just like at the + // top level (JSON-LD 1.1 API Section 5.1.2: "If active property is null + // or @graph, drop free-floating values"). for (auto &extra : graph_nodes) { - graph.push_back(std::move(extra)); + if (!extra.empty()) { + graph.push_back(std::move(extra)); + } } node.assign_assume_new(JSON::String{KEYWORD_GRAPH}, std::move(graph), KEYWORD_GRAPH_HASH); @@ -400,10 +429,6 @@ auto materialize_value(const JSON &value, PointerT &pointer, *matched_edges = nullptr; } - if (value.is_null()) { - return std::nullopt; - } - // Every annotation in the range extends the current position, so one of // equal length is the annotation of the position itself and sorts first if (range.begin == range.end || @@ -423,20 +448,31 @@ auto materialize_value(const JSON &value, PointerT &pointer, } const auto &descriptor{(*range.begin)->descriptor}; + const auto *literal_descriptor{std::get_if(&descriptor.value)}; + + // A null value is treated as if its entry were absent, except under a JSON + // literal, where the null is the data itself (JSON-LD 1.1 API Section + // 5.1.2: "If the result's @type entry is @json, then the @value entry may + // contain any value, and is treated as a JSON literal"). + if (value.is_null() && + (literal_descriptor == nullptr || !literal_descriptor->json)) { + return std::nullopt; + } + range.begin += 1; if (matched_edges != nullptr) { *matched_edges = &descriptor.edges; } - if (std::holds_alternative(descriptor.value)) { - return materialize_node(std::get(descriptor.value), value, - pointer, range, standalone); + if (const auto *node_descriptor{std::get_if(&descriptor.value)}) { + return materialize_node(*node_descriptor, value, pointer, range, + standalone); } - if (std::holds_alternative(descriptor.value)) { - return materialize_literal(std::get(descriptor.value), - value); + if (literal_descriptor != nullptr) { + return materialize_literal(*literal_descriptor, value); } - if (std::holds_alternative(descriptor.value)) { - return materialize_reference(std::get(descriptor.value)); + if (const auto *reference_descriptor{ + std::get_if(&descriptor.value)}) { + return materialize_reference(*reference_descriptor); } const auto &collection{std::get(descriptor.value)}; @@ -531,20 +567,25 @@ auto materialize_root(const JSON &instance, if (root.has_value()) { // The default graph may only hold node objects. A top-level value or list // object, whether the root itself or an element of a root set, carries no - // triples and is dropped. + // triples and is dropped. An empty top-level node object is free-floating + // and is dropped as well (JSON-LD 1.1 API Section 5.1.2: "If result is a + // map which is empty, or contains only the entries @value or @list, set + // result to null"). if (root->is_array()) { for (auto &element : root->as_array()) { - if (is_node_object(element)) { + if (is_node_object(element) && !element.empty()) { result.push_back(std::move(element)); } } - } else if (is_node_object(root.value())) { + } else if (is_node_object(root.value()) && !root->empty()) { result.push_back(std::move(root.value())); } } for (auto &node : standalone) { - result.push_back(std::move(node)); + if (!node.empty()) { + result.push_back(std::move(node)); + } } return result; diff --git a/vendor/core/src/core/jsonld/jsonld_serialise.h b/vendor/core/src/core/jsonld/jsonld_serialise.h index b186878ca..932e757db 100644 --- a/vendor/core/src/core/jsonld/jsonld_serialise.h +++ b/vendor/core/src/core/jsonld/jsonld_serialise.h @@ -8,6 +8,7 @@ #include // std::array #include // assert #include // std::to_chars, std::from_chars, std::chars_format +#include // std::signbit #include // std::size_t #include // std::int32_t #include // std::optional, std::nullopt @@ -41,11 +42,13 @@ inline auto is_floating_point_datatype(const JSON::StringView datatype) // "rounded to 15 digits after the decimal point" with trailing zeros dropped // down to a single digit after the required decimal point, the exponent // carries no plus sign or leading zeros, and "the canonical representation -// for zero is 0.0E0". The form is assembled in the conversion buffer itself +// for zero is 0.0E0". The value space distinguishes the two zeros, and the +// canonical mapping returns "'-0.0E0' when f is negativeZero" (XSD 1.1 Part +// 2 Section 3.3.5). The form is assembled in the conversion buffer itself // so the function performs at most a single allocation inline auto scientific_lexical_form(const double value) -> JSON::String { if (value == 0.0) { - return "0.0E0"; + return std::signbit(value) ? "-0.0E0" : "0.0E0"; } std::array buffer{}; diff --git a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_token.h b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_token.h index 4ca4b6dc5..e6e89be36 100644 --- a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_token.h +++ b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_token.h @@ -37,6 +37,20 @@ template class GenericToken { /// ``` GenericToken(const Property &value) : GenericToken{value, hasher(value)} {} + /// This constructor creates an JSON Pointer token by taking ownership of a + /// string. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// std::string name{"foo"}; + /// const sourcemeta::core::Pointer::Token token{std::move(name)}; + /// ``` + GenericToken(Property &&value) + : as_property{true}, property{std::move(value)}, hash{hasher(property)}, + index{0} {} + /// This constructor creates an JSON Pointer token from a string. For /// example: /// diff --git a/vendor/core/src/core/jsonpointer/parser.h b/vendor/core/src/core/jsonpointer/parser.h index fa1bc4470..077a32a0f 100644 --- a/vendor/core/src/core/jsonpointer/parser.h +++ b/vendor/core/src/core/jsonpointer/parser.h @@ -15,6 +15,7 @@ #include // std::string #include // std::errc #include // std::conditional_t +#include // std::move namespace sourcemeta::core::internal { template > &stream) template typename Allocator> -inline auto -parse_index(std::basic_stringstream> &stream, - const std::uint64_t column) -> std::size_t { - const auto input = stream.str(); +inline auto emplace_index_or_property( + Pointer &result, + std::basic_stringstream> &stream, + const std::uint64_t column) -> void { + auto input = stream.str(); std::size_t index_value{}; - const auto result = + const auto conversion = std::from_chars(input.data(), input.data() + input.size(), index_value); - if (result.ec != std::errc{}) [[unlikely]] { + // RFC 6901 Section 3 treats any reference token as a valid pointer, and + // RFC 6901 Section 4 only interprets a token as an array index when its + // numeric value fits the representation of an index. An all-digit token whose + // value exceeds the platform index type still names a valid object member, so + // it becomes a property token that resolves against an object and yields + // not-found against an array. This keeps a huge index parseable, matching the + // check-only path, rather than reporting a parse error + if (conversion.ec == std::errc::result_out_of_range) [[unlikely]] { + result.emplace_back(std::move(input)); + return; + } + + if (conversion.ec != std::errc{}) [[unlikely]] { throw PointerParseError(column); } - return index_value; + result.emplace_back(index_value); } } // namespace sourcemeta::core::internal @@ -139,7 +153,7 @@ auto parse_pointer(std::basic_istream &stream) column += 1; stream.ignore(); if constexpr (!CheckOnly) { - result.emplace_back(internal::parse_index(string, column)); + internal::emplace_index_or_property(result, string, column); internal::reset(string); } goto done; @@ -150,7 +164,7 @@ auto parse_pointer(std::basic_istream &stream) column += 1; stream.ignore(); if constexpr (!CheckOnly) { - result.emplace_back(internal::parse_index(string, column)); + internal::emplace_index_or_property(result, string, column); internal::reset(string); } goto parse_token_content; @@ -164,7 +178,7 @@ auto parse_pointer(std::basic_istream &stream) column += 1; stream.ignore(); if constexpr (!CheckOnly) { - result.emplace_back(internal::parse_index(string, column)); + internal::emplace_index_or_property(result, string, column); internal::reset(string); } goto done; @@ -175,7 +189,7 @@ auto parse_pointer(std::basic_istream &stream) column += 1; stream.ignore(); if constexpr (!CheckOnly) { - result.emplace_back(internal::parse_index(string, column)); + internal::emplace_index_or_property(result, string, column); internal::reset(string); } goto parse_token_content; diff --git a/vendor/core/src/core/jsonpointer/position.cc b/vendor/core/src/core/jsonpointer/position.cc index 648ee18fb..52fe848ca 100644 --- a/vendor/core/src/core/jsonpointer/position.cc +++ b/vendor/core/src/core/jsonpointer/position.cc @@ -2,7 +2,9 @@ #include #include // std::count_if +#include // std::array #include // assert +#include // std::to_chars #include // std::size_t #include // std::uint64_t #include // std::optional @@ -114,9 +116,23 @@ auto PointerPositionTracker::get(const Pointer &pointer) const const auto &children{this->trie[node].index_children}; const auto iterator{children.find(token.to_index())}; if (iterator == children.end()) { - return std::nullopt; + // If the currently referenced value is a JSON object, the new + // referenced value is the object member with the name identified by the + // reference token. + // See https://www.rfc-editor.org/rfc/rfc6901#section-4 + const auto &properties{this->trie[node].property_children}; + std::array buffer{}; + const auto [end_pointer, error_code] = std::to_chars( + buffer.data(), buffer.data() + buffer.size(), token.to_index()); + const auto property_iterator{ + properties.find(std::string_view{buffer.data(), end_pointer})}; + if (property_iterator == properties.end()) { + return std::nullopt; + } + node = property_iterator->second; + } else { + node = iterator->second; } - node = iterator->second; } } diff --git a/vendor/core/src/core/oauth/CMakeLists.txt b/vendor/core/src/core/oauth/CMakeLists.txt index 4d02fc690..8b96bf667 100644 --- a/vendor/core/src/core/oauth/CMakeLists.txt +++ b/vendor/core/src/core/oauth/CMakeLists.txt @@ -26,6 +26,8 @@ target_link_libraries(sourcemeta_core_oauth PRIVATE sourcemeta::core::text) target_link_libraries(sourcemeta_core_oauth PRIVATE sourcemeta::core::html) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::time) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth) diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h index 69c857708..8a00062da 100644 --- a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h @@ -60,6 +60,63 @@ auto oauth_well_known_url(const std::string_view identifier, const OAuthWellKnownKind kind, std::string &sink) -> bool; +/// @ingroup oauth +/// Whether a URL is usable as an endpoint a client sends requests to: the https +/// scheme, compared case-insensitively per RFC 3986 Section 3.1, a non-empty +/// host, and no fragment, which RFC 6749 Section 3.1 forbids on an endpoint. A +/// query is permitted. This is the rule the metadata parsers apply, named for +/// the endpoint case rather than as a general https test, since the fragment +/// prohibition comes from the endpoint specifications and does not hold of +/// https URLs at large. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_endpoint_url("https://example.com/token")); +/// assert(!sourcemeta::core::oauth_is_endpoint_url("http://example.com/token")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_endpoint_url(const std::string_view value) -> bool; + +/// @ingroup oauth +/// Whether a value is a valid protected resource identifier: the `https` +/// scheme by exact code points, a non-empty host, and no fragment, a query +/// tolerated (RFC 9728 Section 1.2, RFC 8707 Section 2). This is the rule the +/// resource metadata builder and parser apply, so a caller assembling a +/// document can pre-filter with it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_resource_identifier( +/// "https://api.example.com/mcp?tenant=1")); +/// assert(!sourcemeta::core::oauth_is_resource_identifier( +/// "http://api.example.com/mcp")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_resource_identifier(const std::string_view value) -> bool; + +/// @ingroup oauth +/// Whether a value is a valid authorization server issuer identifier: the +/// `https` scheme by exact code points, a non-empty host, and no query or +/// fragment (RFC 8414 Section 2). This is the rule the metadata builders +/// apply to `issuer` and to `authorization_servers` entries, so a caller +/// assembling a document can pre-filter with it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_issuer_identifier( +/// "https://auth.example.com/tenant")); +/// assert(!sourcemeta::core::oauth_is_issuer_identifier( +/// "https://auth.example.com/?tenant=1")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_issuer_identifier(const std::string_view value) -> bool; + #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif @@ -117,6 +174,10 @@ class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata { [[nodiscard]] auto registration_endpoint() const -> std::optional; + /// The device authorization endpoint (RFC 8628 Section 4). + [[nodiscard]] auto device_authorization_endpoint() const + -> std::optional; + /// The token revocation endpoint (RFC 8414 Section 2). [[nodiscard]] auto revocation_endpoint() const -> std::optional; @@ -163,6 +224,11 @@ class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata { supports_token_endpoint_auth_method(const std::string_view value) const -> bool; + /// Whether a protected resource is listed as usable with this authorization + /// server (RFC 9728 Section 4). + [[nodiscard]] auto + supports_protected_resource(const std::string_view value) const -> bool; + /// The underlying document, for reaching members without a typed accessor. [[nodiscard]] auto data() const -> const JSON &; @@ -179,9 +245,10 @@ class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata { /// well-known URL was derived from, or, for the `WWW-Authenticate` /// `resource_metadata` flow, the URL the request was made to (the Section 3.3 /// second check). Only the plain JSON members are read, so a `signed_metadata` -/// statement (RFC 9728 Section 2.2) is not processed. A string accessor returns -/// a view into the owned document, valid for the lifetime of this object. For -/// example: +/// statement is not processed, which RFC 9728 Section 2.2 permits by making +/// its precedence conditional on a consumer that "supports signed metadata". +/// A string accessor returns a view into the owned document, valid for the +/// lifetime of this object. For example: /// /// ```cpp /// #include @@ -239,6 +306,43 @@ class SOURCEMETA_CORE_OAUTH_EXPORT OAuthResourceMetadata { /// false when absent (RFC 9728 Section 2). [[nodiscard]] auto dpop_bound_access_tokens_required() const -> bool; + /// The human-readable name of the resource without a language tag (RFC 9728 + /// Section 2), reaching a language-tagged variant through the underlying + /// document. + [[nodiscard]] auto resource_name() const -> std::optional; + + /// The developer documentation page location (RFC 9728 Section 2). + [[nodiscard]] auto resource_documentation() const + -> std::optional; + + /// The data usage policy page location (RFC 9728 Section 2). + [[nodiscard]] auto resource_policy_uri() const + -> std::optional; + + /// The terms of service page location (RFC 9728 Section 2). + [[nodiscard]] auto resource_tos_uri() const + -> std::optional; + + /// Whether the resource supports mutual-TLS certificate-bound access tokens, + /// defaulting to false when absent (RFC 9728 Section 2). + [[nodiscard]] auto tls_client_certificate_bound_access_tokens() const -> bool; + + /// Whether a JWS algorithm is listed for signing resource responses + /// (RFC 9728 Section 2). + [[nodiscard]] auto + supports_resource_signing_alg(const std::string_view value) const -> bool; + + /// Whether a JWS algorithm is listed for validating DPoP proofs (RFC 9728 + /// Section 2). + [[nodiscard]] auto + supports_dpop_signing_alg(const std::string_view value) const -> bool; + + /// Whether an authorization details type is listed for the resource + /// (RFC 9728 Section 2). + [[nodiscard]] auto + supports_authorization_details_type(const std::string_view value) const + -> bool; + /// The underlying document, for reaching members without a typed accessor /// such as the internationalized names. [[nodiscard]] auto data() const -> const JSON &; @@ -291,6 +395,9 @@ struct OAuthServerMetadataConfig { /// authorization request endpoint, emitted only when true (RFC 9126 /// Section 5). bool require_pushed_authorization_requests{false}; + /// The protected resources usable with this authorization server, each a + /// valid resource identifier (RFC 9728 Section 4). + std::span protected_resources; }; /// @ingroup oauth @@ -324,6 +431,87 @@ SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) -> std::optional; +/// @ingroup oauth +/// The configuration a protected resource publishes as its metadata (RFC 9728 +/// Section 2), each field a non-owning view. An empty scalar and a +/// zero-element array are omitted, since RFC 9728 Section 3.2 forbids a +/// zero-element array in the response. The one exception is the bearer method +/// list, whose engaged empty state is emitted as an empty array, the form +/// RFC 9728 Section 2 gives for a resource that supports no bearer method, +/// distinct from the unspecified absent state. +struct OAuthResourceMetadataConfig { + /// The resource identifier (RFC 9728 Section 2), REQUIRED. + std::string_view resource; + /// The authorization server issuer identifiers that can issue tokens for the + /// resource (RFC 9728 Section 2). + std::span authorization_servers; + /// The JWK Set document location for the resource's own keys (RFC 9728 + /// Section 2). + std::string_view jwks_uri; + /// The scopes used in authorization requests for the resource (RFC 9728 + /// Section 2). + std::span scopes_supported; + /// The supported bearer token presentation methods (RFC 9728 Section 2), + /// where no value omits the member and an engaged empty list advertises that + /// no bearer method is supported. + std::optional> bearer_methods_supported; + /// The supported JWS algorithms for signing resource responses (RFC 9728 + /// Section 2), which must exclude `none`. + std::span resource_signing_alg_values_supported; + /// The human-readable name of the resource without a language tag (RFC 9728 + /// Sections 2 and 2.1), a language-tagged variant assigned by the caller on + /// the returned document. + std::string_view resource_name; + /// The developer documentation page location (RFC 9728 Section 2). + std::string_view resource_documentation; + /// The data usage policy page location (RFC 9728 Section 2). + std::string_view resource_policy_uri; + /// The terms of service page location (RFC 9728 Section 2). + std::string_view resource_tos_uri; + /// Whether the resource supports mutual-TLS certificate-bound access tokens, + /// emitted only when true since the default when absent is false (RFC 9728 + /// Section 2). + bool tls_client_certificate_bound_access_tokens{false}; + /// The supported authorization details types (RFC 9728 Section 2). + std::span authorization_details_types_supported; + /// The supported JWS algorithms for validating DPoP proofs (RFC 9728 + /// Section 2), which must exclude `none` and the MAC algorithms a proof may + /// never use (RFC 9449 Section 4.2). + std::span dpop_signing_alg_values_supported; + /// Whether the resource requires DPoP-bound access tokens, emitted only when + /// true since the default when absent is false (RFC 9728 Section 2). + bool dpop_bound_access_tokens_required{false}; +}; + +/// @ingroup oauth +/// Build a protected resource metadata document for the well-known endpoint +/// (RFC 9728 Section 2), returning no value when the document would be +/// unusable: the resource is not a valid resource identifier, an authorization +/// server entry is not a valid issuer identifier, the JWK Set location is not +/// a valid https URL, a human-readable page location is not a URL, the +/// resource signing algorithm list contains `none`, or the DPoP algorithm +/// list contains `none` or a MAC algorithm. Every produced document parses +/// back through its own consumer for the same resource. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array servers{{"https://auth.example.com"}}; +/// sourcemeta::core::OAuthResourceMetadataConfig config; +/// config.resource = "https://api.example.com"; +/// config.authorization_servers = servers; +/// const auto document{sourcemeta::core::oauth_make_resource_metadata(config)}; +/// assert(document.has_value()); +/// assert(document.value().at("resource").to_string() == +/// "https://api.example.com"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_resource_metadata(const OAuthResourceMetadataConfig &config) + -> std::optional; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/oauth/oauth_assertion.cc b/vendor/core/src/core/oauth/oauth_assertion.cc index 944c142ce..e577309a7 100644 --- a/vendor/core/src/core/oauth/oauth_assertion.cc +++ b/vendor/core/src/core/oauth/oauth_assertion.cc @@ -137,7 +137,9 @@ auto verify_assertion( if (identifier.has_value() && expiration.has_value()) { // The skew is clamped to the same non-negative bounded range the claim // check applies, so a negative value cannot shorten the window below the - // acceptance window and a large one cannot overflow the store's expiry + // acceptance window. The window as a whole is still attacker-influenced, + // since the remaining lifetime comes from the token's own expiration, so + // the store saturates rather than trusting it to fit const auto skew{std::clamp(options.clock_skew, std::chrono::seconds::zero(), std::chrono::seconds{31556952})}; diff --git a/vendor/core/src/core/oauth/oauth_authorization.cc b/vendor/core/src/core/oauth/oauth_authorization.cc index f9dca2026..32094802d 100644 --- a/vendor/core/src/core/oauth/oauth_authorization.cc +++ b/vendor/core/src/core/oauth/oauth_authorization.cc @@ -1,12 +1,12 @@ #include #include +#include #include #include #include "oauth_authorization_parse.h" #include "oauth_decode.h" -#include "oauth_syntax.h" #include // std::function #include // std::optional, std::nullopt diff --git a/vendor/core/src/core/oauth/oauth_decode.h b/vendor/core/src/core/oauth/oauth_decode.h index 9765f8772..4a9d2a5f3 100644 --- a/vendor/core/src/core/oauth/oauth_decode.h +++ b/vendor/core/src/core/oauth/oauth_decode.h @@ -53,6 +53,15 @@ inline auto oauth_form_decode_into(const std::string_view value, inline auto oauth_form_decode_into_secure(const std::string_view value, SecureString &arena, std::string_view &result) -> bool { + // The same reservation contract the borrowing variant enforces, checked the + // same way. The assert catches a violation loudly under test, and the guard + // fails closed rather than dangle a view into the arena holding the secrets + // if the contract is ever broken in a release build + assert(arena.capacity() - arena.size() >= value.size()); + if (arena.capacity() - arena.size() < value.size()) { + return false; + } + const auto base{arena.size()}; if (!URI::unescape_form(value, arena)) { return false; diff --git a/vendor/core/src/core/oauth/oauth_dpop.cc b/vendor/core/src/core/oauth/oauth_dpop.cc index 754c4f399..4a5f441fc 100644 --- a/vendor/core/src/core/oauth/oauth_dpop.cc +++ b/vendor/core/src/core/oauth/oauth_dpop.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include "oauth_json.h" #include "oauth_syntax.h" @@ -322,8 +323,8 @@ auto oauth_dpop_verify(const std::string_view proof, } // Check 11: the creation time is within the acceptable window - if (issued.value() < now - options.past_window || - issued.value() > now + options.future_window) { + if (issued.value() < clock_shift_backward(now, options.past_window) || + issued.value() > clock_shift_forward(now, options.future_window)) { return OAuthDPoPError::Expired; } @@ -403,7 +404,8 @@ auto OAuthDPoPReplayStore::check_and_insert( return false; } - this->entries_.push_back(Entry{.digest = digest, .expiry = now + window}); + this->entries_.push_back( + Entry{.digest = digest, .expiry = clock_shift_forward(now, window)}); return true; } diff --git a/vendor/core/src/core/oauth/oauth_metadata.cc b/vendor/core/src/core/oauth/oauth_metadata.cc index 5d9d5ddae..456e3d330 100644 --- a/vendor/core/src/core/oauth/oauth_metadata.cc +++ b/vendor/core/src/core/oauth/oauth_metadata.cc @@ -6,7 +6,7 @@ #include "oauth_syntax.h" -#include // std::ranges::find +#include // std::ranges::find, std::ranges::any_of, std::ranges::all_of #include // std::optional, std::nullopt #include // std::span #include // std::string @@ -29,6 +29,8 @@ constexpr auto HASH_PAR_ENDPOINT{ JSON::Object::hash("pushed_authorization_request_endpoint"sv)}; constexpr auto HASH_REQUIRE_PAR{ JSON::Object::hash("require_pushed_authorization_requests"sv)}; +constexpr auto HASH_DEVICE_AUTHORIZATION_ENDPOINT{ + JSON::Object::hash("device_authorization_endpoint"sv)}; constexpr auto HASH_REVOCATION_ENDPOINT{ JSON::Object::hash("revocation_endpoint"sv)}; constexpr auto HASH_INTROSPECTION_ENDPOINT{ @@ -55,6 +57,20 @@ constexpr auto HASH_DPOP_BOUND_REQUIRED{ JSON::Object::hash("dpop_bound_access_tokens_required"sv)}; constexpr auto HASH_RESOURCE_SIGNING_ALGS{ JSON::Object::hash("resource_signing_alg_values_supported"sv)}; +constexpr auto HASH_RESOURCE_NAME{JSON::Object::hash("resource_name"sv)}; +constexpr auto HASH_RESOURCE_DOCUMENTATION{ + JSON::Object::hash("resource_documentation"sv)}; +constexpr auto HASH_RESOURCE_POLICY_URI{ + JSON::Object::hash("resource_policy_uri"sv)}; +constexpr auto HASH_RESOURCE_TOS_URI{JSON::Object::hash("resource_tos_uri"sv)}; +constexpr auto HASH_TLS_CLIENT_CERTIFICATE_BOUND{ + JSON::Object::hash("tls_client_certificate_bound_access_tokens"sv)}; +constexpr auto HASH_AUTHORIZATION_DETAILS_TYPES{ + JSON::Object::hash("authorization_details_types_supported"sv)}; +constexpr auto HASH_DPOP_SIGNING_ALGS{ + JSON::Object::hash("dpop_signing_alg_values_supported"sv)}; +constexpr auto HASH_PROTECTED_RESOURCES{ + JSON::Object::hash("protected_resources"sv)}; auto string_member(const JSON &data, const JSON::StringView name, const JSON::Object::hash_type hash) @@ -71,6 +87,65 @@ auto string_member(const JSON &data, const JSON::StringView name, return std::string_view{member->to_string()}; } +// An advertised endpoint is a location the client dereferences with its +// credentials, so a document naming a cleartext one would direct those +// credentials there. A member that is present but is not a valid https URL +// fails the parse rather than being ignored, since an accessor would otherwise +// report a malformed member as an absent one +auto validate_endpoint(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> void { + const auto *member{data.try_at(name, hash)}; + if (member == nullptr) { + return; + } + + if (!member->is_string() || !oauth_is_endpoint_url(member->to_string())) { + throw OAuthMetadataParseError{}; + } +} + +// The shape checks run on exact name lookup only, since RFC 9728 Section 3.2 +// demands that "any metadata parameters that are not understood MUST be +// ignored", which covers the language-tagged variants of the human-readable +// members (RFC 9728 Section 2.1) whose names are distinct from the untagged +// ones. A member that is present with the wrong shape fails the parse rather +// than being ignored, since an accessor would otherwise report a malformed +// member as an absent one +auto validate_string_array(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash, + const bool allow_empty) -> void { + const auto *member{data.try_at(name, hash)}; + if (member == nullptr) { + return; + } + + if (!member->is_array() || (!allow_empty && member->empty())) { + throw OAuthMetadataParseError{}; + } + + for (const auto &element : member->as_array()) { + if (!element.is_string()) { + throw OAuthMetadataParseError{}; + } + } +} + +auto validate_boolean(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> void { + const auto *member{data.try_at(name, hash)}; + if (member != nullptr && !member->is_boolean()) { + throw OAuthMetadataParseError{}; + } +} + +auto validate_string(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> void { + const auto *member{data.try_at(name, hash)}; + if (member != nullptr && !member->is_string()) { + throw OAuthMetadataParseError{}; + } +} + auto validated_server_metadata(JSON &&data, const std::string_view issuer) -> JSON { if (!data.is_object()) { @@ -82,16 +157,52 @@ auto validated_server_metadata(JSON &&data, const std::string_view issuer) throw OAuthMetadataParseError{}; } - // RFC 8414 Section 3.3: the issuer in the document must be identical by code - // points to the one the document was retrieved for, the impersonation defense + // RFC 8414 Section 3.3: the issuer in the document "MUST be identical to the + // authorization server's issuer identifier value into which the well-known + // URI string was inserted", the impersonation defense, and Section 4 fixes + // how that comparison runs, as "a Unicode code-point-to-code-point equality + // comparison" const auto issuer_value{std::string_view{issuer_member->to_string()}}; if (issuer_value != issuer || !oauth_is_issuer_identifier(issuer_value)) { throw OAuthMetadataParseError{}; } - // RFC 8414 Section 2: response_types_supported is REQUIRED, and Section 3.2: - // "Claims with zero elements MUST be omitted from the response", so a - // present but empty array is a malformed document + // Each advertised endpoint carries its own transport requirement, and the + // document arrives from the party the client is still deciding whether to + // trust, so the rule is enforced when the document is read and not only when + // one is built. Some of these sources mandate the scheme outright and the + // rest mandate the transport, which over HTTP is the same demand. RFC 8414 + // Section 2 on jwks_uri: "This URL MUST use the "https" scheme". RFC 9126 + // Section 2: "The PAR endpoint URL MUST use the "https" scheme". RFC 7009 + // Section 2: "URLs for token revocation endpoints MUST be HTTPS URLs" and, + // addressed at this side of the exchange, "Clients MUST verify that the URL + // is an HTTPS URL". RFC 6749 Section 3.1 and Section 3.2: "the authorization + // server MUST require the use of TLS" for the authorization and token + // endpoints. RFC 7591 Section 3: the "registration endpoint MUST be protected + // by a transport-layer security mechanism". RFC 7662 Section 2: "The + // introspection endpoint MUST be protected by a transport-layer security + // mechanism". RFC 8628 Section 3.1 on the device authorization endpoint: + // "All requests from the device MUST use the Transport Layer Security (TLS) + // protocol", and the client authentication rules of RFC 6749 Section 3.2.1 + // apply there too, so it carries credentials just as the token endpoint does + validate_endpoint(data, "authorization_endpoint"sv, + HASH_AUTHORIZATION_ENDPOINT); + validate_endpoint(data, "token_endpoint"sv, HASH_TOKEN_ENDPOINT); + validate_endpoint(data, "registration_endpoint"sv, + HASH_REGISTRATION_ENDPOINT); + validate_endpoint(data, "pushed_authorization_request_endpoint"sv, + HASH_PAR_ENDPOINT); + validate_endpoint(data, "device_authorization_endpoint"sv, + HASH_DEVICE_AUTHORIZATION_ENDPOINT); + validate_endpoint(data, "revocation_endpoint"sv, HASH_REVOCATION_ENDPOINT); + validate_endpoint(data, "introspection_endpoint"sv, + HASH_INTROSPECTION_ENDPOINT); + validate_endpoint(data, "jwks_uri"sv, HASH_JWKS_URI); + + // RFC 8414 Section 2: response_types_supported is REQUIRED (unconditionally, + // unlike authorization_endpoint which is conditional), and Section 3.2: + // "Claims with zero elements MUST be omitted from the response", so a present + // but empty array is a malformed document const auto *response_types{ data.try_at("response_types_supported"sv, HASH_RESPONSE_TYPES)}; if (response_types == nullptr || !response_types->is_array() || @@ -116,6 +227,24 @@ auto validated_server_metadata(JSON &&data, const std::string_view issuer) } } + // RFC 9728 Section 4: the protected resources are "resource identifiers for + // OAuth protected resources that can be used with this authorization + // server", the Section 1.2 form, so an entry that is not one is rejected + // like an invalid advertised issuer, with the scheme case-insensitive for a + // received value per RFC 3986 Section 3.1. RFC 8414 Section 3.2 forbids a + // zero-element array + validate_string_array(data, "protected_resources"sv, HASH_PROTECTED_RESOURCES, + false); + const auto *protected_resources{ + data.try_at("protected_resources"sv, HASH_PROTECTED_RESOURCES)}; + if (protected_resources != nullptr) { + for (const auto &element : protected_resources->as_array()) { + if (!oauth_is_advertised_resource(element.to_string())) { + throw OAuthMetadataParseError{}; + } + } + } + return std::move(data); } @@ -130,30 +259,127 @@ auto validated_resource_metadata(JSON &&data, const std::string_view resource) throw OAuthMetadataParseError{}; } - // RFC 9728 Section 3.3: the resource in the document must be a valid resource - // identifier and identical by code points to the one the document was - // retrieved for, the impersonation defense + // RFC 9728 Section 3.3: the resource in the document "MUST be identical to + // the protected resource's resource identifier value into which the + // well-known URI path suffix was inserted", the impersonation defense, and + // Section 6 fixes how that comparison runs, as "a Unicode + // code-point-to-code-point equality comparison". It must also be a valid + // resource identifier in the first place const auto resource_value{std::string_view{resource_member->to_string()}}; if (resource_value != resource || !oauth_is_resource_identifier(resource_value)) { throw OAuthMetadataParseError{}; } + // RFC 9728 Section 2 on jwks_uri: "This URL MUST use the https scheme" + validate_endpoint(data, "jwks_uri"sv, HASH_JWKS_URI); + + // RFC 9728 Section 2: the authorization servers are "OAuth authorization + // server issuer identifiers, as defined in [RFC8414]", and each one is where + // the client starts its next discovery request, so an entry that is not a + // valid issuer identifier is rejected rather than handed onwards. RFC 8414 + // Section 2 requires the https scheme, a host, and no query or fragment, + // and the scheme of a received value stays case-insensitive per RFC 3986 + // Section 3.1 since an advertised issuer is matched against nothing at parse + // time. Section 3.2: "Parameters with zero values MUST be omitted from the + // response", so a present but empty array is a malformed document + const auto *authorization_servers{ + data.try_at("authorization_servers"sv, HASH_AUTHORIZATION_SERVERS)}; + if (authorization_servers != nullptr) { + if (!authorization_servers->is_array() || authorization_servers->empty()) { + throw OAuthMetadataParseError{}; + } + + for (const auto &element : authorization_servers->as_array()) { + if (!element.is_string() || + !oauth_is_advertised_issuer(element.to_string())) { + throw OAuthMetadataParseError{}; + } + } + } + // RFC 9728 Section 2: "none" MUST NOT appear in the resource signing // algorithms, and Section 3.2 forbids a zero-element array const auto *algorithms{data.try_at("resource_signing_alg_values_supported"sv, HASH_RESOURCE_SIGNING_ALGS)}; - if (algorithms != nullptr && - (!algorithms->is_array() || algorithms->empty() || - algorithms->contains("none"))) { - throw OAuthMetadataParseError{}; + if (algorithms != nullptr) { + if (!algorithms->is_array() || algorithms->empty() || + algorithms->contains("none")) { + throw OAuthMetadataParseError{}; + } + + for (const auto &element : algorithms->as_array()) { + if (!element.is_string()) { + throw OAuthMetadataParseError{}; + } + } } + // RFC 9728 Section 3.2: "Parameters with zero values MUST be omitted from + // the response" is a rule on the emitting side, and no text orders a reader + // to reject a violating document, so treating one as malformed is this + // module's strictness stance, the same one the authorization server list + // already receives. The bearer method list is the lone member whose empty + // array is meaningful, since RFC 9728 Section 2 says "The empty array [] + // can be used to indicate that no bearer methods are supported" + validate_string_array(data, "scopes_supported"sv, HASH_SCOPES_SUPPORTED, + false); + validate_string_array(data, "bearer_methods_supported"sv, HASH_BEARER_METHODS, + true); + validate_string_array(data, "authorization_details_types_supported"sv, + HASH_AUTHORIZATION_DETAILS_TYPES, false); + validate_string_array(data, "dpop_signing_alg_values_supported"sv, + HASH_DPOP_SIGNING_ALGS, false); + validate_boolean(data, "tls_client_certificate_bound_access_tokens"sv, + HASH_TLS_CLIENT_CERTIFICATE_BOUND); + validate_boolean(data, "dpop_bound_access_tokens_required"sv, + HASH_DPOP_BOUND_REQUIRED); + validate_string(data, "resource_name"sv, HASH_RESOURCE_NAME); + validate_string(data, "resource_documentation"sv, + HASH_RESOURCE_DOCUMENTATION); + validate_string(data, "resource_policy_uri"sv, HASH_RESOURCE_POLICY_URI); + validate_string(data, "resource_tos_uri"sv, HASH_RESOURCE_TOS_URI); + return std::move(data); } } // namespace +// RFC 6749 Section 3.1: "the authorization server MUST require the use of TLS +// as described in Section 1.6 when sending requests to the authorization +// endpoint", which over HTTP means the https scheme, and "The endpoint URI MUST +// NOT include a fragment component", while it "MAY include an +// "application/x-www-form-urlencoded" formatted (per Appendix B) query +// component". Unlike an identifier, which RFC 8414 Section 4 compares as "a +// Unicode code-point-to-code-point equality comparison", an endpoint is a +// location to dereference, so RFC 3986 Section 3.1 governs and makes its scheme +// case-insensitive +auto oauth_is_endpoint_url(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->is_https() && uri->host().has_value() && + !uri->host().value().empty() && !uri->fragment().has_value(); +} + +// RFC 9728 Section 1.2 and RFC 8707 Section 2: a resource is an https URL with +// a non-empty host and no fragment, a query tolerated unlike an issuer +auto oauth_is_resource_identifier(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->scheme().has_value() && + uri->scheme().value() == "https" && uri->host().has_value() && + !uri->host().value().empty() && !uri->fragment().has_value(); +} + +// RFC 8414 Section 2: an issuer is an https URL with a non-empty host (RFC 3986 +// Section 3.2) and no query or fragment, its scheme matched by code points to +// reject a non-canonical case +auto oauth_is_issuer_identifier(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->scheme().has_value() && + uri->scheme().value() == "https" && uri->host().has_value() && + !uri->host().value().empty() && !uri->query().has_value() && + !uri->fragment().has_value(); +} + auto oauth_well_known_url(const std::string_view identifier, const OAuthWellKnownKind kind, std::string &sink) -> bool { @@ -274,6 +500,12 @@ auto OAuthServerMetadata::registration_endpoint() const HASH_REGISTRATION_ENDPOINT); } +auto OAuthServerMetadata::device_authorization_endpoint() const + -> std::optional { + return string_member(this->data_, "device_authorization_endpoint"sv, + HASH_DEVICE_AUTHORIZATION_ENDPOINT); +} + auto OAuthServerMetadata::revocation_endpoint() const -> std::optional { return string_member(this->data_, "revocation_endpoint"sv, @@ -362,6 +594,12 @@ auto OAuthServerMetadata::supports_token_endpoint_auth_method( return member->contains(value); } +auto OAuthServerMetadata::supports_protected_resource( + const std::string_view value) const -> bool { + return this->data_.array_member_contains("protected_resources"sv, + HASH_PROTECTED_RESOURCES, value); +} + auto OAuthServerMetadata::data() const -> const JSON & { return this->data_; } OAuthResourceMetadata::OAuthResourceMetadata(JSON &&data, @@ -389,17 +627,14 @@ auto OAuthResourceMetadata::first_authorization_server() const const auto *member{this->data_.try_at("authorization_servers"sv, HASH_AUTHORIZATION_SERVERS)}; - if (member == nullptr || !member->is_array()) { + if (member == nullptr) { return std::nullopt; } - for (const auto &element : member->as_array()) { - if (element.is_string()) { - return std::string_view{element.to_string()}; - } - } - - return std::nullopt; + // Construction rejects a document whose authorization servers are anything + // other than a non-empty array of issuer identifiers, so the first element is + // a string and needs no search past a malformed one + return std::string_view{member->front().to_string()}; } auto OAuthResourceMetadata::supports_authorization_server( @@ -435,6 +670,61 @@ auto OAuthResourceMetadata::dpop_bound_access_tokens_required() const -> bool { return member != nullptr && member->is_boolean() && member->to_boolean(); } +auto OAuthResourceMetadata::resource_name() const + -> std::optional { + return string_member(this->data_, "resource_name"sv, HASH_RESOURCE_NAME); +} + +auto OAuthResourceMetadata::resource_documentation() const + -> std::optional { + return string_member(this->data_, "resource_documentation"sv, + HASH_RESOURCE_DOCUMENTATION); +} + +auto OAuthResourceMetadata::resource_policy_uri() const + -> std::optional { + return string_member(this->data_, "resource_policy_uri"sv, + HASH_RESOURCE_POLICY_URI); +} + +auto OAuthResourceMetadata::resource_tos_uri() const + -> std::optional { + return string_member(this->data_, "resource_tos_uri"sv, + HASH_RESOURCE_TOS_URI); +} + +auto OAuthResourceMetadata::tls_client_certificate_bound_access_tokens() const + -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{ + this->data_.try_at("tls_client_certificate_bound_access_tokens"sv, + HASH_TLS_CLIENT_CERTIFICATE_BOUND)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OAuthResourceMetadata::supports_resource_signing_alg( + const std::string_view value) const -> bool { + return this->data_.array_member_contains( + "resource_signing_alg_values_supported"sv, HASH_RESOURCE_SIGNING_ALGS, + value); +} + +auto OAuthResourceMetadata::supports_dpop_signing_alg( + const std::string_view value) const -> bool { + return this->data_.array_member_contains( + "dpop_signing_alg_values_supported"sv, HASH_DPOP_SIGNING_ALGS, value); +} + +auto OAuthResourceMetadata::supports_authorization_details_type( + const std::string_view value) const -> bool { + return this->data_.array_member_contains( + "authorization_details_types_supported"sv, + HASH_AUTHORIZATION_DETAILS_TYPES, value); +} + auto OAuthResourceMetadata::data() const -> const JSON & { return this->data_; } namespace { @@ -444,42 +734,70 @@ auto span_contains(const std::span values, return std::ranges::find(values, target) != values.end(); } +// RFC 9728 Section 2 calls each human-readable page location a URL without +// mandating a scheme, so the check is for an absolute URI rather than an https +// one, and a fragment stays legitimate on a page location, unlike under the +// stricter RFC 3986 Section 4.3 absolute-URI rule +auto oauth_is_page_url(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->is_absolute(); +} + } // namespace auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) -> std::optional { - // RFC 8414 Section 2: issuer and a non-empty response_types_supported are - // REQUIRED, and the issuer must be a valid issuer identifier - if (!oauth_is_issuer_identifier(config.issuer) || - config.response_types_supported.empty()) { + // RFC 8414 Section 2: the issuer is REQUIRED and must be a valid issuer + // identifier + if (!oauth_is_issuer_identifier(config.issuer)) { return std::nullopt; } - // RFC 8414 Section 2: the authorization endpoint is REQUIRED once a response - // type is advertised, and the token endpoint unless the only grant type is - // the implicit one, and every advertised URL is an https location. A present - // scalar that is not a valid https URL, or a missing required endpoint, would - // yield an unusable discovery document + // RFC 8414 Section 2: the authorization endpoint is "REQUIRED unless no grant + // types are supported that use the authorization endpoint", and the token + // endpoint is REQUIRED unless the only grant type is the implicit one. The + // authorization endpoint grant types are the authorization code and implicit + // grants, and an omitted grant type list defaults to both, so both endpoints + // are needed by default. Every advertised URL is an https location, and a + // present scalar that is not a valid https URL, or a missing required + // endpoint, would yield an unusable discovery document + const bool authorization_endpoint_needed{ + config.grant_types_supported.empty() || + std::ranges::any_of(config.grant_types_supported, + [](const std::string_view grant) -> bool { + return grant == "authorization_code" || + grant == "implicit"; + })}; const bool token_endpoint_needed{ config.grant_types_supported.empty() || std::ranges::any_of(config.grant_types_supported, [](const std::string_view grant) -> bool { return grant != "implicit"; })}; + + // RFC 8414 Section 2: response_types_supported is REQUIRED unconditionally + // (unlike authorization_endpoint, which is conditional), and Section 3.2 + // forbids a zero-element array + if (config.response_types_supported.empty()) { + return std::nullopt; + } + + const bool authorization_endpoint_required_and_valid{ + authorization_endpoint_needed + ? oauth_is_endpoint_url(config.authorization_endpoint) + : config.authorization_endpoint.empty() || + oauth_is_endpoint_url(config.authorization_endpoint)}; const bool token_endpoint_required_and_valid{ - token_endpoint_needed - ? oauth_is_resource_identifier(config.token_endpoint) - : config.token_endpoint.empty() || - oauth_is_resource_identifier(config.token_endpoint)}; - if (!oauth_is_resource_identifier(config.authorization_endpoint) || + token_endpoint_needed ? oauth_is_endpoint_url(config.token_endpoint) + : config.token_endpoint.empty() || + oauth_is_endpoint_url(config.token_endpoint)}; + if (!authorization_endpoint_required_and_valid || !token_endpoint_required_and_valid || (!config.registration_endpoint.empty() && - !oauth_is_resource_identifier(config.registration_endpoint)) || + !oauth_is_endpoint_url(config.registration_endpoint)) || (!config.pushed_authorization_request_endpoint.empty() && - !oauth_is_resource_identifier( - config.pushed_authorization_request_endpoint)) || - (!config.jwks_uri.empty() && - !oauth_is_resource_identifier(config.jwks_uri))) { + !oauth_is_endpoint_url(config.pushed_authorization_request_endpoint)) || + (!config.jwks_uri.empty() && !oauth_is_endpoint_url(config.jwks_uri))) { return std::nullopt; } @@ -508,6 +826,14 @@ auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) return std::nullopt; } + // RFC 9728 Section 4: the advertised protected resources are "resource + // identifiers for OAuth protected resources", so an entry that is not one + // would yield an unusable document + if (!std::ranges::all_of(config.protected_resources, + oauth_is_resource_identifier)) { + return std::nullopt; + } + auto document{JSON::make_object()}; document.assign_assume_new("issuer", JSON{config.issuer}, HASH_ISSUER); document.assign_if_nonempty("authorization_endpoint", @@ -537,6 +863,8 @@ auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) config.token_endpoint_auth_signing_alg_values_supported); document.assign_if_nonempty("scopes_supported", HASH_SCOPES_SUPPORTED, config.scopes_supported); + document.assign_if_nonempty("protected_resources", HASH_PROTECTED_RESOURCES, + config.protected_resources); // RFC 9126 Section 5: the default is false, so the flag is emitted only when // the server requires pushed authorization requests if (config.require_pushed_authorization_requests) { @@ -547,4 +875,111 @@ auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) return document; } +auto oauth_make_resource_metadata(const OAuthResourceMetadataConfig &config) + -> std::optional { + // RFC 9728 Section 2: the resource is REQUIRED and must be a valid resource + // identifier per Section 1.2 + if (!oauth_is_resource_identifier(config.resource)) { + return std::nullopt; + } + + // RFC 9728 Section 2: the authorization servers are "OAuth authorization + // server issuer identifiers, as defined in [RFC8414]". A client resolves an + // entry by inserting the well-known string into it (RFC 8414 Section 3) and + // then requires the metadata issuer to be "identical" to it by code points + // (RFC 8414 Sections 3.3 and 4), so only an entry in the exact issuer + // identifier form can ever complete discovery, and emitting any other form + // would advertise a dead end + if (!std::ranges::all_of(config.authorization_servers, + oauth_is_issuer_identifier)) { + return std::nullopt; + } + + // RFC 9728 Section 2 on jwks_uri: "This URL MUST use the https scheme" + if (!config.jwks_uri.empty() && !oauth_is_endpoint_url(config.jwks_uri)) { + return std::nullopt; + } + + // RFC 9728 Section 2: "The value none MUST NOT be used" in the resource + // signing algorithms + if (span_contains(config.resource_signing_alg_values_supported, "none")) { + return std::nullopt; + } + + // RFC 9449 Section 4.2: a DPoP proof algorithm "MUST NOT be none or an + // identifier for a symmetric algorithm (Message Authentication Code (MAC))", + // so advertising one describes a proof that cannot exist. The MAC exclusion + // covers the registered JWS MAC identifiers, and a later registration would + // pass, since the registry is extensible and not modelled here + if (span_contains(config.dpop_signing_alg_values_supported, "none") || + span_contains(config.dpop_signing_alg_values_supported, "HS256") || + span_contains(config.dpop_signing_alg_values_supported, "HS384") || + span_contains(config.dpop_signing_alg_values_supported, "HS512")) { + return std::nullopt; + } + + if ((!config.resource_documentation.empty() && + !oauth_is_page_url(config.resource_documentation)) || + (!config.resource_policy_uri.empty() && + !oauth_is_page_url(config.resource_policy_uri)) || + (!config.resource_tos_uri.empty() && + !oauth_is_page_url(config.resource_tos_uri))) { + return std::nullopt; + } + + auto document{JSON::make_object()}; + document.assign_assume_new("resource", JSON{config.resource}, HASH_RESOURCE); + document.assign_if_nonempty("authorization_servers", + HASH_AUTHORIZATION_SERVERS, + config.authorization_servers); + document.assign_if_nonempty("jwks_uri", HASH_JWKS_URI, config.jwks_uri); + document.assign_if_nonempty("scopes_supported", HASH_SCOPES_SUPPORTED, + config.scopes_supported); + // RFC 9728 Section 2: "The empty array [] can be used to indicate that no + // bearer methods are supported", the one member whose engaged empty state is + // emitted despite the Section 3.2 zero-value omission rule, whose general + // form the specific text governs over + if (config.bearer_methods_supported.has_value()) { + auto methods{JSON::make_array()}; + for (const auto &method : config.bearer_methods_supported.value()) { + methods.push_back(JSON{method}); + } + + document.assign_assume_new("bearer_methods_supported", std::move(methods), + HASH_BEARER_METHODS); + } + + document.assign_if_nonempty("resource_signing_alg_values_supported", + HASH_RESOURCE_SIGNING_ALGS, + config.resource_signing_alg_values_supported); + document.assign_if_nonempty("resource_name", HASH_RESOURCE_NAME, + config.resource_name); + document.assign_if_nonempty("resource_documentation", + HASH_RESOURCE_DOCUMENTATION, + config.resource_documentation); + document.assign_if_nonempty("resource_policy_uri", HASH_RESOURCE_POLICY_URI, + config.resource_policy_uri); + document.assign_if_nonempty("resource_tos_uri", HASH_RESOURCE_TOS_URI, + config.resource_tos_uri); + // RFC 9728 Section 2: both boolean defaults are false when absent, so each + // flag is emitted only when true + if (config.tls_client_certificate_bound_access_tokens) { + document.assign_assume_new("tls_client_certificate_bound_access_tokens", + JSON{true}, HASH_TLS_CLIENT_CERTIFICATE_BOUND); + } + + document.assign_if_nonempty("authorization_details_types_supported", + HASH_AUTHORIZATION_DETAILS_TYPES, + config.authorization_details_types_supported); + document.assign_if_nonempty("dpop_signing_alg_values_supported", + HASH_DPOP_SIGNING_ALGS, + config.dpop_signing_alg_values_supported); + if (config.dpop_bound_access_tokens_required) { + document.assign_assume_new("dpop_bound_access_tokens_required", JSON{true}, + HASH_DPOP_BOUND_REQUIRED); + } + + return document; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_registration.cc b/vendor/core/src/core/oauth/oauth_registration.cc index 948d75c49..07e3563e2 100644 --- a/vendor/core/src/core/oauth/oauth_registration.cc +++ b/vendor/core/src/core/oauth/oauth_registration.cc @@ -2,6 +2,7 @@ #include #include +#include #include "oauth_json.h" #include "oauth_syntax.h" @@ -106,6 +107,23 @@ auto validated_client_metadata(JSON &&data) -> JSON { throw OAuthRegistrationParseError{}; } + // RFC 7592 Section 2: "The client configuration endpoint MUST be protected by + // a transport-layer security mechanism", and the client "MUST use its + // registration access token in all calls to this endpoint as an OAuth 2.0 + // Bearer Token", so a cleartext location here would carry that credential in + // the clear. This record is read in both directions, but Section 2.2 keeps a + // request from carrying the member at all, since an update "MUST NOT include + // the "registration_access_token", "registration_client_uri", + // "client_secret_expires_at", or "client_id_issued_at" fields", so validating + // it cannot reject a well-formed request + const auto *management_uri{ + data.try_at("registration_client_uri"sv, HASH_REGISTRATION_CLIENT_URI)}; + if (management_uri != nullptr && + (!management_uri->is_string() || + !oauth_is_endpoint_url(management_uri->to_string()))) { + throw OAuthRegistrationParseError{}; + } + return std::move(data); } @@ -430,9 +448,11 @@ auto oauth_make_registration_response( } // RFC 7592 Section 3: the registration management location is a fully - // qualified URL + // qualified URL, and Section 2 requires the endpoint it names to be protected + // by a transport-layer security mechanism, so the same predicate the parse + // path applies is used here if (!result.registration_client_uri.empty() && - !oauth_try_parse_uri(result.registration_client_uri).has_value()) { + !oauth_is_endpoint_url(result.registration_client_uri)) { return std::nullopt; } diff --git a/vendor/core/src/core/oauth/oauth_syntax.h b/vendor/core/src/core/oauth/oauth_syntax.h index 27cb15cc2..05912d084 100644 --- a/vendor/core/src/core/oauth/oauth_syntax.h +++ b/vendor/core/src/core/oauth/oauth_syntax.h @@ -26,23 +26,25 @@ inline auto oauth_try_parse_uri(const std::string_view value) } } -// RFC 8414 Section 2: an issuer is an https URL with a non-empty host (RFC 3986 -// Section 3.2) and no query or fragment, its scheme matched by code points to -// reject a non-canonical case -inline auto oauth_is_issuer_identifier(const std::string_view value) -> bool { +// An issuer identifier a document advertises for someone else, rather than the +// one the document was retrieved for. RFC 8414 Section 2 gives it the same +// shape, but Section 4 scopes code-point comparison to "comparing values in the +// messages to known values", and an advertised issuer is matched against +// nothing at parse time. Its validity therefore follows RFC 3986 Section 3.1, +// which makes the scheme case-insensitive +inline auto oauth_is_advertised_issuer(const std::string_view value) -> bool { const auto uri{oauth_try_parse_uri(value)}; - return uri.has_value() && uri->scheme().has_value() && - uri->scheme().value() == "https" && uri->host().has_value() && + return uri.has_value() && uri->is_https() && uri->host().has_value() && !uri->host().value().empty() && !uri->query().has_value() && !uri->fragment().has_value(); } -// RFC 9728 Section 1.2 and RFC 8707 Section 2: a resource is an https URL with -// a non-empty host and no fragment, a query tolerated unlike an issuer -inline auto oauth_is_resource_identifier(const std::string_view value) -> bool { +// A resource identifier a document advertises for someone else, given the same +// case-insensitive scheme treatment as an advertised issuer, with a query +// tolerated per RFC 9728 Section 1.2 and RFC 8707 Section 2 +inline auto oauth_is_advertised_resource(const std::string_view value) -> bool { const auto uri{oauth_try_parse_uri(value)}; - return uri.has_value() && uri->scheme().has_value() && - uri->scheme().value() == "https" && uri->host().has_value() && + return uri.has_value() && uri->is_https() && uri->host().has_value() && !uri->host().value().empty() && !uri->fragment().has_value(); } diff --git a/vendor/core/src/core/oidc/CMakeLists.txt b/vendor/core/src/core/oidc/CMakeLists.txt index ff80de257..5d657d4b6 100644 --- a/vendor/core/src/core/oidc/CMakeLists.txt +++ b/vendor/core/src/core/oidc/CMakeLists.txt @@ -4,8 +4,8 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oidc request_object.h logout.h encryption.h profile.h SOURCES oidc_error.cc oidc_metadata.cc oidc_discovery.cc oidc_hash.cc oidc_id_token.cc oidc_authentication.cc oidc_claims.cc oidc_userinfo.cc - oidc_registration.cc oidc_subject.cc oidc_verify.h oidc_request_object.cc - oidc_logout.cc oidc_encryption.cc) + oidc_registration.cc oidc_subject.cc oidc_verify.h + oidc_request_object.cc oidc_logout.cc oidc_encryption.cc) target_link_libraries(sourcemeta_core_oidc PUBLIC sourcemeta::core::json) diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h index fc479d729..d580d419e 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h @@ -153,10 +153,11 @@ auto oidc_authorization_url(const std::string_view authorization_endpoint, /// into the result, returning whether it is well formed (OpenID Connect Core /// 1.0 Section 3.1.2.1). The `client_id`, `redirect_uri`, and `response_type` /// are REQUIRED, the scope must contain `openid`, a `none` prompt must appear -/// alone, `offline_access` cannot pair with a `none` prompt, the -/// `response_type` is limited by the profile, and `OIDCProfile::Strict` -/// requires a `code_challenge` with the `S256` method (PKCE), the same checks -/// the builder applies. Each recognized value is form-decoded, borrowing from +/// alone, an `offline_access` scope that cannot yield a refresh token, such as +/// one paired with a `none` prompt, is dropped rather than rejected (OpenID +/// Connect Core 1.0 Section 11), the `response_type` is limited by the profile, +/// and `OIDCProfile::Strict` requires a `code_challenge` with the `S256` method +/// (PKCE). Each recognized value is form-decoded, borrowing from /// the input when it carries no escape and otherwise from the storage arena, /// which the caller owns and reuses across parses. The result is reset first, /// then borrows from the input and the storage, so both must outlive it. For diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h index df0d78414..0e7b7e799 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h @@ -62,6 +62,11 @@ struct OIDCValidationOptions { /// The acceptable authentication context class references, checked when /// non-empty (OpenID Connect Core 1.0 Section 3.1.3.7 step 12). std::span acceptable_authentication_context_classes; + /// The additional audiences beyond the client that the caller trusts. Every + /// `aud` value that is not the client must appear in this set, so an empty + /// set rejects any additional audience (OpenID Connect Core 1.0 + /// Section 3.1.3.7 step 3). + std::span trusted_audiences; /// The maximum authentication age, requiring a fresh `auth_time` when set /// (OpenID Connect Core 1.0 Section 3.1.3.7 step 13). std::optional maximum_authentication_age; @@ -92,10 +97,11 @@ struct OIDCValidationOptions { /// asserted identity or no value when any check fails (OpenID Connect Core 1.0 /// Section 3.1.3.7). The base JSON Web Token verification (signature under a /// pinned algorithm, issuer, audience, expiration, and skew) runs first, then -/// the OpenID Connect steps: the subject and issued-at are required, an `azp` -/// matching the client is required when the audience carries more than one -/// value, the nonce is echoed when one was sent, the authentication context and -/// age constraints hold, and a required binding hash is present and matches. +/// the OpenID Connect steps: the subject and issued-at are required, every +/// audience beyond the client must be one the caller trusts, an `azp` matching +/// the client is required when the audience carries more than one value, the +/// nonce is echoed when one was sent, the authentication context and age +/// constraints hold, and a required binding hash is present and matches. /// The algorithm allow-list is pinned by the caller and must never contain /// `none`. For example: /// diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h index a91175489..ec9663475 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h @@ -52,11 +52,44 @@ class SOURCEMETA_CORE_OIDC_EXPORT OIDCProviderMetadata { /// throwing when it is invalid. The document is moved in. OIDCProviderMetadata(JSON &&data, const std::string_view issuer); + /// Apply the OpenID Connect layer to a document already parsed and validated + /// as OAuth authorization server metadata, throwing when the OpenID Connect + /// requirements are not met. The document is moved in and the OAuth checks + /// are not repeated. + explicit OIDCProviderMetadata(OAuthServerMetadata &&oauth); + /// Construct and validate a metadata document for an expected issuer, /// returning no value when it is invalid. The document is moved in. [[nodiscard]] static auto from(JSON &&data, const std::string_view issuer) -> std::optional; + /// Apply the OpenID Connect layer to a document already parsed and validated + /// as OAuth authorization server metadata, returning no value when the + /// OpenID Connect requirements are not met. This is what a caching resolver + /// hands back, so lifting it costs no reparse and no repeated OAuth + /// validation. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// auto document{sourcemeta::core::parse_json(R"JSON({ + /// "issuer":"https://example.com", + /// "jwks_uri":"https://example.com/jwks", + /// "response_types_supported":[ "code" ], + /// "subject_types_supported":[ "public" ], + /// "id_token_signing_alg_values_supported":[ "RS256" ] + /// })JSON")}; + /// auto oauth{sourcemeta::core::OAuthServerMetadata::from( + /// std::move(document), "https://example.com")}; + /// assert(oauth.has_value()); + /// const auto metadata{ + /// sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + /// assert(metadata.has_value()); + /// ``` + [[nodiscard]] static auto from(OAuthServerMetadata &&oauth) + -> std::optional; + /// The issuer identifier (OpenID Connect Discovery 1.0 Section 3). [[nodiscard]] auto issuer() const -> std::string_view; @@ -105,6 +138,13 @@ class SOURCEMETA_CORE_OIDC_EXPORT OIDCProviderMetadata { [[nodiscard]] auto supports_response_type(const std::string_view value) const -> bool; + /// Whether a token endpoint authentication method is supported, defaulting + /// to `client_secret_basic` when absent (OpenID Connect Discovery 1.0 + /// Section 3). + [[nodiscard]] auto + supports_token_endpoint_auth_method(const std::string_view value) const + -> bool; + /// Whether a scope is supported (OpenID Connect Discovery 1.0 Section 3). [[nodiscard]] auto supports_scope(const std::string_view value) const -> bool; diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h index d5b83fea4..1cde4f9f3 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h @@ -58,9 +58,12 @@ auto oidc_build_request_object(const JSON ¶meters, const JWKPrivate &key, /// @ingroup oidc /// Verify a request object and return its parameters, or no value when the -/// signature does not verify under a pinned algorithm, the issuer is not the -/// client, or the audience is not the OpenID Provider (OpenID Connect Core 1.0 -/// Section 6.3). For example: +/// signature does not verify under a pinned algorithm, the issuer is present +/// but is not the client, or the audience is missing or does not include the +/// OpenID Provider (OpenID Connect Core 1.0 Section 6.1 and Section 6.3). A +/// signed request object must carry an `aud` that includes the provider, +/// binding it to this provider so it cannot be replayed to another one. For +/// example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h index 22cd5895b..d45d8ec91 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h @@ -53,10 +53,12 @@ auto oidc_userinfo_matches_subject(const JSON &userinfo, /// @ingroup oidc /// Verify a signed UserInfo response and its subject, returning the claims or /// no value when the signature does not verify under a pinned algorithm, the -/// subject does not match the ID Token subject, or a present `iss` or `aud` -/// does not identify this provider and client (OpenID Connect Core 1.0 -/// Section 5.3.2). Checking `aud` binds the response to this client, preventing -/// one minted for another client from being accepted here. For example: +/// subject does not match the ID Token subject, or `iss` or `aud` is missing or +/// does not identify this provider and client. A signed response MUST carry +/// `iss` and `aud` (OpenID Connect Core 1.0 Section 5.3.2, errata set 2), so a +/// signed response lacking either is rejected. Checking `aud` binds the +/// response to this client, preventing one minted for another client from being +/// accepted here. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/oidc/oidc_authentication.cc b/vendor/core/src/core/oidc/oidc_authentication.cc index 2f6ceabaf..b3ecfd408 100644 --- a/vendor/core/src/core/oidc/oidc_authentication.cc +++ b/vendor/core/src/core/oidc/oidc_authentication.cc @@ -6,6 +6,7 @@ #include // std::ranges::all_of #include // std::array #include // std::size_t +#include // std::less #include // std::optional, std::nullopt #include // std::span #include // std::string @@ -44,15 +45,71 @@ auto prompt_is_valid(const std::string_view prompt) -> bool { return token_count == 1; } -// OpenID Connect Core 1.0 Section 11: obtaining a refresh token through the -// offline_access scope requires the end user's consent, which the none prompt -// forbids gathering, so the two cannot be requested together +// OpenID Connect Core 1.0 Section 11: on receipt of an offline_access scope the +// Authorization Server "MUST ensure that the prompt parameter contains consent +// unless other conditions for processing the request permitting offline access +// to the requested resources are in place; unless one or both of these +// conditions are fulfilled, then it MUST ignore the offline_access request". A +// none prompt forbids gathering consent, so absent server-specific conditions +// the request cannot yield a refresh token. Whether such other conditions hold +// is provider policy this parser cannot see, so it treats a none prompt as the +// conservative case where offline_access is ignored auto offline_access_is_valid(const std::string_view scope, const std::string_view prompt) -> bool { return !space_list_contains(scope, "offline_access") || !space_list_contains(prompt, "none"); } +// OpenID Connect Core 1.0 Section 11: when the conditions that authorize +// issuing a refresh token are not met, the Authorization Server "MUST ignore +// the offline_access request", so the token is dropped from the parsed scope +// rather than the whole request being refused. A scope decoded into the arena +// is compacted back into its own bytes, which only shrinks and so never +// disturbs another borrowed view into the arena. A scope borrowed directly from +// the query cannot be edited in place, so its retained tokens are compacted +// into the arena instead. The parser reserved the whole query length up front +// and a borrowed value consumed none of it, so this append stays within the +// existing capacity and never reallocates a buffer that other views borrow from +auto drop_offline_access(std::string_view &scope, std::string &storage) + -> void { + const auto *const arena{storage.data()}; + const std::less before{}; + const bool in_arena{!before(scope.data(), arena) && + before(scope.data(), arena + storage.size())}; + + std::size_t offset{0}; + if (in_arena) { + offset = static_cast(scope.data() - arena); + } else { + if (storage.capacity() - storage.size() < scope.size()) { + return; + } + + offset = storage.size(); + storage.resize(storage.size() + scope.size()); + } + + std::size_t length{0}; + split(scope, ' ', + [&storage, offset, &length](const std::string_view token) -> void { + if (token.empty() || token == "offline_access") { + return; + } + + if (length != 0) { + storage[offset + length] = ' '; + length += 1; + } + + for (const auto character : token) { + storage[offset + length] = character; + length += 1; + } + }); + + scope = std::string_view{storage.data() + offset, length}; +} + // OpenID Connect Core 1.0 Section 3, module design Section 14: the // Authorization Code flow is always permitted, the Hybrid "code id_token" flow // only under the legacy profile, and every flow that returns an access token @@ -323,24 +380,33 @@ auto oidc_parse_authentication_request(const std::string_view query, result.code_challenge_method = base.code_challenge_method; result.request_uri = base.request_uri; + // OpenID Connect Core 1.0 Section 11: on the provider side an offline_access + // request that cannot yield a refresh token, such as one paired with a none + // prompt that forbids gathering consent, is ignored rather than refused, so + // the token is removed from the scope and the request continues to be parsed + if (!offline_access_is_valid(result.scope, result.prompt)) { + drop_offline_access(result.scope, storage); + } + // The same OpenID Connect well-formedness the builder enforces, so parsing // never accepts a request the module could not build: the client identifier // and redirection URI are REQUIRED, the scope must contain openid, a none - // prompt must appear alone, offline_access cannot pair with a none prompt, - // the response_type is REQUIRED and limited by the profile, a returned ID - // Token requires a nonce, and the strict profile requires PKCE (OpenID - // Connect Core 1.0 Section 3.1.2.1, Section 3.3.2.11, Section 11, RFC 9700 - // Section 2.1.1) + // prompt must appear alone, the response_type is REQUIRED and limited by the + // profile, a returned ID Token requires a nonce, and the strict profile + // requires PKCE (OpenID Connect Core 1.0 Section 3.1.2.1, Section 3.3.2.11, + // RFC 9700 Section 2.1.1) return !result.client_id.empty() && !result.redirect_uri.empty() && space_list_contains(result.scope, "openid") && - prompt_is_valid(result.prompt) && - offline_access_is_valid(result.scope, result.prompt) && - !result.response_type.empty() && + prompt_is_valid(result.prompt) && !result.response_type.empty() && response_type_is_allowed(result.response_type, profile) && !(response_type_requires_nonce(result.response_type) && result.nonce.empty()) && pkce_is_valid(result.code_challenge, result.code_challenge_method, - profile); + profile) && + // A refusable offline_access request must have been dropped above, so + // its survival means the drop could not be applied and the request + // fails closed rather than being accepted with the forbidden token + offline_access_is_valid(result.scope, result.prompt); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_discovery.cc b/vendor/core/src/core/oidc/oidc_discovery.cc index 5b5421745..6ace8d5a3 100644 --- a/vendor/core/src/core/oidc/oidc_discovery.cc +++ b/vendor/core/src/core/oidc/oidc_discovery.cc @@ -22,16 +22,65 @@ constexpr auto HASH_HREF{JSON::Object::hash("href"sv)}; constexpr std::string_view ISSUER_RELATION{ "http://openid.net/specs/connect/1.0/issuer"}; +// OpenID Connect Discovery 1.0 Section 2.1: an explicit scheme such as acct or +// https suppresses normalization. A bare "host:port" such as "example.com:8080" +// is listed as scheme-less input, so a colon that begins a port rather than an +// opaque or hierarchical part is not treated as a scheme delimiter (RFC 3986 +// Section 3.1: "scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )") +auto identifier_has_scheme(const std::string_view identifier) -> bool { + const auto delimiter{identifier.find_first_of(":/?#@")}; + if (delimiter == std::string_view::npos || identifier[delimiter] != ':') { + return false; + } + + const auto candidate{identifier.substr(0, delimiter)}; + if (candidate.empty() || !is_alpha(candidate.front())) { + return false; + } + + for (const auto character : candidate) { + if (!is_alphanum(character) && character != '+' && character != '-' && + character != '.') { + return false; + } + } + + // A colon that begins an all-digit port up to the next component delimiter is + // the "host:port" shape rather than a scheme, so only that shape overrides + // the RFC 3986 scheme detection + const auto rest{identifier.substr(delimiter + 1)}; + const auto port{rest.substr(0, rest.find_first_of("/?#"))}; + if (port.empty()) { + return true; + } + + for (const auto character : port) { + if (!is_digit(character)) { + return true; + } + } + + return false; +} + +// OpenID Connect Discovery 1.0 Section 2.1: "If the userinfo and host +// components are present and all of the scheme, path, query, port, and fragment +// components are absent, the acct scheme is assumed". A scheme-less input is +// read as "[ userinfo "@" ] host [ ":" port ]", so this shape carries an "@" +// and none of the port, path, query, or fragment delimiters +auto identifier_is_acct_shaped(const std::string_view identifier) -> bool { + return identifier.find('@') != std::string_view::npos && + identifier.find_first_of(":/?#") == std::string_view::npos; +} + // OpenID Connect Discovery 1.0 Section 2: an issuer identifier is an https URL -// with a host and no query or fragment. RFC 3986 Section 3.1 makes the scheme -// case-insensitive +// with a host and no query or fragment auto is_issuer_identifier(const std::string_view value) -> bool { try { const URI uri{value}; - return uri.scheme().has_value() && - equals_ignore_case(uri.scheme().value(), "https") && - uri.host().has_value() && !uri.host().value().empty() && - !uri.query().has_value() && !uri.fragment().has_value(); + return uri.is_https() && uri.host().has_value() && + !uri.host().value().empty() && !uri.query().has_value() && + !uri.fragment().has_value(); } catch (const URIParseError &) { return false; } @@ -57,12 +106,13 @@ auto oidc_webfinger_request(const std::string_view identifier) -> std::optional { OIDCWebFingerRequest request; - // OpenID Connect Discovery 1.0 Section 2.1: an acct URI or a URL is kept, a - // bare user@host becomes an acct URI, and any other input is an https URL - if (identifier.starts_with("acct:") || - identifier.find("://") != std::string_view::npos) { + // OpenID Connect Discovery 1.0 Section 2.1: an input that already carries a + // scheme is kept, a scheme-less userinfo-and-host-only input takes the acct + // scheme, and "For all other inputs without a scheme component, the https + // scheme is assumed" + if (identifier_has_scheme(identifier)) { request.resource = identifier; - } else if (identifier.find('@') != std::string_view::npos) { + } else if (identifier_is_acct_shaped(identifier)) { request.resource = "acct:"; request.resource.append(identifier); } else { @@ -70,13 +120,23 @@ auto oidc_webfinger_request(const std::string_view identifier) request.resource.append(identifier); } + // OpenID Connect Discovery 1.0 Section 2.1: "If the resulting URI contains a + // fragment component, it MUST be stripped off, together with the fragment + // delimiter character" + const auto fragment{request.resource.find('#')}; + if (fragment != std::string::npos) { + request.resource.erase(fragment); + } + // OpenID Connect Discovery 1.0 Section 2.1: the host is the domain after the // last "@" of an acct resource, or the authority of a URL resource. An acct // URI carries no authority component, so the URI parser cannot expose its // host, but a URL resource is parsed rather than scanned by hand. The host is // copied out because the parsed URI does not outlive this scope std::string host; - if (request.resource.starts_with("acct:")) { + // RFC 3986 Section 3.1: a scheme is case-insensitive, so an acct resource is + // recognized regardless of the case the caller used + if (starts_with_ignore_case(request.resource, "acct:")) { const auto account{ rsplit_once(std::string_view{request.resource}.substr(5), '@')}; if (!account.has_value()) { @@ -88,11 +148,9 @@ auto oidc_webfinger_request(const std::string_view identifier) try { const URI resource{request.resource}; // OpenID Connect Discovery 1.0 Section 2.1: a URL resource uses the https - // scheme and carries a host, so a non-https URL identifier is rejected. - // RFC 3986 Section 3.1 makes the scheme case-insensitive - if (!resource.scheme().has_value() || - !equals_ignore_case(resource.scheme().value(), "https") || - !resource.host().has_value() || resource.host().value().empty()) { + // scheme and carries a host, so a non-https URL identifier is rejected + if (!resource.is_https() || !resource.host().has_value() || + resource.host().value().empty()) { return std::nullopt; } diff --git a/vendor/core/src/core/oidc/oidc_id_token.cc b/vendor/core/src/core/oidc/oidc_id_token.cc index 4bc2ae3dc..9a82a4855 100644 --- a/vendor/core/src/core/oidc/oidc_id_token.cc +++ b/vendor/core/src/core/oidc/oidc_id_token.cc @@ -68,6 +68,35 @@ auto oidc_id_token_checks(const JWT &token, const std::string_view issuer, return std::nullopt; } + // OpenID Connect Core 1.0 Section 3.1.3.7 step 3: "The ID Token MUST be + // rejected if the ID Token does not list the Client as a valid audience, or + // if it contains additional audiences not trusted by the Client". The base + // verification already confirmed the client is a listed audience, so here + // every other audience must be one the caller has marked as trusted. With an + // empty trusted set any audience beyond the client is rejected, the strict + // default. A single-string audience carries no additional audience, so it is + // unaffected + if (audience != nullptr && audience->is_array()) { + for (const auto &element : audience->as_array()) { + const auto &value{element.to_string()}; + if (value == client_id) { + continue; + } + + bool trusted{false}; + for (const auto candidate : options.trusted_audiences) { + if (candidate == value) { + trusted = true; + break; + } + } + + if (!trusted) { + return std::nullopt; + } + } + } + const bool multiple_audiences{audience != nullptr && audience->is_array() && audience->size() > 1}; const auto *authorized_party{payload.try_at("azp"sv, HASH_AZP)}; @@ -103,7 +132,8 @@ auto oidc_id_token_checks(const JWT &token, const std::string_view issuer, // OpenID Connect Core 1.0 Section 3.1.3.7 step 10: the optional issued-at age // policy if (options.maximum_issued_at_age.has_value() && - now - issued_at.value() > options.maximum_issued_at_age.value()) { + issued_at.value() < + clock_shift_backward(now, options.maximum_issued_at_age.value())) { return std::nullopt; } @@ -150,8 +180,9 @@ auto oidc_id_token_checks(const JWT &token, const std::string_view issuer, // An authentication time in the future has not happened yet, so it cannot // satisfy a freshness window and is rejected before the age comparison if (!authentication_time.has_value() || authentication_time.value() > now || - now - authentication_time.value() > - options.maximum_authentication_age.value()) { + authentication_time.value() < + clock_shift_backward(now, + options.maximum_authentication_age.value())) { return std::nullopt; } } @@ -234,7 +265,8 @@ auto oidc_validate_id_token( // audience, expiration, not-before, and issued-at (OpenID Connect Core 1.0 // Section 3.1.3.7 steps 6 through 9) const auto error{jwt_verify(token, keys, allowed_algorithms, issuer, - client_id, now, clock_skew)}; + client_id, now, clock_skew, std::nullopt, + std::nullopt)}; if (error.has_value()) { return std::nullopt; } diff --git a/vendor/core/src/core/oidc/oidc_logout.cc b/vendor/core/src/core/oidc/oidc_logout.cc index a2da68393..ea5e93bc1 100644 --- a/vendor/core/src/core/oidc/oidc_logout.cc +++ b/vendor/core/src/core/oidc/oidc_logout.cc @@ -96,30 +96,16 @@ auto oidc_validate_logout_token( } // OpenID Connect Back-Channel Logout 1.0 Section 2.6: iss and aud as for an - // ID Token - const auto token_issuer{token.issuer()}; - if (!token_issuer.has_value() || token_issuer.value() != issuer) { + // ID Token, so the base JSON Web Token check runs rather than a second copy + // of those rules. Sharing it is also what keeps the clock skew bounded the + // same way here as on every other path that validates a token + if (jwt_check_claims(token, issuer, client_id, now, clock_skew).has_value()) { return false; } - if (!token.has_audience(client_id)) { - return false; - } - - // OpenID Connect Back-Channel Logout 1.0 Section 2.4: iat is REQUIRED and - // must not be in the future - const auto issued_at{token.issued_at()}; - if (!issued_at.has_value() || - issued_at.value() > now + clock_skew.issued_at) { - return false; - } - - // OpenID Connect Back-Channel Logout 1.0 Section 2.4: exp is REQUIRED and is - // validated as for an ID Token, so a token at or past its expiration (with - // skew) is rejected - const auto expires_at{token.expires_at()}; - if (!expires_at.has_value() || - now >= expires_at.value() + clock_skew.expiration) { + // OpenID Connect Back-Channel Logout 1.0 Section 2.4: iat is REQUIRED, which + // the base check treats as optional since RFC 7519 does + if (!token.issued_at().has_value()) { return false; } diff --git a/vendor/core/src/core/oidc/oidc_metadata.cc b/vendor/core/src/core/oidc/oidc_metadata.cc index 9245ba6fe..c536a67e7 100644 --- a/vendor/core/src/core/oidc/oidc_metadata.cc +++ b/vendor/core/src/core/oidc/oidc_metadata.cc @@ -3,7 +3,6 @@ #include #include #include -#include #include #include // std::optional, std::nullopt @@ -80,6 +79,37 @@ auto is_required_string_array(const JSON *member) -> bool { return member != nullptr && member->is_array_of_strings() && !member->empty(); } +auto is_https_url(const std::string_view value) -> bool { + // OpenID Connect Discovery 1.0 Section 3: every advertised endpoint is a URL + // using the https scheme + try { + const URI uri{value}; + // Section 3 enumerates what an endpoint may carry as "port, path, and query + // parameter components", so a fragment is refused as outside that list + // rather than because it could not be dereferenced, which would be the + // wrong reason for the two endpoints a user agent loads + return uri.is_https() && uri.host().has_value() && + !uri.host().value().empty() && !uri.fragment().has_value(); + } catch (const URIParseError &) { + return false; + } +} + +// A member that is present but is not a valid https URL fails the parse rather +// than being ignored, since an accessor would otherwise report a malformed +// member as an absent one +auto validate_endpoint(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> void { + const auto *member{data.try_at(name, hash)}; + if (member == nullptr) { + return; + } + + if (!member->is_string() || !is_https_url(member->to_string())) { + throw OIDCMetadataParseError{}; + } +} + auto validate_provider_metadata(const OAuthServerMetadata &oauth) -> void { const auto &data{oauth.data()}; @@ -109,23 +139,17 @@ auto validate_provider_metadata(const OAuthServerMetadata &oauth) -> void { !id_token_algs->contains("RS256")) { throw OIDCMetadataParseError{}; } -} -auto is_https_url(const std::string_view value) -> bool { - // OpenID Connect Discovery 1.0 Section 3: every advertised endpoint is a URL - // using the https scheme - try { - const URI uri{value}; - // A fragment is never sent in an HTTP request, so an endpoint carrying one - // would advertise a location clients cannot reach and is rejected. RFC 3986 - // Section 3.1 makes the scheme case-insensitive - return uri.scheme().has_value() && - equals_ignore_case(uri.scheme().value(), "https") && - uri.host().has_value() && !uri.host().value().empty() && - !uri.fragment().has_value(); - } catch (const URIParseError &) { - return false; - } + // The endpoints the OAuth layer does not know about. OpenID Connect Discovery + // 1.0 Section 3 on userinfo_endpoint, OpenID Connect RP-Initiated Logout 1.0 + // Section 2.1 on end_session_endpoint, and OpenID Connect Session Management + // 1.0 Section 3.3 on check_session_iframe all carry the same requirement: + // "This URL MUST use the https scheme and MAY contain port, path, and query + // parameter components". The endpoints shared with OAuth are already covered + // when that document is validated, which happens before this runs + validate_endpoint(data, "userinfo_endpoint"sv, HASH_USERINFO_ENDPOINT); + validate_endpoint(data, "end_session_endpoint"sv, HASH_END_SESSION_ENDPOINT); + validate_endpoint(data, "check_session_iframe"sv, HASH_CHECK_SESSION_IFRAME); } } // namespace @@ -136,6 +160,22 @@ OIDCProviderMetadata::OIDCProviderMetadata(JSON &&data, validate_provider_metadata(this->oauth_); } +OIDCProviderMetadata::OIDCProviderMetadata(OAuthServerMetadata &&oauth) + : oauth_{std::move(oauth)} { + validate_provider_metadata(this->oauth_); +} + +auto OIDCProviderMetadata::from(OAuthServerMetadata &&oauth) + -> std::optional { + // The OAuth layer validated itself on the way in, so only the OpenID Connect + // requirements can fail here + try { + return OIDCProviderMetadata{std::move(oauth)}; + } catch (const OIDCMetadataParseError &) { + return std::nullopt; + } +} + auto OIDCProviderMetadata::from(JSON &&data, const std::string_view issuer) -> std::optional { try { @@ -206,6 +246,11 @@ auto OIDCProviderMetadata::supports_response_type( return this->oauth_.supports_response_type(value); } +auto OIDCProviderMetadata::supports_token_endpoint_auth_method( + const std::string_view value) const -> bool { + return this->oauth_.supports_token_endpoint_auth_method(value); +} + auto OIDCProviderMetadata::supports_scope(const std::string_view value) const -> bool { return this->oauth_.data().array_member_contains( diff --git a/vendor/core/src/core/oidc/oidc_registration.cc b/vendor/core/src/core/oidc/oidc_registration.cc index 390bce479..2932d3695 100644 --- a/vendor/core/src/core/oidc/oidc_registration.cc +++ b/vendor/core/src/core/oidc/oidc_registration.cc @@ -3,7 +3,6 @@ #include #include #include -#include #include #include // std::chrono::seconds @@ -33,6 +32,7 @@ constexpr auto HASH_USERINFO_SIGNED_ALG{ constexpr auto HASH_DEFAULT_MAX_AGE{JSON::Object::hash("default_max_age"sv)}; constexpr auto HASH_REQUIRE_AUTH_TIME{ JSON::Object::hash("require_auth_time"sv)}; +constexpr auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; constexpr auto HASH_INITIATE_LOGIN_URI{ JSON::Object::hash("initiate_login_uri"sv)}; constexpr auto HASH_POST_LOGOUT_REDIRECT_URIS{ @@ -71,11 +71,38 @@ auto is_absolute_uri_without_fragment(const std::string_view value) -> bool { auto is_https_url_with_host(const std::string_view value) -> bool { try { const URI uri{value}; - // RFC 3986 Section 3.1: the scheme is case-insensitive, so an uppercase or - // mixed-case HTTPS is still https - return uri.scheme().has_value() && - equals_ignore_case(uri.scheme().value(), "https") && - uri.host().has_value() && !uri.host().value().empty(); + return uri.is_https() && uri.host().has_value() && + !uri.host().value().empty(); + } catch (const URIParseError &) { + return false; + } +} + +// OpenID Connect Dynamic Client Registration 1.0 Section 2: a web client using +// the implicit grant "MUST only register URLs using the https scheme as +// redirect_uris" and "they MUST NOT use localhost as the hostname" +auto is_web_implicit_redirect_uri(const std::string_view value) -> bool { + try { + const URI uri{value}; + return uri.is_https() && uri.host().has_value() && + !uri.host().value().empty() && !uri.is_localhost(); + } catch (const URIParseError &) { + return false; + } +} + +// OpenID Connect Dynamic Client Registration 1.0 Section 2: a native client +// "MUST only register redirect_uris using custom URI schemes or loopback URLs +// using the http scheme", where a loopback URL names localhost or an IP +// loopback literal +auto is_native_redirect_uri(const std::string_view value) -> bool { + try { + const URI uri{value}; + if (uri.is_http()) { + return uri.is_loopback() || uri.is_localhost(); + } + + return uri.scheme().has_value() && !uri.is_https(); } catch (const URIParseError &) { return false; } @@ -114,6 +141,33 @@ auto validate_client_metadata(const OAuthClientMetadata &oauth) -> void { throw OIDCRegistrationParseError{}; } + // OpenID Connect Dynamic Client Registration 1.0 Section 2: "The + // Authorization Server MUST verify that all the registered redirect_uris + // conform to these constraints". A native client is bound to custom-scheme or + // http loopback callbacks, while a web client using the implicit grant is + // bound to https callbacks that are not localhost. The default application + // type is web, and a web client that does not use the implicit grant carries + // no such restriction + const auto application_type{ + string_member(data, "application_type"sv, HASH_APPLICATION_TYPE) + .value_or("web"sv)}; + // OpenID Connect Dynamic Client Registration 1.0 Section 2 defines only the + // web and native application types, so an unknown value is rejected rather + // than defaulted and left unrestricted + if (application_type != "web"sv && application_type != "native"sv) { + throw OIDCRegistrationParseError{}; + } + const bool native{application_type == "native"sv}; + if (native || oauth.supports_grant_type("implicit"sv)) { + for (const auto &element : redirect_uris->as_array()) { + const std::string_view redirect_uri{element.to_string()}; + if (native ? !is_native_redirect_uri(redirect_uri) + : !is_web_implicit_redirect_uri(redirect_uri)) { + throw OIDCRegistrationParseError{}; + } + } + } + const auto *max_age{data.try_at("default_max_age"sv, HASH_DEFAULT_MAX_AGE)}; if (max_age != nullptr && (!max_age->is_integer() || max_age->to_integer() < 0)) { @@ -143,6 +197,20 @@ auto validate_client_metadata(const OAuthClientMetadata &oauth) -> void { throw OIDCRegistrationParseError{}; } + // OpenID Connect Dynamic Client Registration 1.0 Section 2: the jwks_uri is a + // "URL for the Client's JWK Set document, which MUST use the https scheme". + // The provider fetches it to obtain the keys that authenticate the client, so + // over cleartext an attacker substitutes them and impersonates the client. + // Note that request_uris carries the neighbouring rule conditionally, "unless + // the target Request Object is signed in a way that is verifiable by the OP", + // which is not knowable here, so it is deliberately left unchecked + const auto *client_keys{data.try_at("jwks_uri"sv, HASH_JWKS_URI)}; + if (client_keys != nullptr && + (!client_keys->is_string() || + !is_https_url_with_host(client_keys->to_string()))) { + throw OIDCRegistrationParseError{}; + } + // OpenID Connect Core 1.0 Section 4: the initiate_login_uri is a third-party // login target that "MUST use the https scheme" const auto *initiate_login{ diff --git a/vendor/core/src/core/oidc/oidc_request_object.cc b/vendor/core/src/core/oidc/oidc_request_object.cc index af236b2f6..d7e0f7f1e 100644 --- a/vendor/core/src/core/oidc/oidc_request_object.cc +++ b/vendor/core/src/core/oidc/oidc_request_object.cc @@ -18,6 +18,7 @@ using namespace std::literals::string_view_literals; constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)}; constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; +constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; } // namespace @@ -53,17 +54,23 @@ auto oidc_verify_request_object( return std::nullopt; } - // OpenID Connect Core 1.0 Section 6.1: the request object is issued by the - // client and addressed to the OpenID Provider, so iss and aud are REQUIRED - // and must match the client and the provider respectively + // OpenID Connect Core 1.0 Section 6.1: "If signed, the Request Object SHOULD + // contain the Claims iss (issuer) and aud (audience) as members", and "The + // aud value SHOULD be or include the OP's Issuer Identifier URL". The + // audience binds the object to this provider, so a signed object without it + // could be replayed to a different provider. That replay risk is closed by + // requiring a present aud that includes the provider here, even though the + // base specification phrases the claim as a recommendation. The issuer stays + // a recommendation and is validated as the client only when present const auto &payload{token.payload()}; const auto *issuer{payload.try_at("iss"sv, HASH_ISS)}; - if (issuer == nullptr || !issuer->is_string() || - issuer->to_string() != client_id) { + if (issuer != nullptr && + (!issuer->is_string() || issuer->to_string() != client_id)) { return std::nullopt; } - if (!token.has_audience(provider_issuer)) { + const auto *audience{payload.try_at("aud"sv, HASH_AUD)}; + if (audience == nullptr || !token.has_audience(provider_issuer)) { return std::nullopt; } diff --git a/vendor/core/src/core/oidc/oidc_userinfo.cc b/vendor/core/src/core/oidc/oidc_userinfo.cc index 3da17dd2b..a2dba9bb1 100644 --- a/vendor/core/src/core/oidc/oidc_userinfo.cc +++ b/vendor/core/src/core/oidc/oidc_userinfo.cc @@ -61,19 +61,20 @@ auto oidc_verify_userinfo( return std::nullopt; } - // OpenID Connect Core 1.0 Section 5.3.2: a signed response SHOULD carry iss - // and aud, so when present the iss must be this provider and the aud must - // include this client, which stops a response minted for another client from - // being accepted here. The raw members are inspected so a present but - // non-string claim fails closed rather than being read as absent + // OpenID Connect Core 1.0 errata set 2 Section 5.3.2: "If signed, the + // UserInfo Response ... MUST contain the Claims iss (issuer) and aud + // (audience) as members". So a signed response is rejected when either is + // absent, the iss must be this provider, and the aud must include this + // client, which stops a response minted for another client from being + // accepted here const auto *issuer{token.payload().try_at("iss"sv, HASH_ISS)}; - if (issuer != nullptr && - (!issuer->is_string() || issuer->to_string() != expected_issuer)) { + if (issuer == nullptr || !issuer->is_string() || + issuer->to_string() != expected_issuer) { return std::nullopt; } const auto *audience{token.payload().try_at("aud"sv, HASH_AUD)}; - if (audience != nullptr && !token.has_audience(expected_client_id)) { + if (audience == nullptr || !token.has_audience(expected_client_id)) { return std::nullopt; } diff --git a/vendor/core/src/core/time/CMakeLists.txt b/vendor/core/src/core/time/CMakeLists.txt index e4280ef9f..459cfd6bd 100644 --- a/vendor/core/src/core/time/CMakeLists.txt +++ b/vendor/core/src/core/time/CMakeLists.txt @@ -1,7 +1,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME time SOURCES helpers.h imf_fixdate.cc rfc850_date.cc asctime.cc iso8601_basic.cc - unix_timestamp.cc rfc3339_datetime.cc rfc3339_fulldate.cc + unix_timestamp.cc clock_shift.cc rfc3339_datetime.cc rfc3339_fulldate.cc rfc3339_fulltime.cc rfc3339_partialtime_no_secfrac.cc rfc3339_duration.cc) diff --git a/vendor/core/src/core/time/asctime.cc b/vendor/core/src/core/time/asctime.cc index c3faac92c..87835a1f0 100644 --- a/vendor/core/src/core/time/asctime.cc +++ b/vendor/core/src/core/time/asctime.cc @@ -13,7 +13,9 @@ #include // std::string_view namespace { -constexpr auto FORMAT_ASCTIME_OUTPUT{"%a %b %e %H:%M:%S %Y"}; +// The year is rendered separately so it is always four digits (RFC 9110 +// §5.6.7: "year = 4DIGIT"), which "%Y" does not guarantee for a year below 1000 +constexpr auto FORMAT_ASCTIME_OUTPUT_BEFORE_YEAR{"%a %b %e %H:%M:%S "}; constexpr auto FORMAT_ASCTIME_NORMALISED_INPUT{"%a %b %d %H:%M:%S %Y"}; } // namespace @@ -24,7 +26,8 @@ auto to_asctime(const std::chrono::system_clock::time_point time) const auto parts{time_point_to_broken_down(time)}; std::ostringstream stream; stream.imbue(std::locale::classic()); - stream << std::put_time(&parts, FORMAT_ASCTIME_OUTPUT); + stream << std::put_time(&parts, FORMAT_ASCTIME_OUTPUT_BEFORE_YEAR) + << format_four_digit_year(parts.tm_year + 1900); return stream.str(); } diff --git a/vendor/core/src/core/time/clock_shift.cc b/vendor/core/src/core/time/clock_shift.cc new file mode 100644 index 000000000..407cd597f --- /dev/null +++ b/vendor/core/src/core/time/clock_shift.cc @@ -0,0 +1,55 @@ +#include + +#include // std::clamp +#include // std::chrono::system_clock, std::chrono::seconds, std::chrono::duration_cast +#include // std::ratio, std::ratio_less_equal_v + +namespace { + +using Clock = std::chrono::system_clock; + +// Expressing the clock's widest span in seconds is a division only while a tick +// is no coarser than a second, which holds for every standard library this +// builds against. A coarser tick would turn it into a multiplication that +// overflows, so the assumption is stated here rather than left to be discovered +static_assert(std::ratio_less_equal_v>); + +// The span reduced to ticks the clock can hold, truncated toward zero so the +// conversion back can never exceed the representable range. A negative span +// becomes none, since a span that runs backwards has no meaning here and +// shifting the wrong way would be worse than ignoring it +auto bounded_ticks(const std::chrono::seconds span) noexcept + -> Clock::duration { + constexpr auto limit{ + std::chrono::duration_cast(Clock::duration::max())}; + return std::chrono::duration_cast( + std::clamp(span, std::chrono::seconds::zero(), limit)); +} + +} // namespace + +namespace sourcemeta::core { + +auto clock_shift_backward(const std::chrono::system_clock::time_point time, + const std::chrono::seconds span) noexcept + -> std::chrono::system_clock::time_point { + const auto ticks{bounded_ticks(span)}; + if (time.time_since_epoch() < Clock::duration::min() + ticks) { + return Clock::time_point{Clock::duration::min()}; + } + + return time - ticks; +} + +auto clock_shift_forward(const std::chrono::system_clock::time_point time, + const std::chrono::seconds span) noexcept + -> std::chrono::system_clock::time_point { + const auto ticks{bounded_ticks(span)}; + if (time.time_since_epoch() > Clock::duration::max() - ticks) { + return Clock::time_point{Clock::duration::max()}; + } + + return time + ticks; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/time/helpers.h b/vendor/core/src/core/time/helpers.h index 58f7b0a27..301671b86 100644 --- a/vendor/core/src/core/time/helpers.h +++ b/vendor/core/src/core/time/helpers.h @@ -5,9 +5,11 @@ #include // std::array #include // std::chrono::system_clock, std::chrono::sys_days +#include // std::size_t #include // std::uint8_t, std::uint16_t #include // std::tm #include // std::optional, std::nullopt +#include // std::string, std::to_string #include // std::string_view #include // std::cmp_greater @@ -69,6 +71,22 @@ inline auto time_point_to_broken_down( return parts; } +// RFC 9110 §5.6.7 defines the calendar year as "year = 4DIGIT", a fixed width +// that asctime and the ISO 8601 basic format share, yet rendering the year +// through the standard library omits its leading zeros for a year below 1000 on +// several platforms, which the parsers here then reject. The field is built +// explicitly so a representable year is always at least four digits. The year +// is expected to be non-negative, as every caller derives it from a broken-down +// time whose year is offset from 1900 +inline auto format_four_digit_year(const int year) -> std::string { + std::string digits{std::to_string(year)}; + if (digits.size() < 4) { + digits.insert(std::size_t{0}, 4 - digits.size(), '0'); + } + + return digits; +} + // RFC 9110 §5.6.7: "HTTP-date is case sensitive". The standard library's // std::get_time matches day and month names case-insensitively on some // implementations, so the exact spelling is verified against the parsed index diff --git a/vendor/core/src/core/time/imf_fixdate.cc b/vendor/core/src/core/time/imf_fixdate.cc index 5a77d4950..a7c1b9b59 100644 --- a/vendor/core/src/core/time/imf_fixdate.cc +++ b/vendor/core/src/core/time/imf_fixdate.cc @@ -14,7 +14,11 @@ namespace { constexpr auto FORMAT_IMF_FIXDATE{"%a, %d %b %Y %H:%M:%S GMT"}; -} +// The year is rendered separately so it is always four digits (RFC 9110 +// §5.6.7: "year = 4DIGIT"), which "%Y" does not guarantee for a year below 1000 +constexpr auto FORMAT_IMF_FIXDATE_BEFORE_YEAR{"%a, %d %b "}; +constexpr auto FORMAT_IMF_FIXDATE_AFTER_YEAR{" %H:%M:%S GMT"}; +} // namespace namespace sourcemeta::core { @@ -23,7 +27,9 @@ auto to_imf_fixdate(const std::chrono::system_clock::time_point time) const auto parts{time_point_to_broken_down(time)}; std::ostringstream stream; stream.imbue(std::locale::classic()); - stream << std::put_time(&parts, FORMAT_IMF_FIXDATE); + stream << std::put_time(&parts, FORMAT_IMF_FIXDATE_BEFORE_YEAR) + << format_four_digit_year(parts.tm_year + 1900) + << std::put_time(&parts, FORMAT_IMF_FIXDATE_AFTER_YEAR); return stream.str(); } diff --git a/vendor/core/src/core/time/include/sourcemeta/core/time.h b/vendor/core/src/core/time/include/sourcemeta/core/time.h index 9461e8560..92a9f783e 100644 --- a/vendor/core/src/core/time/include/sourcemeta/core/time.h +++ b/vendor/core/src/core/time/include/sourcemeta/core/time.h @@ -41,7 +41,12 @@ auto to_imf_fixdate(const std::chrono::system_clock::time_point time) -> std::string; /// @ingroup time -/// Parse an RFC 9110 §5.6.7 IMF-fixdate string into a time point. For example: +/// Parse an RFC 9110 §5.6.7 IMF-fixdate string into a time point. The leading +/// day-name is validated as a real weekday name but is not cross-checked +/// against the calendar date, so a syntactically valid string whose day-name +/// disagrees with its date is still accepted. RFC 9110 imposes no recipient +/// requirement to reject such a string, the day-name is redundant with the +/// date, and this matches how browsers and common clients behave. For example: /// /// ```cpp /// #include @@ -72,7 +77,12 @@ auto to_rfc850_date(const std::chrono::system_clock::time_point time) /// @ingroup time /// Parse an RFC 850 date string into a time point. The two-digit year is -/// interpreted per RFC 9110 §5.6.7. For example: +/// interpreted per RFC 9110 §5.6.7. The leading day-name is validated as a real +/// weekday name but is not cross-checked against the calendar date, so a +/// syntactically valid string whose day-name disagrees with its date is still +/// accepted. RFC 9110 imposes no recipient requirement to reject such a string, +/// the day-name is redundant with the date, and this matches how browsers and +/// common clients behave. For example: /// /// ```cpp /// #include @@ -104,7 +114,12 @@ auto to_asctime(const std::chrono::system_clock::time_point time) /// @ingroup time /// Parse an RFC 9110 §5.6.7 asctime-date string into a time point. The format -/// has no timezone token and is interpreted as GMT. For example: +/// has no timezone token and is interpreted as GMT. The leading day-name is +/// validated as a real weekday name but is not cross-checked against the +/// calendar date, so a syntactically valid string whose day-name disagrees with +/// its date is still accepted. RFC 9110 imposes no recipient requirement to +/// reject such a string, the day-name is redundant with the date, and this +/// matches how browsers and common clients behave. For example: /// /// ```cpp /// #include @@ -187,6 +202,46 @@ auto to_unix_timestamp( const std::chrono::system_clock::time_point time) noexcept -> std::chrono::duration; +/// @ingroup time +/// Move a time point back by a span of seconds, saturating at the oldest +/// instant the clock can represent instead of overflowing. A negative span is +/// treated as no span at all. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto point{std::chrono::system_clock::from_time_t(100)}; +/// assert(sourcemeta::core::clock_shift_backward( +/// point, std::chrono::seconds{40}) == +/// std::chrono::system_clock::from_time_t(60)); +/// ``` +SOURCEMETA_CORE_TIME_EXPORT +auto clock_shift_backward(const std::chrono::system_clock::time_point time, + const std::chrono::seconds span) noexcept + -> std::chrono::system_clock::time_point; + +/// @ingroup time +/// Move a time point forward by a span of seconds, saturating at the newest +/// instant the clock can represent instead of overflowing. A negative span is +/// treated as no span at all. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto point{std::chrono::system_clock::from_time_t(100)}; +/// assert(sourcemeta::core::clock_shift_forward( +/// point, std::chrono::seconds{40}) == +/// std::chrono::system_clock::from_time_t(140)); +/// ``` +SOURCEMETA_CORE_TIME_EXPORT +auto clock_shift_forward(const std::chrono::system_clock::time_point time, + const std::chrono::seconds span) noexcept + -> std::chrono::system_clock::time_point; + /// @ingroup time /// Check whether the given string is a valid date-time value per RFC 3339 /// Section 5.6 (Internet Date/Time Format). This implements the full diff --git a/vendor/core/src/core/time/iso8601_basic.cc b/vendor/core/src/core/time/iso8601_basic.cc index e0b129c84..7db5d2091 100644 --- a/vendor/core/src/core/time/iso8601_basic.cc +++ b/vendor/core/src/core/time/iso8601_basic.cc @@ -14,7 +14,10 @@ #include // std::string_view namespace { -constexpr auto FORMAT_ISO8601_BASIC{"%Y%m%dT%H%M%SZ"}; +// The year is rendered separately so it is always four digits, as the basic +// format is fixed width (ISO 8601-1 §5.4.2.1), which "%Y" does not guarantee +// for a year below 1000 +constexpr auto FORMAT_ISO8601_BASIC_AFTER_YEAR{"%m%dT%H%M%SZ"}; auto all_digits(const std::string_view value, const std::size_t offset, const std::size_t length) -> bool { @@ -44,7 +47,8 @@ auto to_iso8601_basic(const std::chrono::system_clock::time_point time) const auto parts{time_point_to_broken_down(time)}; std::ostringstream stream; stream.imbue(std::locale::classic()); - stream << std::put_time(&parts, FORMAT_ISO8601_BASIC); + stream << format_four_digit_year(parts.tm_year + 1900) + << std::put_time(&parts, FORMAT_ISO8601_BASIC_AFTER_YEAR); return stream.str(); } diff --git a/vendor/core/src/core/uri/accessors.cc b/vendor/core/src/core/uri/accessors.cc index a2d5d2f50..73f7fc49a 100644 --- a/vendor/core/src/core/uri/accessors.cc +++ b/vendor/core/src/core/uri/accessors.cc @@ -1,4 +1,5 @@ #include +#include #include #include // std::uint32_t @@ -35,6 +36,19 @@ auto URI::is_file() const -> bool { return scheme.has_value() && scheme.value() == "file"; } +// RFC 3986 Section 3.1: "an implementation should accept uppercase letters as +// equivalent to lowercase in scheme names" + +auto URI::is_http() const -> bool { + const auto scheme{this->scheme()}; + return scheme.has_value() && equals_ignore_case(scheme.value(), "http"); +} + +auto URI::is_https() const -> bool { + const auto scheme{this->scheme()}; + return scheme.has_value() && equals_ignore_case(scheme.value(), "https"); +} + auto URI::is_ipv4() const -> bool { return this->host_.has_value() && sourcemeta::core::is_ipv4(this->host_.value()); @@ -45,6 +59,43 @@ auto URI::is_ipv6() const -> bool { sourcemeta::core::is_ipv6(this->host_.value()); } +auto URI::is_loopback() const -> bool { + if (!this->host_.has_value()) { + return false; + } + + return ipv4_classify(this->host_.value()) == IPAddressClass::Loopback || + ipv6_classify(this->host_.value()) == IPAddressClass::Loopback; +} + +auto URI::is_localhost() const -> bool { + // RFC 3986 Section 3.2.2 separates an IP literal from a registered name, and + // only the latter can be a domain name, so a bracketed host such as an + // IPvFuture ending in the reserved labels does not qualify + if (!this->host_.has_value() || this->ip_literal_) { + return false; + } + + // RFC 6761 Section 6.3 reserves "The domain 'localhost.' and any names + // falling within '.localhost.'", where the trailing dot marks the absolute + // form of a name that compares case-insensitively (RFC 4343 Section 2) + std::string_view host{this->host_.value()}; + if (host.ends_with('.')) { + host.remove_suffix(1); + } + + constexpr std::string_view name{"localhost"}; + if (host.size() < name.size()) { + return false; + } + + if (host.size() > name.size() && host[host.size() - name.size() - 1] != '.') { + return false; + } + + return equals_ignore_case(host.substr(host.size() - name.size()), name); +} + auto URI::is_fragment_only() const -> bool { return !this->scheme().has_value() && !this->host().has_value() && !this->port().has_value() && !this->path().has_value() && diff --git a/vendor/core/src/core/uri/canonicalize.cc b/vendor/core/src/core/uri/canonicalize.cc index 513fb2502..f1276ebf2 100644 --- a/vendor/core/src/core/uri/canonicalize.cc +++ b/vendor/core/src/core/uri/canonicalize.cc @@ -154,6 +154,11 @@ auto URI::canonicalize() -> URI & { if (this->host_.has_value()) { normalize_component(this->host_.value(), this->iri_); sourcemeta::core::to_lowercase(this->host_.value()); + // The case fold above also lowercases the digits inside percent-encoding + // triplets, so the triplet hex is normalised back to uppercase last (RFC + // 3986 Section 6.2.2.1 "the hexadecimal digits within a percent-encoding + // triplet [...] should be normalized to use uppercase letters") + uri_normalize_percent_encoding_inplace(this->host_.value()); } // Remove default ports (80 for http, 443 for https) diff --git a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h index f000a6a61..d0c5e9397 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -137,6 +137,30 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` [[nodiscard]] auto is_file() const -> bool; + /// Check if the URI has the `http` scheme (RFC 9110 Section 4.2.1), + /// accepting any scheme case (RFC 3986 Section 3.1). For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::URI uri{"http://www.sourcemeta.com"}; + /// assert(uri.is_http()); + /// ``` + [[nodiscard]] auto is_http() const -> bool; + + /// Check if the URI has the `https` scheme (RFC 9110 Section 4.2.2), + /// accepting any scheme case (RFC 3986 Section 3.1). For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::URI uri{"https://www.sourcemeta.com"}; + /// assert(uri.is_https()); + /// ``` + [[nodiscard]] auto is_https() const -> bool; + /// Check if the URI only consists of a fragment. For example: /// /// ```cpp @@ -181,6 +205,40 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` [[nodiscard]] auto is_ipv6() const -> bool; + /// Check if the host is a loopback IP literal, any address in `127.0.0.0/8` + /// (RFC 1122 Section 3.2.1.3), `::1` (RFC 4291 Section 2.5.3), or an + /// IPv4-mapped or IPv4-compatible IPv6 address embedding a `127.0.0.0/8` + /// address (RFC 4291 Section 2.5.5). The name `localhost` is deliberately + /// not a loopback host, as it usually resolves to one but that is a + /// different claim (RFC 8252 Section 8.3). For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::URI uri{"http://127.0.0.1:8000"}; + /// assert(uri.is_loopback()); + /// sourcemeta::core::URI name{"http://localhost:8000"}; + /// assert(!name.is_loopback()); + /// ``` + [[nodiscard]] auto is_loopback() const -> bool; + + /// Check if the host is the special-use domain name `localhost` or a name + /// falling within it, such as `foo.localhost`, in any case and with or + /// without the trailing dot of the absolute form (RFC 6761 Section 6.3). A + /// loopback IP literal is not a localhost name. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::URI uri{"http://localhost:8000"}; + /// assert(uri.is_localhost()); + /// sourcemeta::core::URI address{"http://127.0.0.1:8000"}; + /// assert(!address.is_localhost()); + /// ``` + [[nodiscard]] auto is_localhost() const -> bool; + /// Check if the URI corresponds to the empty URI. For example: /// /// ```cpp diff --git a/vendor/core/src/core/uritemplate/helpers.h b/vendor/core/src/core/uritemplate/helpers.h index 0d21976d6..74c3adc31 100644 --- a/vendor/core/src/core/uritemplate/helpers.h +++ b/vendor/core/src/core/uritemplate/helpers.h @@ -89,11 +89,26 @@ inline auto append_percent_encoded(std::string &output, const char character) output += HEX_DIGITS[byte & 0x0F]; } -inline auto percent_encode(std::string &output, const std::string_view input) - -> void { +// In the IRI mode, the bytes of internationalized characters pass through +// rather than becoming percent encoded triplets. This is an extension beyond +// RFC 6570, whose result always takes the URI syntax (Section 1.1: "Although +// the URI syntax is used for the result, the template string is allowed to +// contain the broader set of characters that can be found in +// Internationalized Resource Identifier (IRI) references"). Nothing is ever +// decoded, and whether the passed through codepoints are valid in an IRI is +// left to the caller +inline auto passes_through_unencoded(const char character, + const URITemplateExpansionMode mode) + -> bool { + return mode == URITemplateExpansionMode::IRI && + static_cast(character) >= 0x80; +} + +inline auto percent_encode(std::string &output, const std::string_view input, + const URITemplateExpansionMode mode) -> void { output.reserve(output.size() + input.size() * 3); for (const char character : input) { - if (is_unreserved(character)) { + if (is_unreserved(character) || passes_through_unencoded(character, mode)) { output += character; } else { append_percent_encoded(output, character); @@ -102,11 +117,14 @@ inline auto percent_encode(std::string &output, const std::string_view input) } inline auto percent_encode_reserved(std::string &output, - const std::string_view input) -> void { + const std::string_view input, + const URITemplateExpansionMode mode) + -> void { output.reserve(output.size() + input.size() * 3); for (std::size_t index = 0; index < input.size(); ++index) { const char character = input[index]; if (is_unreserved(character) || is_reserved(character) || + passes_through_unencoded(character, mode) || (character == '%' && index + 2 < input.size() && is_hex_digit(input[index + 1]) && is_hex_digit(input[index + 2]))) { output += character; @@ -117,11 +135,12 @@ inline auto percent_encode_reserved(std::string &output, } template -inline auto encode(std::string &output, const std::string_view input) -> void { +inline auto encode(std::string &output, const std::string_view input, + const URITemplateExpansionMode mode) -> void { if constexpr (T::allow_reserved) { - percent_encode_reserved(output, input); + percent_encode_reserved(output, input, mode); } else { - percent_encode(output, input); + percent_encode(output, input, mode); } } @@ -399,7 +418,8 @@ template auto expand_expression( std::string &result, const std::vector &variables, - const std::function &callback) -> void { + const std::function &callback, + const URITemplateExpansionMode mode) -> void { bool first_var = true; for (const auto &variable : variables) { @@ -435,14 +455,14 @@ auto expand_expression( } if (object_key.has_value()) { - encode(result, object_key.value()); + encode(result, object_key.value(), mode); if (actual_value.empty()) { if constexpr (has_empty_suffix::value) { result += T::empty_suffix; } } else { result += '='; - encode(result, actual_value); + encode(result, actual_value, mode); } } else if constexpr (T::named) { result += variable.name; @@ -452,10 +472,10 @@ auto expand_expression( } } else { result += '='; - encode(result, actual_value); + encode(result, actual_value, mode); } } else { - encode(result, actual_value); + encode(result, actual_value, mode); } } else { // An associative-array pair always contributes its key, so it is never @@ -476,10 +496,10 @@ auto expand_expression( if (!first_value || !value_empty || has_more) { if (object_key.has_value()) { - encode(result, object_key.value()); + encode(result, object_key.value(), mode); result += ','; } - encode(result, actual_value); + encode(result, actual_value, mode); } } diff --git a/vendor/core/src/core/uritemplate/include/sourcemeta/core/uritemplate.h b/vendor/core/src/core/uritemplate/include/sourcemeta/core/uritemplate.h index 29f5a49ff..a0843e908 100644 --- a/vendor/core/src/core/uritemplate/include/sourcemeta/core/uritemplate.h +++ b/vendor/core/src/core/uritemplate/include/sourcemeta/core/uritemplate.h @@ -37,6 +37,22 @@ namespace sourcemeta::core { using URITemplateValue = std::optional< std::tuple, bool>>; +/// @ingroup uritemplate +/// The alphabet that expansion encodes variable values against +enum class URITemplateExpansionMode : std::uint8_t { + /// Encode against the URI alphabet as defined by RFC 6570 + URI, + /// Encode against the IRI alphabet, passing internationalized characters + /// through unencoded rather than percent encoding them. RFC 6570 Section + /// 1.1 acknowledges that "a URI Template is also an IRI template", but + /// only by expanding to a URI and converting the result per RFC 3987 + /// Section 3.2, a conversion that also decodes percent encoded triplets + /// already present in variable values, whereas this mode never decodes + /// anything. Passed through characters are not validated against the + /// RFC 3987 grammar, which is left to the caller + IRI +}; + /// @ingroup uritemplate /// The result of parsing a token: the token and how many characters were /// consumed @@ -95,14 +111,18 @@ class SOURCEMETA_CORE_URITEMPLATE_EXPORT URITemplate { /// Expand the template by looking up variable values via a callback. /// The callback is called repeatedly for composite values - [[nodiscard]] auto expand( - const std::function &callback) const + [[nodiscard]] auto + expand(const std::function &callback, + URITemplateExpansionMode mode = URITemplateExpansionMode::URI) const -> std::string; /// Expand the template using an associative container (string values only) template > - [[nodiscard]] auto expand(const Container &variables) const -> std::string { + [[nodiscard]] auto expand( + const Container &variables, + const URITemplateExpansionMode mode = URITemplateExpansionMode::URI) const + -> std::string { return this->expand( [&variables](const std::string_view name) -> URITemplateValue { const auto iterator{find_variable(variables, name)}; @@ -112,7 +132,8 @@ class SOURCEMETA_CORE_URITEMPLATE_EXPORT URITemplate { return std::make_tuple(std::string_view{iterator->second}, std::nullopt, false); } - }); + }, + mode); } private: diff --git a/vendor/core/src/core/uritemplate/uritemplate.cc b/vendor/core/src/core/uritemplate/uritemplate.cc index 1605e5f27..17bf4163e 100644 --- a/vendor/core/src/core/uritemplate/uritemplate.cc +++ b/vendor/core/src/core/uritemplate/uritemplate.cc @@ -109,18 +109,18 @@ auto URITemplate::end() const noexcept } auto URITemplate::expand( - const std::function &callback) - const -> std::string { + const std::function &callback, + const URITemplateExpansionMode mode) const -> std::string { std::string result; for (const auto &token : this->tokens_) { std::visit( - [&result, &callback](const auto &expansion) -> void { + [&result, &callback, mode](const auto &expansion) -> void { using T = std::decay_t; if constexpr (std::is_same_v) { result += expansion.value; } else { - expand_expression(result, expansion.variables, callback); + expand_expression(result, expansion.variables, callback, mode); } }, token); diff --git a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h index e806db081..4d4e9a32a 100644 --- a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h +++ b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h @@ -30,7 +30,8 @@ namespace sourcemeta::core { /// @ingroup yaml /// /// Create a JSON document from a C++ standard input stream that represents a -/// YAML document. For example: +/// YAML document. The input must be UTF-8, optionally preceded by a UTF-8 byte +/// order mark, as UTF-16 and UTF-32 input are not supported. For example: /// /// ```cpp /// #include @@ -49,7 +50,8 @@ auto parse_yaml(std::basic_istream &stream) /// @ingroup yaml /// /// Create a JSON document from a C++ standard input stream that represents a -/// YAML document. For example: +/// YAML document. The input must be UTF-8, optionally preceded by a UTF-8 byte +/// order mark, as UTF-16 and UTF-32 input are not supported. For example: /// /// ```cpp /// #include @@ -68,8 +70,9 @@ auto parse_yaml(const JSON::String &input) -> JSON; /// @ingroup yaml /// -/// Read a JSON document from a file location that represents a YAML file. For -/// example: +/// Read a JSON document from a file location that represents a YAML file. The +/// file must be UTF-8, optionally preceded by a UTF-8 byte order mark, as +/// UTF-16 and UTF-32 input are not supported. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/yaml/lexer.h b/vendor/core/src/core/yaml/lexer.h index c33b5b7f3..d8e1e3f04 100644 --- a/vendor/core/src/core/yaml/lexer.h +++ b/vendor/core/src/core/yaml/lexer.h @@ -67,7 +67,18 @@ struct Token { class Lexer { public: Lexer(const std::string_view input, const bool roundtrip_mode = false) - : input_{input}, roundtrip_{roundtrip_mode} {} + : input_{strip_byte_order_mark(input)}, + bom_length_{input.size() - this->input_.size()}, + roundtrip_{roundtrip_mode} { + this->validate_characters(); + } + + // The number of leading bytes consumed by a stripped byte order mark, so a + // caller reading from a stream can map a consumed count back to the original + // input offset + [[nodiscard]] auto bom_length() const noexcept -> std::size_t { + return this->bom_length_; + } auto next() -> std::optional { if (this->roundtrip_) { @@ -352,6 +363,61 @@ class Lexer { } private: + // YAML 1.2.2 Section 5.2: a stream may begin with a byte order mark that + // selects the character encoding. Only the UTF-8 encoding is supported here, + // so its byte order mark is skipped before lexing + [[nodiscard]] static auto + strip_byte_order_mark(const std::string_view input) noexcept + -> std::string_view { + if (input.size() >= 3 && static_cast(input[0]) == 0xEF && + static_cast(input[1]) == 0xBB && + static_cast(input[2]) == 0xBF) { + return std::string_view{input.data() + 3, input.size() - 3}; + } + return input; + } + + // YAML 1.2.2 Section 5.1: the JSON-compatible production is "nb-json ::= x09 + // | [x20-x10FFFF]", and every character in the stream is built on it, so a C0 + // control other than tab, line feed, and carriage return is never allowed in + // any context, including inside a quoted scalar. The narrower printable set + // excludes further code points such as the delete character, the high control + // block, and the two permanently unassigned code points, but only outside + // quoted scalars, so that context-specific restriction is left to the + // per-context scanners rather than enforced by this whole-stream pass + [[nodiscard]] static auto + is_disallowed_control(const char32_t codepoint) noexcept -> bool { + return codepoint <= 0x1F && codepoint != 0x09 && codepoint != 0x0A && + codepoint != 0x0D; + } + + auto validate_characters() const -> void { + std::uint64_t line{1}; + std::uint64_t column{1}; + std::size_t position{0}; + while (position < this->input_.size()) { + const auto decoded{utf8_decode(this->input_, position)}; + // YAML 1.2.2 Section 5.2: a stream is a sequence of characters in one of + // the supported encodings, so a byte sequence that does not decode is not + // a well-formed stream + if (!decoded.has_value()) [[unlikely]] { + throw YAMLParseError{line, column, + "Invalid UTF-8 sequence in YAML stream"}; + } + if (is_disallowed_control(decoded->first)) [[unlikely]] { + throw YAMLParseError{line, column, + "Control character not allowed in YAML stream"}; + } + if (decoded->first == '\n') { + line++; + column = 1; + } else { + column++; + } + position += decoded->second; + } + } + [[nodiscard]] static auto is_whitespace(const char character) noexcept -> bool { return character == ' ' || character == '\t' || character == '\n' || @@ -1650,6 +1716,7 @@ class Lexer { } std::string_view input_; + std::size_t bom_length_{0}; std::size_t position_{0}; std::uint64_t line_{1}; std::uint64_t column_{1}; diff --git a/vendor/core/src/core/yaml/parser.h b/vendor/core/src/core/yaml/parser.h index dc9791617..cbb0eee37 100644 --- a/vendor/core/src/core/yaml/parser.h +++ b/vendor/core/src/core/yaml/parser.h @@ -11,7 +11,6 @@ #include // assert #include // std::uint64_t, std::int64_t -#include // std::numeric_limits #include // std::optional #include // std::ostringstream #include // std::string @@ -102,6 +101,7 @@ class Parser { throw YAMLParseError{1, 1, "Empty YAML document"}; } else if (token->type == TokenType::DocumentEnd) { while (token.has_value() && token->type == TokenType::DocumentEnd) { + this->document_ended_ = true; token = this->lexer_->next(); } if (!token.has_value() || token->type == TokenType::StreamEnd) @@ -135,6 +135,7 @@ class Parser { } } while (token.has_value() && token->type == TokenType::DocumentEnd) { + this->document_ended_ = true; if (this->roundtrip_) { this->roundtrip_->pre_end_comments = this->lexer_->take_preceding_comments(); @@ -176,9 +177,17 @@ class Parser { auto validate_end_of_stream() -> void { auto token{this->next_token()}; - bool saw_document_end{false}; + // The preceding parse already consumed a document, so its end marker, if + // any, is not among the tokens seen here. YAML 1.2.2 Section 6.8.2: tag + // directives are local to one document, so crossing that boundary begins a + // fresh directive scope. + bool saw_document_end{this->document_ended_}; + if (saw_document_end) { + this->tag_directives_.clear(); + } while (token.has_value() && token->type == TokenType::DocumentEnd) { saw_document_end = true; + this->tag_directives_.clear(); token = this->next_token(); } if (!token.has_value() || token->type == TokenType::StreamEnd) { @@ -186,7 +195,6 @@ class Parser { } while (token.has_value() && token->type != TokenType::StreamEnd) { if (token->type == TokenType::DocumentStart) { - this->tag_directives_.clear(); token = this->next_token(); if (!token.has_value() || token->type == TokenType::StreamEnd) { return; @@ -218,6 +226,9 @@ class Parser { token = this->next_token(); while (token.has_value() && token->type == TokenType::DocumentEnd) { saw_document_end = true; + // YAML 1.2.2 Section 6.8.2: the next document begins with an empty + // directive scope + this->tag_directives_.clear(); token = this->next_token(); } } @@ -295,6 +306,13 @@ class Parser { throw YAMLParseError{token.line, token.column, "Invalid version in %YAML directive"}; } + // YAML 1.2.2 Section 6.8.1: a document that names a higher major + // version than this processor supports must be rejected + const auto major{version.substr(0, version_dot)}; + if (major.size() > 1 || major.front() > '1') [[unlikely]] { + throw YAMLParseError{token.line, token.column, + "Unsupported major version in %YAML directive"}; + } while (cursor < content.size() && (content[cursor] == ' ' || content[cursor] == '\t')) { cursor++; @@ -330,6 +348,13 @@ class Parser { const auto prefix{ std::string{content.substr(prefix_start, cursor - prefix_start)}}; if (!handle.empty() && !prefix.empty()) { + // YAML 1.2.2 Section 6.8.2: a handle may carry at most one tag + // directive in a document, even when both give the same prefix + if (this->tag_directives_.contains(handle)) [[unlikely]] { + throw YAMLParseError{ + token.line, token.column, + "Duplicate %TAG directive for the same handle"}; + } this->tag_directives_.insert_or_assign(handle, prefix); } } @@ -365,6 +390,10 @@ class Parser { return iterator->second + std::string{raw_tag.substr(second_bang + 1)}; } + // YAML 1.2.2 Section 6.8.2.1: a named tag handle must be associated + // with a prefix by a tag directive, so an undefined handle is an error + throw YAMLParseError{this->lexer_->line(), this->lexer_->column(), + "Undefined tag handle"}; } } @@ -410,8 +439,18 @@ class Parser { : token.column; } - [[nodiscard]] auto json_to_key_string(const JSON &value) const + [[nodiscard]] auto json_to_key_string(const JSON &value, + const std::uint64_t key_line, + const std::uint64_t key_column) const -> std::string { + // RFC 8259 Section 4: an object member name is a string, so a mapping key + // that resolves to a collection cannot be represented as JSON. PyYAML + // raises on the unhashable key and js-yaml rejects the complex key, so a + // collection key is rejected rather than silently stringified + if (value.is_array() || value.is_object()) [[unlikely]] { + throw YAMLParseError{key_line, key_column, + "Mapping key cannot be a collection"}; + } if (value.is_string()) { return value.to_string(); } @@ -423,6 +462,23 @@ class Parser { return stream.str(); } + auto resolve_scalar_key(const Token &token, + const std::optional &tag = std::nullopt) + -> std::string { + // Round-trip mode preserves the original key text, so it is not resolved + if (this->roundtrip_) { + return std::string{token.value}; + } + // Resolve a scalar key to its typed value and stringify it, so that keys + // such as 0x1 and 1 collapse to the same member name, keeping key handling + // consistent with values and alias keys. An explicit tag is honored the + // same way it would be for a scalar value, so a string-tagged key keeps its + // literal text + const auto value{ + this->interpret_scalar(token.value, token.scalar_style, tag)}; + return this->json_to_key_string(value, token.line, token.column); + } + auto parse_value(const Token &token, const JSON::ParseContext context, const std::size_t index, const std::string &property, const std::uint64_t key_line = 0, @@ -620,7 +676,7 @@ class Parser { } result = this->parse_block_mapping_from_first_key( current_token, context, index, property, key_line, key_column, - node_start_column); + node_start_column, tag); } else { if (anchor_count > 1) [[unlikely]] { throw YAMLParseError{current_token.line, current_token.column, @@ -662,14 +718,15 @@ class Parser { throw YAMLUnknownAnchorError{alias_name, current_token.line, current_token.column}; } - const auto key_string{ - this->json_to_key_string(iterator->second.value)}; + const auto key_string{this->json_to_key_string(iterator->second.value, + current_token.line, + current_token.column)}; Token key_token{current_token}; key_token.type = TokenType::Scalar; key_token.value = key_string; result = this->parse_block_mapping_from_first_key( key_token, context, index, property, key_line, key_column, - node_start_column); + node_start_column, std::nullopt, true); } else { if (anchor_name.has_value()) [[unlikely]] { throw YAMLParseError{current_token.line, current_token.column, @@ -746,6 +803,14 @@ class Parser { return result; } + [[nodiscard]] static auto is_special_float(const std::string_view value) + -> bool { + return value == ".inf" || value == ".Inf" || value == ".INF" || + value == "+.inf" || value == "+.Inf" || value == "+.INF" || + value == "-.inf" || value == "-.Inf" || value == "-.INF" || + value == ".nan" || value == ".NaN" || value == ".NAN"; + } + auto interpret_scalar(const std::string_view value, const ScalarStyle style, const std::optional &tag) -> JSON { if (tag.has_value()) { @@ -766,6 +831,16 @@ class Parser { return this->parse_integer(value); } if (tag_value == "tag:yaml.org,2002:float") { + // RFC 8259 Section 6: Infinity and NaN are not permitted in JSON, so an + // explicitly floated infinity or not-a-number has no JSON + // representation. Round-trip mode preserves the original text instead + if (is_special_float(value)) [[unlikely]] { + if (!this->roundtrip_) { + throw YAMLParseError{this->lexer_->line(), this->lexer_->column(), + "Infinity and NaN are not permitted"}; + } + return JSON{value}; + } return this->parse_float(value); } return JSON{value}; @@ -791,10 +866,15 @@ class Parser { return JSON{false}; } - if (value == ".inf" || value == ".Inf" || value == ".INF" || - value == "+.inf" || value == "+.Inf" || value == "+.INF" || - value == "-.inf" || value == "-.Inf" || value == "-.INF" || - value == ".nan" || value == ".NaN" || value == ".NAN") { + if (is_special_float(value)) { + // RFC 8259 Section 6: numeric values that cannot be represented in the + // JSON grammar, such as Infinity and NaN, are not permitted, so a YAML + // infinity or not-a-number float has no JSON representation. Round-trip + // mode preserves the original text instead, so it keeps the string + if (!this->roundtrip_) [[unlikely]] { + throw YAMLParseError{this->lexer_->line(), this->lexer_->column(), + "Infinity and NaN are not permitted"}; + } return JSON{value}; } @@ -819,11 +899,11 @@ class Parser { } } - if (value.size() > start + 1 && value[start] == '0') { - if (value[start + 1] == 'x' || value[start + 1] == 'X') { - return true; - } - if (value[start + 1] == 'o' || value[start + 1] == 'O') { + // YAML 1.2.2 Section 10.3.2: the hexadecimal and octal integer forms carry + // no sign and use a lowercase indicator, so a preceding sign or an + // uppercase indicator makes the value a plain string rather than an integer + if (start == 0 && value.size() > 1 && value[0] == '0') { + if (value[1] == 'x' || value[1] == 'o') { return true; } } @@ -866,12 +946,14 @@ class Parser { auto parse_number(const std::string_view value) -> JSON { const std::size_t prefix{(value[0] == '-' || value[0] == '+') ? 1u : 0u}; - if (value.size() > prefix + 1 && value[prefix] == '0') { - const char indicator{value[prefix + 1]}; - if (indicator == 'x' || indicator == 'X') { + // YAML 1.2.2 Section 10.3.2: the hexadecimal and octal integer forms carry + // no sign and use a lowercase indicator + if (prefix == 0 && value.size() > 1 && value[0] == '0') { + const char indicator{value[1]}; + if (indicator == 'x') { return this->parse_base_integer(value, 16); } - if (indicator == 'o' || indicator == 'O') { + if (indicator == 'o') { return this->parse_base_integer(value, 8); } } @@ -910,11 +992,11 @@ class Parser { auto parse_base_integer(const std::string_view value, const int base) -> JSON { - const bool negative{value[0] == '-'}; - const std::size_t start{(value[0] == '-' || value[0] == '+') ? 3u : 2u}; - const auto result{to_int64_t(std::string{value.substr(start)}, base)}; + // YAML 1.2.2 Section 10.3.2: the base indicator is two characters and no + // sign precedes it, so parsing starts at the third character + const auto result{to_int64_t(std::string{value.substr(2)}, base)}; if (result.has_value()) { - return JSON{negative ? -result.value() : result.value()}; + return JSON{result.value()}; } return JSON{value}; } @@ -941,21 +1023,10 @@ class Parser { return JSON{Decimal{value}}; } - // Only convert to an integer when the value is within the representable - // range, since casting an out-of-range double to an integer is undefined - // behavior - const auto number{result.value()}; - if (number >= - static_cast(std::numeric_limits::min()) && - number < - static_cast(std::numeric_limits::max())) { - const auto as_integer{static_cast(number)}; - if (number == static_cast(as_integer)) { - return JSON{as_integer}; - } - } - - return JSON{number}; + // YAML 1.2.2 Section 10.3.2 tags a dotted or explicitly floated value as a + // float, so an integral-valued float stays a real rather than collapsing to + // an integer, matching the JSON parser where a dotted literal is a real + return JSON{result.value()}; } auto parse_flow_mapping(const Token &start_token, @@ -1006,7 +1077,7 @@ class Parser { key = ""; } } else if (key_token.type == TokenType::Scalar) { - key = std::string{key_token.value}; + key = this->resolve_scalar_key(key_token, key_tag); this->record_key_scalar_style(key, key_token.scalar_style, key_token.quoted_original); } else [[unlikely]] { @@ -1148,14 +1219,16 @@ class Parser { std::string key_string; if (token->type == TokenType::Scalar) { - key_string = std::string{token->value}; + key_string = this->resolve_scalar_key(token.value()); token = this->next_token(); } else { - // For non-scalar keys, parse the value and stringify + const auto explicit_key_line{token->line}; + const auto explicit_key_column{token->column}; auto key_value{this->parse_value(token.value(), JSON::ParseContext::Index, element_index, empty_property_)}; - key_string = this->json_to_key_string(key_value); + key_string = this->json_to_key_string(key_value, explicit_key_line, + explicit_key_column); token = this->next_token(); } @@ -1349,7 +1422,11 @@ class Parser { token = next.value(); } + std::optional key_tag; while (token.type == TokenType::Tag || token.type == TokenType::Anchor) { + if (token.type == TokenType::Tag) { + key_tag = this->resolve_tag(token.value); + } auto next{this->next_token()}; assert(next.has_value()); token = next.value(); @@ -1357,6 +1434,16 @@ class Parser { if (token.type != TokenType::Scalar && token.type != TokenType::BlockMappingValue) { + // RFC 8259 Section 4: an object member name is a string, so an explicit + // mapping key that is itself a collection cannot be represented as + // JSON. PyYAML raises on the unhashable key and js-yaml rejects the + // complex key, so such a key is rejected rather than silently dropped + if (token.type == TokenType::SequenceStart || + token.type == TokenType::MappingStart || + token.type == TokenType::BlockSequenceEntry) [[unlikely]] { + throw YAMLParseError{token.line, token.column, + "Mapping key cannot be a collection"}; + } if (token.type == TokenType::DocumentEnd || token.type == TokenType::DocumentStart) { this->pending_tokens_.push_back(token); @@ -1365,11 +1452,13 @@ class Parser { } std::string key; + bool key_present{false}; std::uint64_t current_key_line{0}; std::uint64_t current_key_column{0}; if (token.type == TokenType::Scalar) { - key = token.value; + key = this->resolve_scalar_key(token, key_tag); + key_present = true; current_key_line = token.line; current_key_column = token.column; @@ -1404,9 +1493,14 @@ class Parser { continue; } + // In round-trip mode the key text is kept raw, so a null-like key such + // as ~ stays non-empty and the historical empty-string sentinel still + // marks the absence of a key. In conversion mode a null-like key + // resolves to an empty string, so a dedicated flag marks its presence + const bool key_absent{this->roundtrip_ ? key.empty() : !key_present}; if (next->type == TokenType::BlockMappingValue || next->type == TokenType::BlockMappingKey) { - if (key.empty() && next->type == TokenType::BlockMappingKey) { + if (key_absent && next->type == TokenType::BlockMappingKey) { token = next.value(); continue; } @@ -1415,8 +1509,8 @@ class Parser { continue; } - if (key.empty() && next->type == TokenType::Scalar) { - key = next->value; + if (key_absent && next->type == TokenType::Scalar) { + key = this->resolve_scalar_key(next.value()); if (seen_keys.contains(key)) [[unlikely]] { throw YAMLDuplicateKeyError{key, next->line, next->column}; } @@ -1529,7 +1623,9 @@ class Parser { const std::size_t index, const std::string &property, const std::uint64_t parent_key_line = 0, const std::uint64_t parent_key_column = 0, - const std::uint64_t node_start_column = 0) -> JSON { + const std::uint64_t node_start_column = 0, + const std::optional &key_tag = std::nullopt, + const bool key_pre_resolved = false) -> JSON { this->invoke_callback( JSON::ParsePhase::Pre, JSON::Type::Object, this->effective_line(key_token, context, parent_key_line), @@ -1543,7 +1639,11 @@ class Parser { this->detect_indent_width(parent_key_column, base_column); - std::string key{key_token.value}; + // An alias key arrives already stringified from its anchor value, so it + // must not be resolved a second time + std::string key{key_pre_resolved + ? std::string{key_token.value} + : this->resolve_scalar_key(key_token, key_tag)}; std::uint64_t key_line{key_token.line}; std::uint64_t key_column{key_token.column}; const auto first_key_line{key_token.line}; @@ -1625,7 +1725,7 @@ class Parser { continue; } - key = next->value; + key = this->resolve_scalar_key(next.value()); key_line = next->line; key_column = next->column; this->record_key_scalar_style(key, next->scalar_style, @@ -1673,6 +1773,7 @@ class Parser { } auto effective_column{next->column}; + std::optional subsequent_key_tag; if (next->type == TokenType::Anchor) { next = this->next_token(); @@ -1682,6 +1783,7 @@ class Parser { } if (next->type == TokenType::Tag) { + subsequent_key_tag = this->resolve_tag(next->value); next = this->next_token(); if (!next.has_value() || next->type != TokenType::Scalar) { continue; @@ -1697,7 +1799,8 @@ class Parser { if (iterator == this->anchors_.end()) [[unlikely]] { throw YAMLUnknownAnchorError{alias_name, next->line, next->column}; } - key = this->json_to_key_string(iterator->second.value); + key = this->json_to_key_string(iterator->second.value, next->line, + next->column); key_line = next->line; key_column = next->column; @@ -1748,7 +1851,7 @@ class Parser { } this->record_inline_comment_for_key(key); - key = next->value; + key = this->resolve_scalar_key(next.value(), subsequent_key_tag); key_line = next->line; key_column = next->column; this->record_key_scalar_style(key, next->scalar_style, @@ -2054,6 +2157,10 @@ class Parser { std::optional pending_token_position_; std::unordered_map tag_directives_; std::uint64_t document_start_line_{0}; + // Whether the document parsed by the most recent call ended with an explicit + // document end marker, so a subsequent stream validation knows a document + // boundary was already crossed + bool document_ended_{false}; }; } // namespace sourcemeta::core::yaml diff --git a/vendor/core/src/core/yaml/yaml.cc b/vendor/core/src/core/yaml/yaml.cc index 4eabe5e01..464b10f06 100644 --- a/vendor/core/src/core/yaml/yaml.cc +++ b/vendor/core/src/core/yaml/yaml.cc @@ -17,7 +17,11 @@ auto parse_yaml(std::basic_istream &stream) yaml::Parser parser{&lexer, nullptr}; auto result{parser.parse()}; - const auto consumed{static_cast(parser.position())}; + // The parser position is relative to the input after any byte order mark has + // been stripped, so the mark is added back to resume the stream at the right + // byte + const auto consumed{static_cast(lexer.bom_length()) + + static_cast(parser.position())}; stream.clear(); stream.seekg(start_pos + consumed); @@ -56,7 +60,11 @@ auto parse_yaml(std::basic_istream &stream, yaml::Parser parser{&lexer, &callback}; output = parser.parse(); - const auto consumed{static_cast(parser.position())}; + // The parser position is relative to the input after any byte order mark has + // been stripped, so the mark is added back to resume the stream at the right + // byte + const auto consumed{static_cast(lexer.bom_length()) + + static_cast(parser.position())}; stream.clear(); stream.seekg(start_pos + consumed); } diff --git a/vendor/core/src/lang/numeric/decimal.cc b/vendor/core/src/lang/numeric/decimal.cc index 39c5fbd69..d0ebb5d94 100644 --- a/vendor/core/src/lang/numeric/decimal.cc +++ b/vendor/core/src/lang/numeric/decimal.cc @@ -7,7 +7,7 @@ #include // assert #include // std::to_chars #include // std::isfinite, std::isnan, std::isinf, std::abs, - // std::frexp, std::ldexp + // std::frexp, std::ldexp, std::signbit #include // std::size_t #include // std::strlen #include // std::setprecision @@ -674,10 +674,13 @@ auto Decimal::exact_from(const double value) -> Decimal { return value < 0 ? Decimal::negative_infinity() : Decimal::infinity(); } - // The library builds without IEEE signed zeros, so a negative zero is - // indistinguishable from a positive zero and always yields an unsigned zero + // The decimal representation carries a dedicated sign, so a negative zero + // converts to a signed zero and the conversion is lossless if (value == 0.0) { Decimal output{static_cast(0)}; + if (std::signbit(value)) { + output.flags_ = static_cast(output.flags_ | FLAG_SIGN); + } output.flags_ = static_cast(output.flags_ & ~FLAG_INTEGER_LITERAL); return output; diff --git a/vendor/core/src/lang/text/include/sourcemeta/core/text.h b/vendor/core/src/lang/text/include/sourcemeta/core/text.h index ce6d28fab..26483c01e 100644 --- a/vendor/core/src/lang/text/include/sourcemeta/core/text.h +++ b/vendor/core/src/lang/text/include/sourcemeta/core/text.h @@ -766,8 +766,8 @@ auto squeeze(const std::string_view input, const char character) -> std::string; /// @ingroup text /// /// Collapse consecutive runs of a character into a single occurrence, appending -/// the result to an existing string rather than allocating a new one. The -/// output must not alias the input. For example: +/// the result to a string like output sink rather than allocating a new +/// string. The output must not alias the input. For example: /// /// ```cpp /// #include @@ -778,9 +778,23 @@ auto squeeze(const std::string_view input, const char character) -> std::string; /// sourcemeta::core::squeeze("a//b", '/', output); /// assert(output == "path=a/b"); /// ``` -SOURCEMETA_CORE_TEXT_EXPORT -auto squeeze(const std::string_view input, const char character, - std::string &output) -> void; +template +auto squeeze(const std::string_view input, const char character, Output &output) + -> void { + bool in_run{false}; + for (const auto value : input) { + if (value == character) { + if (!in_run) { + output.push_back(value); + } + + in_run = true; + } else { + output.push_back(value); + in_run = false; + } + } +} /// @ingroup text /// diff --git a/vendor/core/src/lang/text/text.cc b/vendor/core/src/lang/text/text.cc index fc631d624..3afd8f0e2 100644 --- a/vendor/core/src/lang/text/text.cc +++ b/vendor/core/src/lang/text/text.cc @@ -174,23 +174,6 @@ auto split_once(const std::string_view input, return std::pair{before, after}; } -auto squeeze(const std::string_view input, const char character, - std::string &output) -> void { - bool in_run{false}; - for (const auto value : input) { - if (value == character) { - if (!in_run) { - output.push_back(value); - } - - in_run = true; - } else { - output.push_back(value); - in_run = false; - } - } -} - auto squeeze(const std::string_view input, const char character) -> std::string { std::string result;