diff --git a/DEPENDENCIES b/DEPENDENCIES index 350c15c9..ca31f307 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core f8a406692eb8080c84cd016ec4968e391461b1c1 -blaze https://github.com/sourcemeta/blaze 22d0e8a03d1de54c44599426d8537386fcf2a617 +core https://github.com/sourcemeta/core 6bbc7a8850da0ad72cd2b2badfc33033a4e48be1 +blaze https://github.com/sourcemeta/blaze 49ff4cd2893d52970154a7eea5a729671a9f630a bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/cmake/FindCore.cmake b/cmake/FindCore.cmake index 7885d48a..edd0efe9 100644 --- a/cmake/FindCore.cmake +++ b/cmake/FindCore.cmake @@ -13,6 +13,7 @@ if(NOT Core_FOUND) set(SOURCEMETA_CORE_JSONRPC OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_MCP OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_HTTP OFF CACHE BOOL "disable") + set(SOURCEMETA_CORE_OAUTH OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_SEMVER OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_MARKDOWN OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_CONTRIB_GOOGLEBENCHMARK OFF CACHE BOOL "GoogleBenchmark") diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index ca8a0348..a89169bc 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 f8a406692eb8080c84cd016ec4968e391461b1c1 +core https://github.com/sourcemeta/core 7581b8c977c794de48e7c2ae5fcf56a7410f8fe4 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/ports/javascript/describe.mjs b/vendor/blaze/ports/javascript/describe.mjs index 18d4d63d..4c06fdaf 100644 --- a/vendor/blaze/ports/javascript/describe.mjs +++ b/vendor/blaze/ports/javascript/describe.mjs @@ -135,6 +135,12 @@ function resolveTarget(instance, instanceLocation) { return current; } +function instanceParent(instance, instanceLocation) { + if (instanceLocation === '') return undefined; + const lastSlash = instanceLocation.lastIndexOf('/'); + return resolveTarget(instance, instanceLocation.slice(0, lastSlash)); +} + function extractKeyword(evaluatePath) { if (evaluatePath === '') return ''; const lastSlash = evaluatePath.lastIndexOf('/'); @@ -430,13 +436,20 @@ export function describe(valid, instruction, evaluatePath, if (keyword === 'contains') { return 'The constraints declared for this keyword were not satisfiable'; } - if (keyword === 'additionalProperties' || - keyword === 'unevaluatedProperties') { + // A `false` subschema declares no keyword of its own, so a property named + // after one of these must not be described as if it were that keyword. + // The instance location agrees only when it sits under an object for the + // property cases, or under an array for the item case + const parent = instanceParent(instance, instanceLocation); + if ((keyword === 'additionalProperties' || + keyword === 'unevaluatedProperties') && + parent !== undefined && parent !== null && typeof parent === 'object' && + !Array.isArray(parent)) { const property = lastInstanceToken(instanceLocation); return 'The object value was not expected to define the property ' + escapeString(property); } - if (keyword === 'unevaluatedItems') { + if (keyword === 'unevaluatedItems' && Array.isArray(parent)) { const tokenValue = lastInstanceToken(instanceLocation); return 'The array value was not expected to define the item at index ' + tokenValue; @@ -560,7 +573,11 @@ export function describe(valid, instruction, evaluatePath, 'positional subschemas'; } - if (keyword === 'title' || keyword === 'description') { + // Annotation values come from the schema as written, so they need not have + // the type their keyword expects. When they do not, the generic fallthrough + // below describes them instead + if ((keyword === 'title' || keyword === 'description') && + typeof annotation === 'string') { let message = 'The ' + keyword + ' of the'; if (instanceLocation === '') { message += ' instance'; @@ -621,7 +638,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'examples') { + if (keyword === 'examples' && Array.isArray(annotation)) { let message = ''; if (instanceLocation === '') { message += 'Examples of the instance'; @@ -640,7 +657,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'contentEncoding') { + if (keyword === 'contentEncoding' && typeof annotation === 'string') { let message = 'The content encoding of the'; if (instanceLocation === '') { message += ' instance'; @@ -651,7 +668,7 @@ export function describe(valid, instruction, evaluatePath, return message; } - if (keyword === 'contentMediaType') { + if (keyword === 'contentMediaType' && typeof annotation === 'string') { let message = 'The content media type of the'; if (instanceLocation === '') { message += ' instance'; diff --git a/vendor/blaze/ports/javascript/index.mjs b/vendor/blaze/ports/javascript/index.mjs index 46684e8c..a3aeb11f 100644 --- a/vendor/blaze/ports/javascript/index.mjs +++ b/vendor/blaze/ports/javascript/index.mjs @@ -647,6 +647,14 @@ class Blaze { if (match) entry.skip = true; } } + + checkpoint() { + return this.evaluated.length; + } + + rewind(checkpoint) { + this.evaluated.length = checkpoint; + } } function evaluateInstructionFast(instruction, instance, depth, template, evaluator) { @@ -1646,16 +1654,21 @@ function LogicalOr(instruction, instance, depth, template, evaluator) { let result = false; if (exhaustive) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { result = true; + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } else { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { result = true; break; } + if (evaluator.trackMode) evaluator.rewind(checkpoint); } } if (evaluator.callbackMode) evaluator.callbackPop(instruction, result); @@ -1687,6 +1700,7 @@ function LogicalXor(instruction, instance, depth, template, evaluator) { let hasMatched = false; if (children) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { if (hasMatched) { result = false; @@ -1694,6 +1708,8 @@ function LogicalXor(instruction, instance, depth, template, evaluator) { } else { hasMatched = true; } + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } @@ -1710,24 +1726,29 @@ function LogicalCondition(instruction, instance, depth, template, evaluator) { const children = instruction[6]; const childrenSize = children ? children.length : 0; - let conditionEnd = childrenSize; - if (thenStart > 0) conditionEnd = thenStart; - else if (elseStart > 0) conditionEnd = elseStart; - const target = resolveInstance(instance, instruction[2]); + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; let conditionResult = true; - for (let cursor = 0; cursor < conditionEnd; cursor++) { + for (let cursor = 0; cursor < thenStart; cursor++) { if (!evaluateInstruction(children[cursor], target, depth + 1, template, evaluator)) { conditionResult = false; break; } } + if (!conditionResult && evaluator.trackMode) evaluator.rewind(checkpoint); - const consequenceStart = conditionResult ? thenStart : elseStart; - const consequenceEnd = (conditionResult && elseStart > 0) ? elseStart : childrenSize; + let consequenceStart; + let consequenceEnd; + if (conditionResult) { + consequenceStart = thenStart; + consequenceEnd = elseStart > 0 ? elseStart : childrenSize; + } else { + consequenceStart = elseStart; + consequenceEnd = elseStart > 0 ? childrenSize : elseStart; + } - if (consequenceStart > 0) { + if (consequenceStart < consequenceEnd) { if (evaluator.trackMode || evaluator.callbackMode) { evaluator.popPath(instruction[1].length); } @@ -2147,15 +2168,16 @@ function LoopPropertiesExactlyTypeStrict(instruction, instance, depth, template, return false; } const value = instruction[5]; + const names = new Set(value[1]); let count = 0; for (const key in target) { count++; - if (effectiveTypeStrictReal(target[key]) !== value[0]) { + if (effectiveTypeStrictReal(target[key]) !== value[0] || !names.has(key)) { if (evaluator.callbackMode) evaluator.callbackPop(instruction, false); return false; } } - const __result = count === value[1].length; + const __result = count === names.size; if (evaluator.callbackMode) evaluator.callbackPop(instruction, __result); return __result; }; @@ -2465,9 +2487,11 @@ function LoopItemsIntegerBoundedSized(instruction, instance, depth, template, ev const minimum = value[0][0]; const maximum = value[0][1]; const minimumSize = value[1][0]; + const maximumSize = value[1][1]; const target = resolveInstance(instance, instruction[2]); if (evaluator.callbackMode) evaluator.callbackPush(instruction); - if (!Array.isArray(target) || target.length < minimumSize) { + if (!Array.isArray(target) || target.length < minimumSize || + (maximumSize !== null && maximumSize !== undefined && target.length > maximumSize)) { if (evaluator.callbackMode) evaluator.callbackPop(instruction, false); return false; } @@ -2494,11 +2518,10 @@ function LoopContains(instruction, instance, depth, template, evaluator) { const minimum = range[0]; const maximum = range[1]; const isExhaustive = range[2]; - if (minimum === 0 && target.length === 0) return true; if (evaluator.callbackMode) evaluator.callbackPush(instruction); const children = instruction[6]; - let result = false; + let result = minimum === 0; let matchCount = 0; for (let index = 0; index < target.length; index++) { if (evaluator.callbackMode) evaluator.pushInstanceToken(index); @@ -2813,11 +2836,15 @@ function LogicalOr_fast(instruction, instance, depth, template, evaluator) { let result = false; if (exhaustive) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) result = true; + else if (evaluator.trackMode) evaluator.rewind(checkpoint); } } else { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) return true; + if (evaluator.trackMode) evaluator.rewind(checkpoint); } } return result; @@ -2862,6 +2889,7 @@ function LogicalXor_fast(instruction, instance, depth, template, evaluator) { let hasMatched = false; if (children) { for (let index = 0; index < children.length; index++) { + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; if (evaluateInstruction(children[index], target, depth + 1, template, evaluator)) { if (hasMatched) { result = false; @@ -2869,6 +2897,8 @@ function LogicalXor_fast(instruction, instance, depth, template, evaluator) { } else { hasMatched = true; } + } else if (evaluator.trackMode) { + evaluator.rewind(checkpoint); } } } @@ -2965,21 +2995,27 @@ function LogicalCondition_fast(instruction, instance, depth, template, evaluator const elseStart = value[1]; const children = instruction[6]; const childrenSize = children ? children.length : 0; - let conditionEnd = childrenSize; - if (thenStart > 0) conditionEnd = thenStart; - else if (elseStart > 0) conditionEnd = elseStart; const relInstance = instruction[2]; const target = relInstance.length === 0 ? instance : resolveInstance(instance, relInstance); + const checkpoint = evaluator.trackMode ? evaluator.checkpoint() : 0; let conditionResult = true; - for (let cursor = 0; cursor < conditionEnd; cursor++) { + for (let cursor = 0; cursor < thenStart; cursor++) { if (!evaluateInstruction(children[cursor], target, depth + 1, template, evaluator)) { conditionResult = false; break; } } - const consequenceStart = conditionResult ? thenStart : elseStart; - const consequenceEnd = (conditionResult && elseStart > 0) ? elseStart : childrenSize; - if (consequenceStart > 0) { + if (!conditionResult && evaluator.trackMode) evaluator.rewind(checkpoint); + let consequenceStart; + let consequenceEnd; + if (conditionResult) { + consequenceStart = thenStart; + consequenceEnd = elseStart > 0 ? elseStart : childrenSize; + } else { + consequenceStart = elseStart; + consequenceEnd = elseStart > 0 ? childrenSize : elseStart; + } + if (consequenceStart < consequenceEnd) { if (evaluator.trackMode) { evaluator.popPath(instruction[1].length); } @@ -3700,12 +3736,13 @@ function LoopPropertiesExactlyTypeStrict_fast(instruction, instance, depth, temp const target = resolveInstance(instance, instruction[2]); if (!isObject(target)) return false; const value = instruction[5]; + const names = new Set(value[1]); let count = 0; for (const key in target) { count++; - if (effectiveTypeStrictReal(target[key]) !== value[0]) return false; + if (effectiveTypeStrictReal(target[key]) !== value[0] || !names.has(key)) return false; } - return count === value[1].length; + return count === names.size; } function LoopPropertiesExactlyTypeStrictHash_fast(instruction, instance, depth, template, evaluator) { @@ -3881,8 +3918,10 @@ function LoopItemsIntegerBoundedSized_fast(instruction, instance, depth, templat const minimum = value[0][0]; const maximum = value[0][1]; const minimumSize = value[1][0]; + const maximumSize = value[1][1]; const target = resolveInstance(instance, instruction[2]); - if (!Array.isArray(target) || target.length < minimumSize) return false; + if (!Array.isArray(target) || target.length < minimumSize || + (maximumSize !== null && maximumSize !== undefined && target.length > maximumSize)) return false; for (let index = 0; index < target.length; index++) { const element = target[index]; const elementType = typeof element; diff --git a/vendor/blaze/ports/javascript/package-lock.json b/vendor/blaze/ports/javascript/package-lock.json index 4b5a3aa2..95ea9a6c 100644 --- a/vendor/blaze/ports/javascript/package-lock.json +++ b/vendor/blaze/ports/javascript/package-lock.json @@ -6,7 +6,14 @@ "packages": { "": { "name": "@sourcemeta/blaze", - "version": "0.0.0" + "version": "0.0.0", + "license": "LGPL-3.0-or-later", + "engines": { + "node": ">=21.7" + }, + "funding": { + "url": "https://github.com/sponsors/sourcemeta" + } } } } diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json new file mode 100644 index 00000000..33f13901 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true, + "https://json-schema.org/draft/2019-09/vocab/applicator": true, + "https://json-schema.org/draft/2019-09/vocab/validation": true, + "https://json-schema.org/draft/2019-09/vocab/meta-data": true, + "https://json-schema.org/draft/2019-09/vocab/format": false, + "https://json-schema.org/draft/2019-09/vocab/content": true, + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$recursiveAnchor": true, + "title": "Sourcemeta dialect", + "description": "JSON Schema 2019-09 dialect that extends the official dialect with the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + }, + { + "type": "object", + "x-jsonld-type": "https://schema.org/Product", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + ], + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2019-09/schema" + }, + { + "$ref": "vocabulary.json" + } + ] +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json new file mode 100644 index 00000000..60e0aad5 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$recursiveAnchor": true, + "title": "Sourcemeta Extension vocabulary", + "description": "Meta-schema that describes the keywords of the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "x-jsonld-id": "https://schema.org/name" + }, + { + "x-jsonld-type": "https://schema.org/Product", + "x-jsonld-graph": true + }, + { + "format": "uri", + "x-format-assertion": true + } + ], + "type": [ "object", "boolean" ], + "properties": { + "x-format-assertion": { + "type": "boolean" + }, + "x-jsonld-container": { + "enum": [ "@list", "@set", "@language", "@index" ] + }, + "x-jsonld-datatype": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-direction": { + "enum": [ "ltr", "rtl" ] + }, + "x-jsonld-graph": { + "type": "boolean" + }, + "x-jsonld-id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-json": { + "type": "boolean" + }, + "x-jsonld-language": { + "type": "string", + "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + }, + "x-jsonld-reverse": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-self": { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + }, + "x-jsonld-type": { + "anyOf": [ + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + { + "type": "array", + "items": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + } + ] + } + } +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json new file mode 100644 index 00000000..9701ca5a --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$dynamicAnchor": "meta", + "title": "Sourcemeta dialect", + "description": "JSON Schema 2020-12 dialect that extends the official dialect with the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + }, + { + "type": "object", + "x-jsonld-type": "https://schema.org/Product", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + ], + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "vocabulary.json" + } + ] +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json new file mode 100644 index 00000000..607faeb4 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$dynamicAnchor": "meta", + "title": "Sourcemeta Extension vocabulary", + "description": "Meta-schema that describes the keywords of the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "x-jsonld-id": "https://schema.org/name" + }, + { + "x-jsonld-type": "https://schema.org/Product", + "x-jsonld-graph": true + }, + { + "format": "uri", + "x-format-assertion": true + } + ], + "type": [ "object", "boolean" ], + "properties": { + "x-format-assertion": { + "type": "boolean" + }, + "x-jsonld-container": { + "enum": [ "@list", "@set", "@language", "@index" ] + }, + "x-jsonld-datatype": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-direction": { + "enum": [ "ltr", "rtl" ] + }, + "x-jsonld-graph": { + "type": "boolean" + }, + "x-jsonld-id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-json": { + "type": "boolean" + }, + "x-jsonld-language": { + "type": "string", + "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + }, + "x-jsonld-reverse": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-self": { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + }, + "x-jsonld-type": { + "anyOf": [ + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + { + "type": "array", + "items": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + } + ] + } + } +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown new file mode 100644 index 00000000..f35654e0 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -0,0 +1,200 @@ +Sourcemeta Extension Vocabulary +=============================== + +1. Introduction +--------------- + +The Sourcemeta Extension vocabulary, identified by the URI +`tag:sourcemeta.com,2026:extension/v1`, defines JSON Schema keywords that +annotate schemas with evaluation behavior and with the information required to +promote valid instances to JSON-LD [JSONLD11], the JSON-based serialization of +RDF [RDF11]. Every keyword in this vocabulary is an annotation. No keyword +directly asserts constraints on the instance, though `x-format-assertion` +changes how a sibling keyword is evaluated. + +2. Versioning +------------- + +The vocabulary URI identifies major version 1 of this vocabulary. Backwards +compatible changes, such as the introduction of new keywords, MAY be made to +this vocabulary without changing its URI. A breaking change MUST result in a +new vocabulary published under a new major version with a new URI. + +3. Keywords +----------- + +| Keyword | Value | Applies To | +|---------|-------|------------| +| [`x-format-assertion`](#31-x-format-assertion) | A boolean | Any subschema | +| [`x-jsonld-id`](#321-x-jsonld-id) | An absolute IRI | Property subschemas | +| [`x-jsonld-type`](#322-x-jsonld-type) | An absolute IRI or array of absolute IRIs | Object or reference subschemas | +| [`x-jsonld-reverse`](#323-x-jsonld-reverse) | An absolute IRI | Property subschemas | +| [`x-jsonld-datatype`](#324-x-jsonld-datatype) | An absolute IRI | Scalar subschemas | +| [`x-jsonld-language`](#325-x-jsonld-language) | A language tag | String subschemas | +| [`x-jsonld-direction`](#326-x-jsonld-direction) | `ltr` or `rtl` | String subschemas | +| [`x-jsonld-json`](#327-x-jsonld-json) | A boolean | Any subschema | +| [`x-jsonld-graph`](#328-x-jsonld-graph) | A boolean | Object subschemas | +| [`x-jsonld-container`](#329-x-jsonld-container) | `@list`, `@set`, `@language`, or `@index` | Array or object property subschemas | +| [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template | Scalar or object subschemas | + +### 3.1. `x-format-assertion` + +The value of this keyword MUST be a boolean. + +When the value is `true`, a `format` keyword in the same schema object MUST be +evaluated as an assertion rather than as an annotation. When the value is +`false` or the keyword is absent, the evaluation of `format` is unchanged. +This keyword has no effect on any other keyword. + +### 3.2. JSON-LD + +The keywords in this section map the instance locations that their subschemas +successfully evaluate to nodes, literals, and edges of a JSON-LD document. A +keyword takes effect at every instance location its subschema evaluates, +including across references, so annotations declared in different schemas may +meet at the same instance location. When they do, their values are merged, and +identical values always collapse into one, so a schema and the schemas it +references may safely declare the same annotation. A keyword that takes a +single value MUST NOT be assigned two different values for the same location, +unless its own section defines how distinct values merge. + +#### 3.2.1. `x-jsonld-id` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares the predicate IRI that connects the annotated instance +location, as the object, to its enclosing node. It MUST NOT be applied to the +document root, to an array element, or to a member of a location annotated +with `x-jsonld-container`, as such locations have no enclosing node to attach +a predicate to. Distinct values that meet at the same location merge, +asserting the location under each declared predicate. + +#### 3.2.2. `x-jsonld-type` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987], or an array of such strings. + +This keyword declares the `@type` of the node that the annotated instance +location materializes as. A single IRI is equivalent to an array containing +only that IRI, values that meet at the same location merge into the union of +the declared types, and an empty array declares no types. It MUST be applied +to a location whose value is an object, unless the location is promoted to an +identified node with `x-jsonld-self`. + +#### 3.2.3. `x-jsonld-reverse` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares a reverse predicate IRI, asserting the edge with the +annotated instance location as the subject and the enclosing node as the +object, emitted through the JSON-LD `@reverse` keyword. The annotated location +MUST materialize as a node or as an array of nodes, as a literal cannot be the +subject of an edge. The placement and merging behavior of `x-jsonld-id` also +apply to this keyword. + +#### 3.2.4. `x-jsonld-datatype` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares the datatype IRI of the typed literal that the annotated +instance location materializes as, such as +`http://www.w3.org/2001/XMLSchema#date`. It MUST be applied to a location +whose value is a scalar, and it MUST NOT be combined with `x-jsonld-language` +or `x-jsonld-direction` at the same location. + +#### 3.2.5. `x-jsonld-language` + +The value of this keyword MUST be a string representing a well-formed language +tag [RFC5646]. + +This keyword declares the language of the language-tagged literal that the +annotated instance location materializes as. It MUST be applied to a location +whose value is a string. Tags compare case-insensitively, so spellings that +differ only in case are the same value, and an implementation MAY keep any of +the declared spellings. + +#### 3.2.6. `x-jsonld-direction` + +The value of this keyword MUST be the string `ltr` or the string `rtl`. + +This keyword declares the base direction of the internationalised literal that +the annotated instance location materializes as. It MUST be applied to a +location whose value is a string. + +#### 3.2.7. `x-jsonld-json` + +The value of this keyword MUST be a boolean. + +When the value is `true`, the annotated instance location is preserved +verbatim as an opaque JSON literal through the JSON-LD `@json` datatype, and +the keyword MUST NOT be combined with any keyword of this vocabulary other +than `x-jsonld-id` at the same location. When the value is `false`, the +keyword has no effect. + +#### 3.2.8. `x-jsonld-graph` + +The value of this keyword MUST be a boolean. + +When the value is `true`, the edges of the node that the annotated instance +location materializes as are asserted inside a named `@graph` that the node +identifier denotes. The location value MUST be an object. When the value is +`false`, the keyword has no effect. + +#### 3.2.9. `x-jsonld-container` + +The value of this keyword MUST be the string `@list`, `@set`, `@language`, or +`@index`. + +This keyword declares the container semantics of the annotated instance +location. The `@list` and `@set` containers range over an array value, +asserting an ordered RDF list and an unordered set respectively. The +`@language` container ranges over an object value, asserting language-tagged +literals. Its keys MUST be well-formed language tags [RFC5646], except for the +reserved key `@none` which asserts a literal with no language, and its members +MUST be strings, arrays of strings, or `null`, where `null` entries are +ignored. The `@index` container ranges over an object value whose keys carry +no RDF meaning. This keyword MUST NOT be combined with any keyword of this +vocabulary other than `x-jsonld-id` at the same location, and the members of a +`@language` container MUST NOT carry annotations of this vocabulary. + +#### 3.2.10. `x-jsonld-self` + +The value of this keyword MUST be a string representing a URI Template +[RFC6570]. + +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, a number, or a boolean, 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 +`x-jsonld-datatype`, `x-jsonld-language`, or `x-jsonld-direction` at the same +location. + +4. References +------------- + +### 4.1. Normative + +- [RFC3987] Duerst, M. and M. Suignard, ["Internationalized Resource + Identifiers (IRIs)"](https://www.rfc-editor.org/info/rfc3987), RFC 3987, + January 2005 +- [RFC5646] Phillips, A. and M. Davis, ["Tags for Identifying + Languages"](https://www.rfc-editor.org/info/rfc5646), BCP 47, RFC 5646, + September 2009 +- [RFC6570] Gregorio, J., Fielding, R., Hadley, M., Nottingham, M., and D. + Orchard, ["URI Template"](https://www.rfc-editor.org/info/rfc6570), RFC + 6570, March 2012 +- [JSONLD11] Sporny, M., Longley, D., Kellogg, G., Lanthaler, M., Champin, + P., and N. Lindström, ["JSON-LD 1.1"](https://www.w3.org/TR/json-ld11/), + W3C Recommendation, July 2020 + +### 4.2. Informative + +- [RDF11] Cyganiak, R., Wood, D., and M. Lanthaler, ["RDF 1.1 Concepts and + Abstract Syntax"](https://www.w3.org/TR/rdf11-concepts/), W3C + Recommendation, February 2014 diff --git a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h index 993b0c2c..8a1776bb 100644 --- a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h +++ b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h @@ -88,16 +88,26 @@ auto add(SchemaTransformer &bundle, const AlterSchemaMode mode) -> void; /// @ingroup alterschema /// -/// A linter rule driven by a JSON Schema. Every subschema in the document -/// under inspection is validated as a JSON instance against the provided -/// rule schema. When a subschema does not conform, the rule fires and -/// reports the validation errors. The rule name is extracted from the -/// `title` keyword of the rule schema, and the rule description from the -/// `description` keyword. The title must consist only of lowercase ASCII -/// letters, digits, underscores, or slashes. +/// A linter rule driven by a JSON Schema. By default, every subschema in +/// the document under inspection is validated as a JSON instance against +/// the provided rule schema, though a rule may be scoped to only run +/// against the document root. When a subschema does not conform, the rule +/// fires and reports the validation errors. The rule name is extracted +/// from the `title` keyword of the rule schema, and the rule description +/// from the `description` keyword. The title must consist only of +/// lowercase ASCII letters, digits, underscores, or slashes. class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final : public SchemaTransformRule { public: + /// The locations of the schema that the rule applies to + enum class Scope : std::uint8_t { + /// Every subschema of the document under inspection + All, + + /// Only the document root + TopLevel + }; + using mutates = std::false_type; using reframe_after_transform = std::false_type; SchemaRule(const sourcemeta::core::JSON &schema, @@ -105,7 +115,8 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect = "", - const std::optional &tweaks = std::nullopt); + const std::optional &tweaks = std::nullopt, + const Scope scope = Scope::All); [[nodiscard]] auto condition(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &, const sourcemeta::blaze::Vocabularies &, @@ -117,6 +128,7 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final private: Template template_; + Scope scope_; }; #if defined(_MSC_VER) diff --git a/vendor/blaze/src/alterschema/schema_rule.cc b/vendor/blaze/src/alterschema/schema_rule.cc index 74072ac6..1610e82b 100644 --- a/vendor/blaze/src/alterschema/schema_rule.cc +++ b/vendor/blaze/src/alterschema/schema_rule.cc @@ -68,19 +68,24 @@ SchemaRule::SchemaRule(const sourcemeta::core::JSON &schema, const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect, - const std::optional &tweaks) + const std::optional &tweaks, const Scope scope) : SchemaTransformRule{extract_title(schema), extract_description(schema)}, template_{compile(schema, walker, resolver, compiler, Mode::Exhaustive, - default_dialect, "", "", tweaks)} {}; - -auto SchemaRule::condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &, - const sourcemeta::blaze::Vocabularies &, - const sourcemeta::blaze::SchemaFrame &, - const sourcemeta::blaze::SchemaFrame::Location &, - const sourcemeta::blaze::SchemaWalker &, - const sourcemeta::blaze::SchemaResolver &, - const bool) const -> SchemaTransformRule::Result { + default_dialect, "", "", tweaks)}, + scope_{scope} {}; + +auto SchemaRule::condition( + const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, + const sourcemeta::blaze::Vocabularies &, + const sourcemeta::blaze::SchemaFrame &, + const sourcemeta::blaze::SchemaFrame::Location &location, + const sourcemeta::blaze::SchemaWalker &, + const sourcemeta::blaze::SchemaResolver &, const bool) const + -> SchemaTransformRule::Result { + if (this->scope_ == Scope::TopLevel && !location.pointer.empty()) { + return false; + } + SimpleOutput output{schema}; Evaluator evaluator; const auto result{ diff --git a/vendor/blaze/src/bundle/bundle.cc b/vendor/blaze/src/bundle/bundle.cc index a27ca5fa..1658eee2 100644 --- a/vendor/blaze/src/bundle/bundle.cc +++ b/vendor/blaze/src/bundle/bundle.cc @@ -448,8 +448,9 @@ auto bundle(sourcemeta::core::JSON &schema, const SchemaWalker &walker, // If the schema identifier is implicit, add it to the top-level of the // bundled schema. Otherwise, potential relative references based on this // implicit base URI will likely not resolve unless end users happen to - // know that this implicit base URI is. - if (!default_id.empty() && + // know that this implicit base URI is. Note that boolean schemas cannot + // declare identifiers, so we leave those untouched + if (!default_id.empty() && schema.is_object() && identify(schema, resolver, default_dialect).empty()) { reidentify(schema, default_id, resolver, default_dialect); } diff --git a/vendor/blaze/src/canonicalizer/canonicalize.cc b/vendor/blaze/src/canonicalizer/canonicalize.cc index 95b425c4..e134c0db 100644 --- a/vendor/blaze/src/canonicalizer/canonicalize.cc +++ b/vendor/blaze/src/canonicalizer/canonicalize.cc @@ -366,6 +366,7 @@ auto apply(const std::vector &rules, sourcemeta::core::JSON &schema, #include "rules/unevaluated_properties_to_additional_properties.h" #include "rules/unknown_keywords_prefix.h" #include "rules/unknown_local_ref.h" +#include "rules/unknown_type_names.h" #include "rules/unnecessary_allof_ref_wrapper_draft.h" #include "rules/unnecessary_extends_ref_wrapper.h" #include "rules/unsatisfiable_drop_validation.h" @@ -405,6 +406,7 @@ auto canonicalize(sourcemeta::core::JSON &schema, rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); diff --git a/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h b/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h new file mode 100644 index 00000000..1c6b05e6 --- /dev/null +++ b/vendor/blaze/src/canonicalizer/rules/unknown_type_names.h @@ -0,0 +1,66 @@ +class UnknownTypeNames final : public SchemaTransformRule { +public: + using reframe_after_transform = std::false_type; + UnknownTypeNames() : SchemaTransformRule{"unknown_type_names"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::blaze::Vocabularies &vocabularies, + const sourcemeta::blaze::SchemaFrame &, + const sourcemeta::blaze::SchemaFrame::Location &, + const sourcemeta::blaze::SchemaWalker &, + const sourcemeta::blaze::SchemaResolver &) const -> bool override { + ONLY_CONTINUE_IF(vocabularies.contains_any( + {Vocabularies::Known::JSON_Schema_2020_12_Validation, + Vocabularies::Known::JSON_Schema_2019_09_Validation, + Vocabularies::Known::JSON_Schema_Draft_7, + Vocabularies::Known::JSON_Schema_Draft_6, + Vocabularies::Known::JSON_Schema_Draft_4}) && + schema.is_object()); + + const auto *type{schema.try_at("type")}; + ONLY_CONTINUE_IF(type); + + // An unrecognised type name constrains nothing, so it is safe (and more + // canonical) to drop it: a scalar unknown name leaves no constraint at all, + // and an unknown name inside a union just does not contribute an + // alternative. `parse_schema_type` yields an empty set for a name it does + // not recognise + if (type->is_string()) { + return !parse_schema_type(*type).any(); + } + + if (type->is_array()) { + for (const auto &entry : type->as_array()) { + if (entry.is_string() && !parse_schema_type(entry).any()) { + return true; + } + } + } + + return false; + } + + auto transform(sourcemeta::core::JSON &schema) const -> void override { + if (schema.at("type").is_string()) { + schema.erase("type"); + return; + } + + auto recognised{sourcemeta::core::JSON::make_array()}; + for (const auto &entry : schema.at("type").as_array()) { + if (entry.is_string() && !parse_schema_type(entry).any()) { + continue; + } + + recognised.push_back(entry); + } + + if (recognised.empty()) { + schema.erase("type"); + } else { + schema.at("type").into(std::move(recognised)); + } + } +}; diff --git a/vendor/blaze/src/compiler/compile.cc b/vendor/blaze/src/compiler/compile.cc index 65fcbdcf..e3b12af0 100644 --- a/vendor/blaze/src/compiler/compile.cc +++ b/vendor/blaze/src/compiler/compile.cc @@ -11,6 +11,7 @@ #include // std::set #include // std::string_view #include // std::unordered_map +#include // std::unordered_set #include // std::move, std::pair #include // std::vector @@ -73,6 +74,53 @@ auto compile_subschema(const sourcemeta::blaze::Context &context, return steps; } +auto defines_any_whitelisted_keyword( + const sourcemeta::core::JSON &schema, + const sourcemeta::blaze::SchemaFrame &frame, + const sourcemeta::blaze::SchemaWalker &walker, + const sourcemeta::blaze::SchemaResolver &resolver, + const sourcemeta::blaze::SchemaFrame::Location &entrypoint_location, + const std::unordered_set &keywords) + -> bool { + std::vector> + hashed_keywords; + hashed_keywords.reserve(keywords.size()); + for (const auto &keyword : keywords) { + hashed_keywords.emplace_back(keyword, + sourcemeta::core::JSON::Object::hash(keyword)); + } + + for (const auto &entry : frame.locations()) { + if (entry.second.type == + sourcemeta::blaze::SchemaFrame::LocationType::Pointer || + entry.second.type == + sourcemeta::blaze::SchemaFrame::LocationType::Anchor) { + continue; + } + + const auto &subschema{sourcemeta::core::get(schema, entry.second.pointer)}; + if (!subschema.is_object()) { + continue; + } + + bool defines_keyword{false}; + for (const auto &[keyword, keyword_hash] : hashed_keywords) { + if (subschema.defines(keyword, keyword_hash)) { + defines_keyword = true; + break; + } + } + + if (defines_keyword && frame.is_reachable(entrypoint_location, entry.second, + walker, resolver)) { + return true; + } + } + + return false; +} + // TODO: Somehow move this logic up to `SchemaFrame` auto schema_frame_populate_target_types( const sourcemeta::blaze::SchemaFrame &frame, @@ -174,6 +222,14 @@ auto compile(const sourcemeta::core::JSON &schema, entrypoint, "The given entry point URI is not a valid subschema"}; } + const bool collects_annotations{ + mode == Mode::Exhaustive || + (effective_tweaks.annotations.has_value() && + !effective_tweaks.annotations.value().empty() && + defines_any_whitelisted_keyword(schema, frame, walker, resolver, + entrypoint_location, + effective_tweaks.annotations.value()))}; + /////////////////////////////////////////////////////////////////// // (1) Determine all the schema resources in the schema /////////////////////////////////////////////////////////////////// @@ -307,6 +363,7 @@ auto compile(const sourcemeta::core::JSON &schema, .compiler = compiler, .mode = mode, .uses_dynamic_scopes = uses_dynamic_scopes, + .collects_annotations = collects_annotations, .unevaluated = std::move(unevaluated), .tweaks = effective_tweaks, .targets = std::move(targets_map), diff --git a/vendor/blaze/src/compiler/compile_helpers.h b/vendor/blaze/src/compiler/compile_helpers.h index e8859110..2b0aaf98 100644 --- a/vendor/blaze/src/compiler/compile_helpers.h +++ b/vendor/blaze/src/compiler/compile_helpers.h @@ -11,6 +11,7 @@ #include // std::distance #include // std::optional #include // std::regex, std::regex_match, std::smatch +#include // std::out_of_range #include // std::declval, std::move namespace sourcemeta::blaze { @@ -159,14 +160,24 @@ inline auto rephrase(const Context &context, const InstructionIndex type, .extra_index = extra_index}; } +// Note that the size keywords that let the type level fuse their bound accept +// any integral number, including reals with no fractional part like `2.0`, so +// this must recognise those too or the fused bound would be silently dropped inline auto unsigned_integer_property(const sourcemeta::core::JSON &document, const sourcemeta::core::JSON::String &property) -> std::optional { - if (document.defines(property) && document.at(property).is_integer()) { - const auto value{document.at(property).to_integer()}; - assert(value >= 0); - return static_cast(value); + if (document.defines(property) && document.at(property).is_integral()) { + // A real or decimal may spell an integer too large to represent, and such a + // bound sits so far beyond any instance that we ignore it, just as a + // non-integral bound is ignored, rather than let the conversion raise + try { + const auto value{document.at(property).as_integer()}; + assert(value >= 0); + return static_cast(value); + } catch (const std::out_of_range &) { + return std::nullopt; + } } return std::nullopt; @@ -347,21 +358,6 @@ inline auto annotations_enabled(const Context &context, return context.mode == Mode::Exhaustive; } -// Whether any annotation may be collected anywhere in this compilation. Drives -// the "do not short-circuit / keep iterating" behavior of applicators like -// `contains` so that nested annotations are reached on every instance location, -// independently of whether the applicator's own annotation was whitelisted. -// Exhaustive mode always keeps iterating for complete error reporting, -// regardless of whether annotations are disabled via an empty whitelist. -inline auto annotations_collected(const Context &context) -> bool { - if (context.mode == Mode::Exhaustive) { - return true; - } - - return context.tweaks.annotations.has_value() && - !context.tweaks.annotations.value().empty(); -} - // TODO: Elevate to Core and test inline auto diff --git a/vendor/blaze/src/compiler/default_compiler.cc b/vendor/blaze/src/compiler/default_compiler.cc index 192b6a17..31f18b22 100644 --- a/vendor/blaze/src/compiler/default_compiler.cc +++ b/vendor/blaze/src/compiler/default_compiler.cc @@ -46,7 +46,8 @@ auto sourcemeta::blaze::default_schema_compiler( Known::JSON_Schema_Draft_3, Known::JSON_Schema_Draft_3_Hyper, Known::OpenAPI_3_1_Base, - Known::OpenAPI_3_2_Base}; + Known::OpenAPI_3_2_Base, + Known::Sourcemeta_Extension_V1}; schema_context.vocabularies.throw_if_any_unsupported( SUPPORTED_VOCABULARIES, "Cannot compile unsupported vocabulary"); diff --git a/vendor/blaze/src/compiler/default_compiler_2019_09.h b/vendor/blaze/src/compiler/default_compiler_2019_09.h index 6a7c6be3..81fd43bc 100644 --- a/vendor/blaze/src/compiler/default_compiler_2019_09.h +++ b/vendor/blaze/src/compiler/default_compiler_2019_09.h @@ -144,10 +144,10 @@ auto compiler_2019_09_applicator_contains_with_options( } } - if (maximum.has_value() && minimum > maximum.value()) { - return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, - schema_context, dynamic_context, ValueNone{})}; - } + // An unsatisfiable range is deliberately left for the general check below, + // which rejects every array yet leaves non-arrays alone. An unconditional + // failure here would instead reject non-arrays too, but `contains` does not + // apply to them if (minimum == 0 && !maximum.has_value() && !track_evaluation) { return {}; @@ -175,8 +175,12 @@ auto compiler_2019_09_applicator_contains_with_options( schema_context, relative_dynamic_context(), ValuePointer{})); } - if (children.empty()) { - // We still need to check the instance is not empty + // A trivial subschema matches every element, so the number of matches + // equals the array size and the constraint reduces to a size bound. The + // plain "at least one match" case has a cheap single-instruction form, but + // any higher minimum or any maximum needs the general range check, which + // handles an empty subschema correctly on its own + if (children.empty() && minimum == 1 && !maximum.has_value()) { return {make(sourcemeta::blaze::InstructionIndex::AssertionArraySizeGreater, context, schema_context, dynamic_context, ValueUnsignedInteger{0})}; @@ -195,7 +199,7 @@ auto compiler_2019_09_applicator_contains(const Context &context, -> Instructions { return compiler_2019_09_applicator_contains_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), false); + context.collects_annotations, false); } auto compiler_2019_09_applicator_additionalproperties( @@ -204,7 +208,7 @@ auto compiler_2019_09_applicator_additionalproperties( -> Instructions { return compiler_draft3_applicator_additionalproperties_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, requires_evaluation(context, schema_context)); } @@ -221,12 +225,12 @@ auto compiler_2019_09_applicator_items(const Context &context, if (schema_context.schema.at(dynamic_context.keyword).is_array()) { return compiler_draft3_applicator_items_with_options( - context, schema_context, dynamic_context, - annotations_collected(context), track); + context, schema_context, dynamic_context, context.collects_annotations, + track); } return compiler_draft3_applicator_items_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -242,7 +246,7 @@ auto compiler_2019_09_applicator_additionalitems( })}; return compiler_draft3_applicator_additionalitems_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -293,8 +297,12 @@ auto compiler_2019_09_applicator_unevaluateditems( return {}; } - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; + // An unconditional marker would record this instance as evaluated whatever + // its type, so against a non-array it would wrongly suppress a sibling + // `unevaluatedProperties`. An array-guarded marker only records arrays + return {make(sourcemeta::blaze::InstructionIndex::LoopItemsUnevaluated, + context, schema_context, dynamic_context, ValueNone{}, + Instructions{})}; } // TODO: Attempt to short-circuit evaluation tracking by looking at sibling @@ -391,8 +399,12 @@ auto compiler_2019_09_applicator_unevaluatedproperties( } if (children.empty()) { - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; + // An unconditional marker would record this instance as evaluated whatever + // its type, so against a non-object it would wrongly suppress a sibling + // `unevaluatedItems`. An object-guarded marker only records objects + return {make(sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate, + context, schema_context, dynamic_context, ValueNone{}, + Instructions{})}; } else if (!filter_strings.empty() || !filter_prefixes.empty() || !filter_regexes.empty()) { return {make( @@ -432,7 +444,7 @@ auto compiler_2019_09_applicator_properties( -> Instructions { return compiler_draft3_applicator_properties_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), + context.collects_annotations, requires_evaluation(context, schema_context)); } @@ -441,7 +453,7 @@ auto compiler_2019_09_applicator_patternproperties( const DynamicContext &dynamic_context, const Instructions &) -> Instructions { return compiler_draft3_applicator_patternproperties_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, requires_evaluation(context, schema_context)); } diff --git a/vendor/blaze/src/compiler/default_compiler_2020_12.h b/vendor/blaze/src/compiler/default_compiler_2020_12.h index 9b02ab84..6b6245ff 100644 --- a/vendor/blaze/src/compiler/default_compiler_2020_12.h +++ b/vendor/blaze/src/compiler/default_compiler_2020_12.h @@ -22,7 +22,7 @@ auto compiler_2020_12_applicator_prefixitems( })}; return compiler_draft3_applicator_items_array( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track); } @@ -44,7 +44,7 @@ auto compiler_2020_12_applicator_items(const Context &context, return compiler_draft3_applicator_additionalitems_from_cursor( context, schema_context, dynamic_context, cursor, - annotations_collected(context), + context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -62,7 +62,7 @@ auto compiler_2020_12_applicator_contains(const Context &context, return compiler_2019_09_applicator_contains_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), track); + context.collects_annotations, track); } auto compiler_2020_12_core_dynamicref(const Context &context, diff --git a/vendor/blaze/src/compiler/default_compiler_draft3.h b/vendor/blaze/src/compiler/default_compiler_draft3.h index ae717352..b65c0620 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft3.h +++ b/vendor/blaze/src/compiler/default_compiler_draft3.h @@ -232,7 +232,15 @@ auto compile_required_assertions(const Context &context, types.insert(std::get(property.second.front().value)); } - if (types.size() == 1) { + // `properties` only collapses into the fused form that also + // enforces these requirements when it is neither emitting + // annotations nor tracking evaluation, so this must mirror both of + // its gates. Note that we must ask that of `properties` rather than + // of this keyword, as evaluation tracking follows the keywords an + // `unevaluatedProperties` depends on, which `required` is not one of + if (types.size() == 1 && + !annotations_enabled(context, KEYWORD_PROPERTIES) && + !requires_evaluation(context, new_schema_context)) { // Handled in `properties` return {}; } @@ -614,8 +622,11 @@ auto compiler_draft3_applicator_properties_with_options( auto properties{compile_properties(context, schema_context, effective_dynamic_context, current)}; + // These fused forms collapse every property into a single instruction that + // does not mark the properties it matches as evaluated, so under evaluation + // tracking we must fall through to the form that emits the markers if (!emit_annotation && context.mode == Mode::FastValidation && - !required.empty() && + !track_evaluation && !required.empty() && schema_context.schema.defines("additionalProperties") && schema_context.schema.at("additionalProperties").is_boolean() && !schema_context.schema.at("additionalProperties").to_boolean() && @@ -856,15 +867,56 @@ auto compiler_draft3_applicator_properties_with_options( } } - if (fusion_possible && substeps.size() >= 2 && - std::ranges::any_of(substeps, [](const auto &step) -> auto { - return step.type == - InstructionIndex::AssertionObjectPropertiesSimple; - })) { - std::erase_if(substeps, [](const auto &step) -> auto { + // A fused check rejects a non-object and enforces the presence of the + // properties it marks as required, but only those, and only at its own + // instance location. This list is flattened and may also hold checks + // merged in from elsewhere, as every `allOf` branch contributes to it, + // so we may only drop what the fusion provably covers + std::vector< + std::pair>> + fusions; + if (fusion_possible && substeps.size() >= 2) { + for (const auto &step : substeps) { + if (step.type != InstructionIndex::AssertionObjectPropertiesSimple) { + continue; + } + + auto match{ + std::ranges::find_if(fusions, [&step](const auto &entry) -> bool { + return entry.first == step.relative_instance_location; + })}; + if (match == fusions.end()) { + fusions.emplace_back(step.relative_instance_location, + std::unordered_set{}); + match = std::prev(fusions.end()); + } + + for (const auto &entry : + std::get(step.value)) { + if (std::get<2>(entry)) { + match->second.insert(std::get<0>(entry)); + } + } + } + } + + if (!fusions.empty()) { + std::erase_if(substeps, [&fusions](const auto &step) -> auto { + const auto fusion{ + std::ranges::find_if(fusions, [&step](const auto &entry) -> bool { + return entry.first == step.relative_instance_location; + })}; + if (fusion == fusions.cend()) { + return false; + } + if (step.type == InstructionIndex::AssertionDefinesAllStrict || step.type == InstructionIndex::AssertionDefinesAll) { - return true; + const auto &value{std::get(step.value)}; + return std::ranges::all_of( + value, [&fusion](const auto &property) -> auto { + return fusion->second.contains(property.first); + }); } if ((step.type == InstructionIndex::AssertionTypeStrict || @@ -878,9 +930,23 @@ auto compiler_draft3_applicator_properties_with_options( }); } - if (fusion_possible && substeps.size() == 1 && + // Fusion re-applies the single check to every property of the enclosing + // loop with its instance location cleared, so a check whose meaning + // depends on its own instance location cannot be relocated this way. A + // jump into a separate target, or a check that navigates into or gates + // on a nested location such as the one a `properties` conditional + // produces, therefore falls back to unfused emission + const bool fusable_single{ + substeps.size() == 1 && substeps.front().type != InstructionIndex::ControlJump && - substeps.front().type != InstructionIndex::ControlDynamicAnchorJump) { + substeps.front().type != InstructionIndex::ControlDynamicAnchorJump && + substeps.front().type != InstructionIndex::ControlGroup && + substeps.front().type != InstructionIndex::ControlGroupWhenDefines && + substeps.front().type != + InstructionIndex::ControlGroupWhenDefinesDirect && + substeps.front().type != InstructionIndex::ControlGroupWhenType && + substeps.front().type != InstructionIndex::ControlEvaluate}; + if (fusion_possible && fusable_single) { const auto is_required{assume_object && required.contains(name)}; auto prop{make_property(name)}; auto fusion_child{substeps.front()}; @@ -922,7 +988,7 @@ auto compiler_draft3_applicator_properties_with_options( if (context.mode == Mode::FastValidation) { if (fusion_possible && !fusion_entries.empty() && - !annotations_collected(context)) { + !context.collects_annotations) { for (const auto &req : required) { const auto &req_name{req.first}; bool already_tracked{false}; @@ -1195,14 +1261,28 @@ auto compiler_draft3_applicator_additionalproperties_with_options( return {}; } - // When `additionalProperties: false` with only `properties` (no - // patternProperties), and `properties` is compiled as a loop - // (LoopPropertiesMatchClosed), that loop already handles rejecting unknown - // properties, so we don't need to emit anything for `additionalProperties` - if (context.mode == Mode::FastValidation && children.size() == 1 && + // The closed forms that the elisions below rely on are only emitted when + // `additionalProperties` is the boolean `false`. Testing the compiled shape + // alone is not enough, as other subschemas that reject everything, like an + // empty `enum`, also reduce to an unconditional failure without closing + // anything + const auto rejects_with_boolean_false{ + schema_context.schema.at(dynamic_context.keyword).is_boolean() && + !schema_context.schema.at(dynamic_context.keyword).to_boolean()}; + + // When `additionalProperties: false` sits with `properties` alone and + // `properties` compiles to its closed form, that form already rejects + // unknown properties, so `additionalProperties` need emit nothing. Both + // evaluation tracking and a defined `patternProperties` hold `properties` + // back to its non-closed form, in which case the closure must still be + // emitted here. An empty `patternProperties` contributes no property filters + // yet still counts as defined, so its mere presence, not the filters it + // yields, is what matters + if (context.mode == Mode::FastValidation && !track_evaluation && + rejects_with_boolean_false && children.size() == 1 && children.front().type == InstructionIndex::AssertionFail && - !filter_strings.empty() && filter_prefixes.empty() && - filter_regexes.empty() && + !filter_strings.empty() && + !schema_context.schema.defines("patternProperties") && properties_as_loop(context, schema_context, schema_context.schema.at("properties"))) { return {}; @@ -1229,9 +1309,12 @@ auto compiler_draft3_applicator_additionalproperties_with_options( } } + // Similarly, `patternProperties` only compiles to its closed form, which + // rejects unmatched properties on its own, when `additionalProperties` is + // the boolean `false` if (context.mode == Mode::FastValidation && filter_strings.empty() && filter_prefixes.empty() && filter_regexes.size() == 1 && - !track_evaluation && !children.empty() && + !track_evaluation && rejects_with_boolean_false && !children.empty() && children.front().type == InstructionIndex::AssertionFail) { return {}; } @@ -1251,11 +1334,11 @@ auto compiler_draft3_applicator_additionalproperties_with_options( std::move(filter_regexes)}, std::move(children))}; } else if (track_evaluation) { - if (children.empty()) { - return {make(sourcemeta::blaze::InstructionIndex::Evaluate, context, - schema_context, dynamic_context, ValueNone{})}; - } - + // An unconditional marker records this instance as evaluated whatever its + // type, so when this schema is applied to a non-object, such as a + // conditional branch evaluated against an array, it would wrongly mark that + // array evaluated and suppress a sibling `unevaluatedItems`. An + // object-guarded marker only records objects, and needs no children return {make(sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate, context, schema_context, dynamic_context, ValueNone{}, std::move(children))}; @@ -1758,8 +1841,11 @@ auto compiler_draft3_validation_maxlength(const Context &context, return {}; } - // We'll handle it at the type level as an optimization + // We'll handle it at the type level as an optimization. Note that the type + // compiler bails out before it reads these bounds when it is compiling a + // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && + !schema_context.is_property_name && schema_context.schema.defines("type") && schema_context.schema.at("type").is_string() && schema_context.schema.at("type").to_string() == "string") { @@ -1792,8 +1878,11 @@ auto compiler_draft3_validation_minlength(const Context &context, return {}; } - // We'll handle it at the type level as an optimization + // We'll handle it at the type level as an optimization. Note that the type + // compiler bails out before it reads these bounds when it is compiling a + // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && + !schema_context.is_property_name && schema_context.schema.defines("type") && schema_context.schema.at("type").is_string() && schema_context.schema.at("type").to_string() == "string") { @@ -2092,6 +2181,12 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxProperties")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, + ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeObjectUpper, @@ -2116,8 +2211,13 @@ auto compiler_draft3_validation_type(const Context &context, return {}; } + // A non-empty `required` rejects a non-object on its own, so the type + // assertion is redundant. An empty `required` asserts nothing, so the + // type check must stay if (!is_draft3 && context.mode == Mode::FastValidation && - schema_context.schema.defines("required")) { + schema_context.schema.defines("required") && + schema_context.schema.at("required").is_array() && + !schema_context.schema.at("required").empty()) { return {}; } @@ -2131,6 +2231,11 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxItems")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return { make(sourcemeta::blaze::InstructionIndex::AssertionTypeArrayUpper, @@ -2195,6 +2300,11 @@ auto compiler_draft3_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxLength")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeStringUpper, @@ -2286,8 +2396,16 @@ auto compiler_draft3_validation_type(const Context &context, } if (!types.any()) { - return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, - schema_context, dynamic_context, ValueNone{})}; + // An empty array names no type at all, so no value can match it. A + // non-empty one that named only unrecognised types is an invalid but + // legitimate use of the keyword that we ignore, constraining nothing, + // exactly as the scalar form does for a single unrecognised name + if (value.empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + + return {}; } return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrictAny, diff --git a/vendor/blaze/src/compiler/default_compiler_draft4.h b/vendor/blaze/src/compiler/default_compiler_draft4.h index b974a7d5..8960ab6b 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft4.h +++ b/vendor/blaze/src/compiler/default_compiler_draft4.h @@ -141,7 +141,7 @@ auto compiler_draft4_applicator_anyof(const Context &context, } const auto requires_exhaustive{context.mode == Mode::Exhaustive || - annotations_collected(context) || + context.collects_annotations || requires_evaluation(context, schema_context)}; return {make(sourcemeta::blaze::InstructionIndex::LogicalOr, context, @@ -172,7 +172,7 @@ auto compiler_draft4_applicator_oneof(const Context &context, } const auto requires_exhaustive{context.mode == Mode::Exhaustive || - annotations_collected(context) || + context.collects_annotations || requires_evaluation(context, schema_context)}; return {make(sourcemeta::blaze::InstructionIndex::LogicalXor, context, diff --git a/vendor/blaze/src/compiler/default_compiler_draft6.h b/vendor/blaze/src/compiler/default_compiler_draft6.h index 25a1d754..f4ee5032 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft6.h +++ b/vendor/blaze/src/compiler/default_compiler_draft6.h @@ -16,6 +16,40 @@ auto compiler_draft6_validation_type(const Context &context, const DynamicContext &dynamic_context, const Instructions ¤t) -> Instructions { + // Every property name is a string, so inside `propertyNames` this keyword + // is decidable at compile time. Note that the evaluator applies such a + // subschema to a null placeholder and carries the name out of band, so a + // type assertion emitted here would test that placeholder rather than the + // name. The failure is emitted per name, as these instructions become the + // children of the loop over the keys, so an object with no properties + // still passes + if (schema_context.is_property_name) { + const auto types{sourcemeta::blaze::parse_schema_type( + schema_context.schema.at(dynamic_context.keyword))}; + if (types.test(std::to_underlying(sourcemeta::core::JSON::Type::String))) { + return {}; + } + + // A set that names known types, none of them a string, can never match a + // property name, so every name fails + if (types.any()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + + // No known type was named. An empty array names nothing at all, so no name + // can match it, whereas an unrecognised name is an invalid but legitimate + // use that constrains nothing and is ignored, matching how the forms below + // treat both outside `propertyNames` + if (schema_context.schema.at(dynamic_context.keyword).is_array() && + schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + + return {}; + } + if (schema_context.schema.at(dynamic_context.keyword).is_string()) { const auto &type{ schema_context.schema.at(dynamic_context.keyword).to_string()}; @@ -64,6 +98,11 @@ auto compiler_draft6_validation_type(const Context &context, unsigned_integer_property(schema_context.schema, "maxProperties")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeObjectUpper, @@ -92,8 +131,13 @@ auto compiler_draft6_validation_type(const Context &context, return {}; } + // A non-empty `required` rejects a non-object on its own, so the type + // assertion is redundant. An empty `required` asserts nothing, so the + // type check must stay if (context.mode == Mode::FastValidation && - schema_context.schema.defines("required")) { + schema_context.schema.defines("required") && + schema_context.schema.at("required").is_array() && + !schema_context.schema.at("required").empty()) { return {}; } @@ -101,15 +145,25 @@ auto compiler_draft6_validation_type(const Context &context, context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Object)}; } else if (type == "array") { - if (context.mode == Mode::FastValidation && !current.empty() && + const auto minimum{ + unsigned_integer_property(schema_context.schema, "minItems", 0)}; + const auto maximum{ + unsigned_integer_property(schema_context.schema, "maxItems")}; + + // A preceding array loop at this location already rejects a non-array, + // so the type assertion is redundant. The array size bounds are only + // redundant when there are none to enforce, as the loops other than the + // sized one do not check the item count. Only a loop that truly rejects + // a non-array qualifies: some item loops instead pass a non-array + // untouched, so they are deliberately excluded below + if (context.mode == Mode::FastValidation && minimum == 0 && + !maximum.has_value() && !current.empty() && (current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsPropertiesExactlyTypeStrictHash || current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsPropertiesExactlyTypeStrictHash3 || - current.back().type == - sourcemeta::blaze::InstructionIndex::LoopItemsIntegerBounded || current.back().type == sourcemeta::blaze::InstructionIndex:: LoopItemsIntegerBoundedSized) && current.back().relative_instance_location == @@ -117,12 +171,12 @@ auto compiler_draft6_validation_type(const Context &context, return {}; } - const auto minimum{ - unsigned_integer_property(schema_context.schema, "minItems", 0)}; - const auto maximum{ - unsigned_integer_property(schema_context.schema, "maxItems")}; - if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return { make(sourcemeta::blaze::InstructionIndex::AssertionTypeArrayUpper, @@ -196,16 +250,17 @@ auto compiler_draft6_validation_type(const Context &context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Integer)}; } else if (type == "string") { - if (schema_context.is_property_name) { - return {}; - } - const auto minimum{ unsigned_integer_property(schema_context.schema, "minLength", 0)}; const auto maximum{ unsigned_integer_property(schema_context.schema, "maxLength")}; if (context.mode == Mode::FastValidation) { + if (maximum.has_value() && minimum > maximum.value()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + if (maximum.has_value() && minimum == 0) { return {make( sourcemeta::blaze::InstructionIndex::AssertionTypeStringUpper, @@ -275,10 +330,6 @@ auto compiler_draft6_validation_type(const Context &context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Integer)}; } else if (type == "string") { - if (schema_context.is_property_name) { - return {}; - } - return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrict, context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::String)}; @@ -306,15 +357,23 @@ auto compiler_draft6_validation_type(const Context &context, } else if (type_string == "integer") { types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); } else if (type_string == "string") { - if (schema_context.is_property_name) { - continue; - } - types.set(std::to_underlying(sourcemeta::core::JSON::Type::String)); } } - assert(types.any()); + if (types.none()) { + // An empty array names no type at all, so no value can match it. A + // non-empty one that named only unrecognised types is an invalid but + // legitimate use of the keyword that we ignore, constraining nothing, + // exactly as the scalar form does for a single unrecognised name + if (schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, + context, schema_context, dynamic_context, ValueNone{})}; + } + + return {}; + } + return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeAny, context, schema_context, dynamic_context, types)}; } diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h index 8fcf6c89..bebc4f25 100644 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h +++ b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h @@ -99,8 +99,9 @@ struct Tweaks { std::size_t target_inline_threshold{50}; /// When set, force `format` to be compiled as an assertion bool format_assertion{false}; - /// Select which keywords emit annotations in exhaustive mode. When not set, - /// every annotation keyword is emitted + /// Select which keywords emit annotations, regardless of the compilation + /// mode. When not set, every annotation keyword is emitted in exhaustive + /// mode and none in fast mode std::optional> annotations{}; }; @@ -127,6 +128,8 @@ struct Context { const Mode mode; /// Whether the schema makes use of dynamic scoping const bool uses_dynamic_scopes; + /// Whether evaluating the schema may collect at least one annotation + const bool collects_annotations; /// The list of unevaluated entries and their dependencies const SchemaUnevaluatedEntries unevaluated; /// The set of tweaks for the compiler diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h index aa5b0e95..8eb8cf5e 100644 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h +++ b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h @@ -22,7 +22,8 @@ namespace sourcemeta::blaze { struct SchemaUnevaluatedEntry { /// The absolute pointers of the static keyword dependencies std::set static_dependencies; - /// The absolute pointers of the static keyword dependencies + /// The absolute pointers of the dynamic keyword dependencies, along with the + /// references that lead to them std::set dynamic_dependencies; /// Whether the entry cannot be fully resolved, which means /// there might be unknown dynamic dependencies diff --git a/vendor/blaze/src/compiler/postprocess.h b/vendor/blaze/src/compiler/postprocess.h index 70504705..8c69f88c 100644 --- a/vendor/blaze/src/compiler/postprocess.h +++ b/vendor/blaze/src/compiler/postprocess.h @@ -7,6 +7,7 @@ #include #include #include // std::string +#include // std::tuple #include #include // std::unordered_set #include @@ -32,15 +33,18 @@ inline auto is_noop_without_children(const InstructionIndex type) noexcept case InstructionIndex::LogicalWhenArraySizeGreater: case InstructionIndex::LoopPropertiesMatch: case InstructionIndex::LoopProperties: - case InstructionIndex::LoopPropertiesEvaluate: case InstructionIndex::LoopPropertiesRegex: case InstructionIndex::LoopPropertiesStartsWith: case InstructionIndex::LoopPropertiesExcept: case InstructionIndex::LoopKeys: case InstructionIndex::LoopItems: case InstructionIndex::LoopItemsFrom: - case InstructionIndex::LoopItemsUnevaluated: - case InstructionIndex::LoopContains: + // Deliberately absent are the loops that still do something with no + // children: the one carrying a match count still asserts that the array + // size falls within that range, and the ones that mark their instance + // location as evaluated still carry that side effect for a later + // `unevaluatedProperties` or `unevaluatedItems` to consult. Dropping any of + // them would discard those semantics case InstructionIndex::ControlGroupWhenDefines: case InstructionIndex::ControlGroupWhenDefinesDirect: case InstructionIndex::ControlGroupWhenType: @@ -103,34 +107,59 @@ inline auto duplicate_metadata(Instruction &instruction, } } -inline auto rebase(Instruction &instruction, - std::vector &extra, - const sourcemeta::core::Pointer &schema_prefix, - const sourcemeta::core::Pointer &instance_prefix) -> void { +// Whether an instruction relays its children without pushing its own schema +// location onto the evaluation path, leaving them anchored on its own parent +inline auto is_pass_through_instruction(const InstructionIndex type) noexcept + -> bool { + switch (type) { + case InstructionIndex::ControlGroup: + case InstructionIndex::ControlGroupWhenDefines: + case InstructionIndex::ControlGroupWhenDefinesDirect: + case InstructionIndex::ControlGroupWhenType: + case InstructionIndex::ControlEvaluate: + return true; + default: + return false; + } +} + +// Prefix every instruction that anchors its schema location on the point an +// inlined target is spliced into. That is the top instruction itself, plus +// the instructions that evaluation reaches without a location of their own in +// between: the consequence children of a conditional, which re-anchor on the +// conditional's parent, and the children of pass-through instructions. +// Condition children and ordinary nested children stay relative to their +// parent instruction and must be left alone +inline auto rebase_anchored(Instruction &instruction, + std::vector &extra, + const sourcemeta::core::Pointer &schema_prefix) + -> void { extra[instruction.extra_index].relative_schema_location = schema_prefix.concat( extra[instruction.extra_index].relative_schema_location); - instruction.relative_instance_location = - instance_prefix.concat(instruction.relative_instance_location); if (instruction.type == InstructionIndex::LogicalCondition) { const auto &value{std::get(instruction.value)}; - const auto then_cursor{value.first}; - // TODO(C++23): Use std::views::enumerate when available in libc++ - for (std::size_t index = then_cursor; index < instruction.children.size(); + for (std::size_t index = value.first; index < instruction.children.size(); ++index) { - auto &child{instruction.children[index]}; - extra[child.extra_index].relative_schema_location = schema_prefix.concat( - extra[child.extra_index].relative_schema_location); - for (auto &grandchild : child.children) { - extra[grandchild.extra_index].relative_schema_location = - schema_prefix.concat( - extra[grandchild.extra_index].relative_schema_location); - } + rebase_anchored(instruction.children[index], extra, schema_prefix); + } + } else if (is_pass_through_instruction(instruction.type)) { + for (auto &child : instruction.children) { + rebase_anchored(child, extra, schema_prefix); } } } +inline auto rebase(Instruction &instruction, + std::vector &extra, + const sourcemeta::core::Pointer &schema_prefix, + const sourcemeta::core::Pointer &instance_prefix) -> void { + instruction.relative_instance_location = + instance_prefix.concat(instruction.relative_instance_location); + rebase_anchored(instruction, extra, schema_prefix); +} + inline auto collect_statistics(const Instructions &instructions, TargetStatistics &statistics) -> void { for (const auto &instruction : instructions) { @@ -156,8 +185,12 @@ transform_instruction(Instruction &instruction, Instructions &output, const std::vector &targets, const std::vector &statistics, TargetStatistics ¤t_stats, const Tweaks &tweaks, - const bool uses_dynamic_scopes) -> bool { - if (instruction.type == InstructionIndex::ControlJump) { + const bool uses_dynamic_scopes, const bool positional) + -> bool { + // A positional owner pairs each child with an entry of its own data by + // index, and inlining a jump expands one child into many, which would + // re-pair the rest + if (!positional && instruction.type == InstructionIndex::ControlJump) { const auto jump_target_index{ std::get(instruction.value)}; const auto &jump_target_stats{statistics[jump_target_index]}; @@ -380,13 +413,12 @@ inline auto postprocess(std::vector &targets, auto &target{targets[current_target_index]}; auto ¤t_stats{statistics[current_target_index]}; - std::vector worklist; - std::vector> stack; - stack.emplace_back(&target, 0); + std::vector> worklist; + std::vector> stack; + stack.emplace_back(&target, 0, nullptr); while (!stack.empty()) { - auto current{stack.back().first}; - auto index{stack.back().second}; + auto [current, index, owner] = stack.back(); stack.pop_back(); while (index < current->size() && (*current)[index].children.empty()) { @@ -394,38 +426,114 @@ inline auto postprocess(std::vector &targets, } if (index < current->size()) { - stack.emplace_back(current, index + 1); - stack.emplace_back(&(*current)[index].children, 0); + stack.emplace_back(current, index + 1, owner); + stack.emplace_back(&(*current)[index].children, 0, + &(*current)[index]); } else { - worklist.push_back(current); + worklist.emplace_back(current, owner); } } - for (auto *current : worklist) { + for (const auto &[current, owner] : worklist) { Instructions result; result.reserve(current->size()); - std::unordered_set fusion_covered_properties; - for (const auto &instruction : *current) { - if (instruction.type == - InstructionIndex::AssertionObjectPropertiesSimple) { - const auto &entries{ - std::get(instruction.value)}; - for (const auto &entry : entries) { - fusion_covered_properties.insert(std::get<0>(entry)); + // A conditional stores the indices where its branches begin, so when + // this pass drops or expands any child, those indices must be remapped + // onto the rewritten list + const bool remap{owner != nullptr && + owner->type == InstructionIndex::LogicalCondition}; + std::vector boundaries; + if (remap) { + boundaries.reserve(current->size() + 1); + } + + // The children of a conditional span its mutually exclusive + // condition, consequence, and alternative segments, and the children + // of a disjunction are alternative branches, so an instruction in one + // of them cannot subsume an instruction in another. Every other + // children list is a conjunction evaluated as a whole, where + // subsumption holds + // A fused simple-properties instruction pairs its entries with its + // children by index, so its children may neither be dropped, expanded, + // nor subsumed by each other, as each one applies to a different + // property despite them all sharing an empty instance location + const bool positional{ + owner != nullptr && + owner->type == InstructionIndex::AssertionObjectPropertiesSimple}; + + const bool disjoint{ + positional || (owner != nullptr && + (owner->type == InstructionIndex::LogicalCondition || + owner->type == InstructionIndex::LogicalOr || + owner->type == InstructionIndex::LogicalXor))}; + + // A fused simple-properties instruction only enforces that a property + // is present for the entries it marks as required, so only those may + // subsume a sibling presence assertion. Every fused instruction, on + // the other hand, rejects a non-object outright, so any of them can + // subsume a sibling object type assertion. None of that says anything + // about the rest of the instance, so a fused instruction may only + // subsume siblings that apply to the very same instance location + std::vector>> + fusions; + if (!disjoint) { + for (const auto &instruction : *current) { + if (instruction.type != + InstructionIndex::AssertionObjectPropertiesSimple) { + continue; + } + + auto match{std::ranges::find_if( + fusions, [&instruction](const auto &entry) -> bool { + return entry.first == instruction.relative_instance_location; + })}; + if (match == fusions.end()) { + fusions.emplace_back(instruction.relative_instance_location, + std::unordered_set{}); + match = std::prev(fusions.end()); + } + + for (const auto &entry : + std::get(instruction.value)) { + if (std::get<2>(entry)) { + match->second.insert(std::get<0>(entry)); + } } } } + // A positional owner pairs child N with entry N of its own data, and + // every entry past the last child only carries a presence requirement. + // Dropping a child therefore means moving its entry onto that tail, so + // the entries left in front stay paired with the children that survived + std::vector surviving; + std::vector dropped; + std::size_t child_index{0}; + for (auto &instruction : *current) { - if (!fusion_covered_properties.empty()) { + if (remap) { + boundaries.push_back(result.size()); + } + + const auto result_size{result.size()}; + const auto positional_index{child_index}; + child_index += 1; + + const auto fusion{std::ranges::find_if( + fusions, [&instruction](const auto &entry) -> bool { + return entry.first == instruction.relative_instance_location; + })}; + + if (fusion != fusions.cend()) { switch (instruction.type) { case InstructionIndex::AssertionDefinesAllStrict: case InstructionIndex::AssertionDefinesAll: { const auto &value{std::get(instruction.value)}; bool all_covered{true}; for (const auto &property : value) { - if (!fusion_covered_properties.contains(property.first)) { + if (!fusion->second.contains(property.first)) { all_covered = false; break; } @@ -439,7 +547,7 @@ inline auto postprocess(std::vector &targets, case InstructionIndex::AssertionDefinesStrict: case InstructionIndex::AssertionDefines: { const auto &value{std::get(instruction.value)}; - if (fusion_covered_properties.contains(value.first)) { + if (fusion->second.contains(value.first)) { changed = true; continue; } @@ -460,8 +568,59 @@ inline auto postprocess(std::vector &targets, if (transform_instruction(instruction, result, extra, targets, statistics, current_stats, tweaks, - uses_dynamic_scopes)) + uses_dynamic_scopes, positional)) changed = true; + + if (positional) { + if (result.size() == result_size) { + dropped.push_back(positional_index); + } else { + surviving.push_back(positional_index); + } + } + } + + if (positional && !dropped.empty()) { + auto &entries{std::get(owner->value)}; + ValueObjectProperties reordered; + for (const auto index : surviving) { + reordered.push_back(entries[index]); + } + + for (const auto index : dropped) { + reordered.push_back(entries[index]); + } + + for (auto index = child_index; index < entries.size(); index++) { + reordered.push_back(entries[index]); + } + + entries = std::move(reordered); + } + + if (remap) { + boundaries.push_back(result.size()); + auto &cursors{std::get(owner->value)}; + const auto then_start{boundaries[cursors.first]}; + if (then_start == 0) { + // The condition dropped to nothing, so it is always true: the then + // branch applies unconditionally and the else branch is dead. Keep + // only the then instructions and record an empty condition with no + // else, which the evaluator runs as the consequence + const auto else_start{cursors.second > 0 + ? boundaries[cursors.second] + : result.size()}; + result.erase(result.begin() + + static_cast(else_start), + result.end()); + cursors.first = 0; + cursors.second = 0; + } else { + cursors.first = then_start; + if (cursors.second > 0) { + cursors.second = boundaries[cursors.second]; + } + } } *current = std::move(result); diff --git a/vendor/blaze/src/compiler/unevaluated.cc b/vendor/blaze/src/compiler/unevaluated.cc index 89333ab0..dfcd67f0 100644 --- a/vendor/blaze/src/compiler/unevaluated.cc +++ b/vendor/blaze/src/compiler/unevaluated.cc @@ -54,9 +54,32 @@ auto find_adjacent_dependencies( frame.dereference(entry, make_weak_pointer(property.first))}; if (reference.first == SchemaReferenceType::Static && reference.second.has_value()) { + // Recurse into a dedicated entry so that whether this reference's + // target contributes any dynamic dependency can be read directly, + // rather than inferred from whether it grew the shared deduplicated + // set, which misses every reference after the first to a common + // target + SchemaUnevaluatedEntry nested; find_adjacent_dependencies( current, schema, frame, walker, resolver, keywords, root, - reference.second.value().get(), is_static, result); + reference.second.value().get(), is_static, nested); + + // Whatever the target contributes gets recorded at the location of + // the target itself, which tells the applicators this reference sits + // under nothing about it. Record the reference as a dependency of + // its own too, so that they can still tell that reaching through it + // leads to one, and therefore that they cannot short-circuit. Only + // the dynamic dependencies are consulted that way, whereas the + // static ones name the keyword locations that evaluate, which a + // reference is not one of + if (!is_static && !nested.dynamic_dependencies.empty()) { + result.dynamic_dependencies.emplace( + entry.pointer.concat(make_weak_pointer(property.first))); + } + + result.unresolved = result.unresolved || nested.unresolved; + result.static_dependencies.merge(nested.static_dependencies); + result.dynamic_dependencies.merge(nested.dynamic_dependencies); } else if (reference.first == SchemaReferenceType::Dynamic) { result.unresolved = true; } diff --git a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h index 4c772494..db3f0fb9 100644 --- a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h +++ b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h @@ -69,7 +69,12 @@ struct SOURCEMETA_BLAZE_CONFIGURATION_EXPORT Configuration { std::vector ignore; struct Lint { - std::vector rules; + struct Rule { + std::filesystem::path path; + bool top_level{false}; + }; + + std::vector rules; }; Lint lint; diff --git a/vendor/blaze/src/configuration/json.cc b/vendor/blaze/src/configuration/json.cc index 532af7ec..c995697b 100644 --- a/vendor/blaze/src/configuration/json.cc +++ b/vendor/blaze/src/configuration/json.cc @@ -105,8 +105,16 @@ auto Configuration::to_json() const -> sourcemeta::core::JSON { auto lint_object{sourcemeta::core::JSON::make_object()}; auto rules_array{sourcemeta::core::JSON::make_array()}; for (const auto &rule : this->lint.rules) { - rules_array.push_back( - sourcemeta::core::JSON{relative_display_path(rule, this->base_path)}); + if (rule.top_level) { + auto rule_object{sourcemeta::core::JSON::make_object()}; + rule_object.assign("path", sourcemeta::core::JSON{relative_display_path( + rule.path, this->base_path)}); + rule_object.assign("topLevel", sourcemeta::core::JSON{true}); + rules_array.push_back(std::move(rule_object)); + } else { + rules_array.push_back(sourcemeta::core::JSON{ + relative_display_path(rule.path, this->base_path)}); + } } lint_object.assign("rules", std::move(rules_array)); diff --git a/vendor/blaze/src/configuration/parse.cc b/vendor/blaze/src/configuration/parse.cc index 70b879b8..370272fb 100644 --- a/vendor/blaze/src/configuration/parse.cc +++ b/vendor/blaze/src/configuration/parse.cc @@ -200,15 +200,33 @@ auto Configuration::from_json(const sourcemeta::core::JSON &value, std::size_t index{0}; for (const auto &element : lint_value.at("rules").as_array()) { CONFIGURATION_ENSURE( - element.is_string(), - "The values in the lint rules array must be strings", + element.is_string() || element.is_object(), + "The values in the lint rules array must be strings or objects", sourcemeta::core::Pointer({"lint", "rules", index})); - const std::filesystem::path path{element.to_string()}; + bool top_level{false}; + if (element.is_object()) { + CONFIGURATION_ENSURE( + element.defines("path") && element.at("path").is_string(), + "The lint rule path property must be a string", + sourcemeta::core::Pointer({"lint", "rules", index, "path"})); + CONFIGURATION_ENSURE( + !element.defines("topLevel") || + element.at("topLevel").is_boolean(), + "The lint rule topLevel property must be a boolean", + sourcemeta::core::Pointer({"lint", "rules", index, "topLevel"})); + top_level = element.defines("topLevel") && + element.at("topLevel").to_boolean(); + } + + const std::filesystem::path path{element.is_string() + ? element.to_string() + : element.at("path").to_string()}; result.lint.rules.push_back( - path.is_absolute() - ? sourcemeta::core::weakly_canonical(path) - : sourcemeta::core::weakly_canonical(base_path / path)); + {.path = path.is_absolute() + ? sourcemeta::core::weakly_canonical(path) + : sourcemeta::core::weakly_canonical(base_path / path), + .top_level = top_level}); index += 1; } } diff --git a/vendor/blaze/src/evaluator/evaluator_describe.cc b/vendor/blaze/src/evaluator/evaluator_describe.cc index 61546291..bfba8214 100644 --- a/vendor/blaze/src/evaluator/evaluator_describe.cc +++ b/vendor/blaze/src/evaluator/evaluator_describe.cc @@ -329,20 +329,23 @@ auto describe(const bool valid, const Instruction &step, return "The constraints declared for this keyword were not satisfiable"; } - if (keyword == "additionalProperties" || - keyword == "unevaluatedProperties") { + // A `false` subschema declares no keyword of its own, so its evaluation + // path ends at whatever declared it, which under `properties` and its + // relatives is a name the author chose. A name that happens to read like + // one of the keywords below must not be taken for the keyword itself, so + // each of these only applies when the instance location agrees with it + if ((keyword == "additionalProperties" || + keyword == "unevaluatedProperties") && + !instance_location.empty() && instance_location.back().is_property()) { std::ostringstream message; - assert(!instance_location.empty()); - assert(instance_location.back().is_property()); message << "The object value was not expected to define the property " << escape_string(instance_location.back().to_property()); return message.str(); } - if (keyword == "unevaluatedItems") { + if (keyword == "unevaluatedItems" && !instance_location.empty() && + instance_location.back().is_index()) { std::ostringstream message; - assert(!instance_location.empty()); - assert(instance_location.back().is_index()); message << "The array value was not expected to define the item at index " << instance_location.back().to_index(); return message.str(); @@ -524,8 +527,10 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "title" || keyword == "description") { - assert(annotation.is_string()); + // Note that these annotation values come from the schema as they are + // written, and nothing forces them to have the type their keyword expects + if ((keyword == "title" || keyword == "description") && + annotation.is_string()) { std::ostringstream message; message << "The " << keyword << " of the"; if (instance_location.empty()) { @@ -613,8 +618,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "examples") { - assert(annotation.is_array()); + if (keyword == "examples" && annotation.is_array()) { std::ostringstream message; if (instance_location.empty()) { message << "Examples of the instance"; @@ -639,8 +643,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "contentEncoding") { - assert(annotation.is_string()); + if (keyword == "contentEncoding" && annotation.is_string()) { std::ostringstream message; message << "The content encoding of the"; if (instance_location.empty()) { @@ -655,8 +658,7 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } - if (keyword == "contentMediaType") { - assert(annotation.is_string()); + if (keyword == "contentMediaType" && annotation.is_string()) { std::ostringstream message; message << "The content media type of the"; if (instance_location.empty()) { @@ -703,6 +705,156 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-format-assertion" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "A sibling `format` keyword was expected to be enforced as " + "an assertion"; + } + + return "A sibling `format` keyword was expected to be collected as " + "an annotation"; + } + + if (keyword == "x-jsonld-id" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD predicate was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-id" && annotation.is_null()) { + return "The nested JSON-LD predicates were expected to be removed"; + } + + if (keyword == "x-jsonld-reverse" && annotation.is_string()) { + std::ostringstream message; + message << "The reverse JSON-LD predicate was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-reverse" && annotation.is_null()) { + return "The nested reverse JSON-LD predicates were expected to be " + "removed"; + } + + if (keyword == "x-jsonld-type" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD type was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-type" && annotation.is_null()) { + return "The nested JSON-LD types were expected to be removed"; + } + + if (keyword == "x-jsonld-type" && annotation.is_array()) { + if (annotation.empty()) { + return "No JSON-LD types were assigned"; + } + + std::ostringstream message; + message << "The JSON-LD types were "; + const auto &elements{annotation.as_array()}; + for (auto iterator = elements.cbegin(); iterator != elements.cend(); + ++iterator) { + if (iterator != elements.cbegin()) { + message << (std::next(iterator) == elements.cend() ? ", and " : ", "); + } + + describe_stringify(*iterator, message); + } + + return message.str(); + } + + if (keyword == "x-jsonld-datatype" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD datatype was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-datatype" && annotation.is_null()) { + return "The JSON-LD datatype was expected to be removed"; + } + + if (keyword == "x-jsonld-language" && annotation.is_string()) { + std::ostringstream message; + message << "The natural language was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-language" && annotation.is_null()) { + return "The natural language was expected to be removed"; + } + + if (keyword == "x-jsonld-direction" && annotation.is_string()) { + std::ostringstream message; + message << "The base direction was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-direction" && annotation.is_null()) { + return "The base direction was expected to be removed"; + } + + if (keyword == "x-jsonld-json" && + (annotation.is_boolean() || annotation.is_null())) { + if (annotation.is_boolean() && annotation.to_boolean()) { + return "The value was expected to be treated as an opaque JSON " + "literal"; + } + + return "The value was not expected to be treated as an opaque JSON " + "literal"; + } + + if (keyword == "x-jsonld-graph" && + (annotation.is_boolean() || annotation.is_null())) { + if (annotation.is_boolean() && annotation.to_boolean()) { + return "The value was expected to be wrapped in a JSON-LD named graph"; + } + + return "The value was not expected to be wrapped in a JSON-LD named " + "graph"; + } + + if (keyword == "x-jsonld-container" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD container was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-container" && annotation.is_null()) { + return "The JSON-LD container was expected to be removed"; + } + + if (keyword == "x-jsonld-self" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD identifier template was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-self" && annotation.is_null()) { + return "The JSON-LD identifier was expected to be removed"; + } + + if (keyword == "x-jsonld-override" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "The sibling JSON-LD annotations were expected to override " + "the nested annotations they shadow"; + } + + return "The sibling JSON-LD annotations were not expected to override " + "any other annotations"; + } + std::ostringstream message; message << "The unrecognized keyword " << escape_string(keyword) << " was collected as the annotation "; @@ -791,12 +943,21 @@ auto describe(const bool valid, const Instruction &step, if (step.type == sourcemeta::blaze::InstructionIndex::LoopPropertiesEvaluate) { - assert(keyword == "additionalProperties"); + assert(keyword == "additionalProperties" || + keyword == "unevaluatedProperties"); std::ostringstream message; if (step.children.size() == 1 && step.children.front().type == InstructionIndex::AssertionFail) { - message << "The object value was not expected to define additional " - "properties"; + if (keyword == "unevaluatedProperties") { + message << "The object value was not expected to define unevaluated " + "properties"; + } else { + message << "The object value was not expected to define additional " + "properties"; + } + } else if (keyword == "unevaluatedProperties") { + message << "The object properties not covered by other object " + "keywords were expected to validate against this subschema"; } else { message << "The object properties not covered by other adjacent object " "keywords were expected to validate against this subschema"; diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h index 837a4ba5..3e0aecd9 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h @@ -243,6 +243,20 @@ class SOURCEMETA_BLAZE_EVALUATOR_EXPORT Evaluator { } } + // A subschema that ends up failing must not contribute any of the marks it + // made along the way, so a disjunction records the length here before each + // branch and truncates back to it when the branch does not hold. Marks are + // only ever appended, never inserted earlier, so everything a branch adds + // sits past the recorded length and nothing from outside it can be lost + [[nodiscard]] auto checkpoint() const -> std::size_t { + return this->evaluated_.size(); + } + + auto rewind(const std::size_t checkpoint) -> void { + assert(checkpoint <= this->evaluated_.size()); + this->evaluated_.resize(checkpoint); + } + #if defined(_MSC_VER) #pragma warning(disable : 4251 4275) #endif diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h index 9ef01708..14a3837e 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h @@ -418,25 +418,46 @@ INSTRUCTION_HANDLER(AssertionDefinesExactlyStrictHash3) { assert(value.first.size() == 3); const auto &object{target.as_object()}; - result = - object.size() == 3 && ((value.first.at(0).first == object.at(0).hash && - value.first.at(1).first == object.at(1).hash && - value.first.at(2).first == object.at(2).hash) || - (value.first.at(0).first == object.at(0).hash && - value.first.at(1).first == object.at(2).hash && - value.first.at(2).first == object.at(1).hash) || - (value.first.at(0).first == object.at(1).hash && - value.first.at(1).first == object.at(0).hash && - value.first.at(2).first == object.at(2).hash) || - (value.first.at(0).first == object.at(1).hash && - value.first.at(1).first == object.at(2).hash && - value.first.at(2).first == object.at(0).hash) || - (value.first.at(0).first == object.at(2).hash && - value.first.at(1).first == object.at(0).hash && - value.first.at(2).first == object.at(1).hash) || - (value.first.at(0).first == object.at(2).hash && - value.first.at(1).first == object.at(1).hash && - value.first.at(2).first == object.at(0).hash)); + // A perfect hash captures the key bytes but not its length, so each + // pairing confirms the size alongside the hash while keeping the unrolled + // permutation check over the three keys + result = object.size() == 3 && + ((value.first.at(0).first == object.at(0).hash && + value.first.at(0).second.size() == object.at(0).first.size() && + value.first.at(1).first == object.at(1).hash && + value.first.at(1).second.size() == object.at(1).first.size() && + value.first.at(2).first == object.at(2).hash && + value.first.at(2).second.size() == object.at(2).first.size()) || + (value.first.at(0).first == object.at(0).hash && + value.first.at(0).second.size() == object.at(0).first.size() && + value.first.at(1).first == object.at(2).hash && + value.first.at(1).second.size() == object.at(2).first.size() && + value.first.at(2).first == object.at(1).hash && + value.first.at(2).second.size() == object.at(1).first.size()) || + (value.first.at(0).first == object.at(1).hash && + value.first.at(0).second.size() == object.at(1).first.size() && + value.first.at(1).first == object.at(0).hash && + value.first.at(1).second.size() == object.at(0).first.size() && + value.first.at(2).first == object.at(2).hash && + value.first.at(2).second.size() == object.at(2).first.size()) || + (value.first.at(0).first == object.at(1).hash && + value.first.at(0).second.size() == object.at(1).first.size() && + value.first.at(1).first == object.at(2).hash && + value.first.at(1).second.size() == object.at(2).first.size() && + value.first.at(2).first == object.at(0).hash && + value.first.at(2).second.size() == object.at(0).first.size()) || + (value.first.at(0).first == object.at(2).hash && + value.first.at(0).second.size() == object.at(2).first.size() && + value.first.at(1).first == object.at(0).hash && + value.first.at(1).second.size() == object.at(0).first.size() && + value.first.at(2).first == object.at(1).hash && + value.first.at(2).second.size() == object.at(1).first.size()) || + (value.first.at(0).first == object.at(2).hash && + value.first.at(0).second.size() == object.at(2).first.size() && + value.first.at(1).first == object.at(1).hash && + value.first.at(1).second.size() == object.at(1).first.size() && + value.first.at(2).first == object.at(0).hash && + value.first.at(2).second.size() == object.at(0).first.size())); } EVALUATE_END(AssertionDefinesExactlyStrictHash3); @@ -1042,9 +1063,13 @@ INSTRUCTION_HANDLER(AssertionObjectPropertiesSimple) { for (std::size_t schema_index = 0; schema_index < schema_size; schema_index++) { const auto &schema_hash{std::get<1>(value[schema_index])}; + // A perfect hash captures the key bytes but not its length, so its size + // is confirmed rather than trusting the hash match alone if (schema_hash == instance_hash && - (property_hasher.is_perfect(instance_hash) || - instance_entry.first == std::get<0>(value[schema_index]))) { + (property_hasher.is_perfect(instance_hash) + ? instance_entry.first.size() == + std::get<0>(value[schema_index]).size() + : instance_entry.first == std::get<0>(value[schema_index]))) { seen |= (static_cast(1) << schema_index); if (schema_index < instruction.children.size()) { const auto &child{instruction.children[schema_index]}; @@ -1189,16 +1214,37 @@ INSTRUCTION_HANDLER(LogicalOr) { // This boolean value controls whether we should be exhaustive if (value) { for (const auto &child : instruction.children) { + // A branch that does not hold contributes nothing, including any + // evaluation it marked before failing. Only a schema that tracks + // evaluation can ever observe those marks + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { result = true; + } else { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } } else { for (const auto &child : instruction.children) { + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { result = true; break; } + + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } @@ -1283,6 +1329,13 @@ INSTRUCTION_HANDLER(LogicalXor) { resolve_instance(instance, instruction.relative_instance_location)}; const auto value{assume_value_copy(instruction.value)}; for (const auto &child : instruction.children) { + // A branch that does not hold contributes nothing, including any + // evaluation it marked before failing + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + if (EVALUATE_RECURSE(child, target)) { if (has_matched) [[unlikely]] { result = false; @@ -1293,6 +1346,10 @@ INSTRUCTION_HANDLER(LogicalXor) { } else { has_matched = true; } + } else { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } } } @@ -1310,27 +1367,46 @@ INSTRUCTION_HANDLER(LogicalCondition) { assert(children_size >= value.second); SOURCEMETA_ASSUME(children_size >= value.second); - auto condition_end{children_size}; - if (value.first > 0) { - condition_end = value.first; - } else if (value.second > 0) { - condition_end = value.second; - } - + // The condition is the leading segment [0, then start). It may be empty, + // for example when a `$ref` condition inlines and its jump is dropped, in + // which case it trivially passes and the then branch applies const auto &target{ resolve_instance(instance, instruction.relative_instance_location)}; - for (std::size_t cursor = 0; cursor < condition_end; cursor++) { + + // A condition that holds does contribute the evaluation it marked, but one + // that does not hold contributes nothing at all + [[maybe_unused]] std::size_t checkpoint{0}; + if constexpr (Track) { + checkpoint = context.evaluator->checkpoint(); + } + + for (std::size_t cursor = 0; cursor < value.first; cursor++) { if (!EVALUATE_RECURSE(instruction.children[cursor], target)) { result = false; break; } } - const auto consequence_start{result ? value.first : value.second}; - const auto consequence_end{(result && value.second > 0) ? value.second - : children_size}; + if (!result) { + if constexpr (Track) { + context.evaluator->rewind(checkpoint); + } + } + + // On a passing condition the then branch runs, otherwise the else branch, + // which is absent when it starts at zero + std::size_t consequence_start; + std::size_t consequence_end; + if (result) { + consequence_start = value.first; + consequence_end = value.second > 0 ? value.second : children_size; + } else { + consequence_start = value.second; + consequence_end = value.second > 0 ? children_size : value.second; + } + result = true; - if (consequence_start > 0) { + if (consequence_start < consequence_end) { if constexpr (Track || HasCallback) { if (track) { context.evaluator->evaluate_path.pop_back( @@ -1735,7 +1811,8 @@ INSTRUCTION_HANDLER(LoopProperties) { INSTRUCTION_HANDLER(LoopPropertiesEvaluate) { EVALUATE_BEGIN_NON_STRING(LoopPropertiesEvaluate, target.is_object()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every property + // trivially matches and only the evaluation marking below takes effect result = true; for (const auto &entry : target.as_object()) { if constexpr (HasCallback) { @@ -2004,7 +2081,11 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrict) { assert(!value.second.empty()); result = true; for (const auto &entry : object) { - if (effective_type_strict_real(entry.second) != value.first) + // The property count already matches the required set, so confirming + // that every property is one of the required names is what makes the + // object exactly the required set, and the value must be of the type + if (!value.second.contains(entry.first, entry.hash) || + effective_type_strict_real(entry.second) != value.first) [[unlikely]] { result = false; break; @@ -2042,7 +2123,10 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrictHash) { EVALUATE_END(LoopPropertiesExactlyTypeStrictHash); } - if (entry.hash != value.second.first[index].first) { + // A hash match alone does not prove the property is required, as two + // distinct names can share a hash, so the name itself is confirmed + if (entry.hash != value.second.first[index].first || + entry.first != value.second.first[index].second) { break; } @@ -2055,10 +2139,16 @@ INSTRUCTION_HANDLER(LoopPropertiesExactlyTypeStrictHash) { // Continue where we left std::advance(iterator, index); for (; iterator != object.cend(); ++iterator) { + if (effective_type_strict_real(iterator->second) != value.first) { + result = false; + break; + } + // NOLINTNEXTLINE(modernize-use-ranges) if (std::ranges::none_of(value.second.first, [&iterator](const auto &entry) -> bool { - return entry.first == iterator->hash; + return entry.first == iterator->hash && + entry.second == iterator->first; })) { result = false; break; @@ -2261,7 +2351,8 @@ INSTRUCTION_HANDLER(LoopItemsFrom) { INSTRUCTION_HANDLER(LoopItemsUnevaluated) { EVALUATE_BEGIN_NON_STRING(LoopItemsUnevaluated, target.is_array()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every item + // trivially matches and only the evaluation marking below takes effect result = true; if (!context.evaluator->is_evaluated(&target)) { @@ -2373,7 +2464,9 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } - // Unroll, for performance reasons, for small collections + // Unroll, for performance reasons, for small collections. A perfect hash + // captures the key bytes but not its length, so every match confirms the + // size alongside the hash if (hashes_size == 3) { for (const auto &entry : object) { if (effective_type_strict_real(entry.second) != value.first) @@ -2381,9 +2474,15 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash != value.second.first[0].first && - entry.hash != value.second.first[1].first && - entry.hash != value.second.first[2].first) { + } else if (!((entry.hash == value.second.first[0].first && + entry.first.size() == + value.second.first[0].second.size()) || + (entry.hash == value.second.first[1].first && + entry.first.size() == + value.second.first[1].second.size()) || + (entry.hash == value.second.first[2].first && + entry.first.size() == + value.second.first[2].second.size()))) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2395,8 +2494,12 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash != value.second.first[0].first && - entry.hash != value.second.first[1].first) { + } else if (!((entry.hash == value.second.first[0].first && + entry.first.size() == + value.second.first[0].second.size()) || + (entry.hash == value.second.first[1].first && + entry.first.size() == + value.second.first[1].second.size()))) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2404,7 +2507,9 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { } else if (hashes_size == 1) { const auto &entry{*object.cbegin()}; if (effective_type_strict_real(entry.second) != value.first || - entry.hash != value.second.first[0].first) [[unlikely]] { + entry.hash != value.second.first[0].first || + entry.first.size() != value.second.first[0].second.size()) + [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } @@ -2416,13 +2521,16 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { [[unlikely]] { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); - } else if (entry.hash == value.second.first[index].first) { + } else if (entry.hash == value.second.first[index].first && + entry.first.size() == + value.second.first[index].second.size()) { index += 1; continue; } else if (!std::ranges::any_of( value.second.first, [&entry](const auto &hash_entry) -> bool { - return hash_entry.first == entry.hash; + return hash_entry.first == entry.hash && + hash_entry.second.size() == entry.first.size(); })) { result = false; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); @@ -2471,24 +2579,45 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash3) { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash3); } + // A perfect hash captures the key bytes but not its length, so each + // pairing confirms the size alongside the hash while keeping the unrolled + // permutation check over the three keys if ((value_1.hash == value.second.first[0].first && + value_1.first.size() == value.second.first[0].second.size() && value_2.hash == value.second.first[1].first && - value_3.hash == value.second.first[2].first) || + value_2.first.size() == value.second.first[1].second.size() && + value_3.hash == value.second.first[2].first && + value_3.first.size() == value.second.first[2].second.size()) || (value_1.hash == value.second.first[0].first && + value_1.first.size() == value.second.first[0].second.size() && value_2.hash == value.second.first[2].first && - value_3.hash == value.second.first[1].first) || + value_2.first.size() == value.second.first[2].second.size() && + value_3.hash == value.second.first[1].first && + value_3.first.size() == value.second.first[1].second.size()) || (value_1.hash == value.second.first[1].first && + value_1.first.size() == value.second.first[1].second.size() && value_2.hash == value.second.first[0].first && - value_3.hash == value.second.first[2].first) || + value_2.first.size() == value.second.first[0].second.size() && + value_3.hash == value.second.first[2].first && + value_3.first.size() == value.second.first[2].second.size()) || (value_1.hash == value.second.first[1].first && + value_1.first.size() == value.second.first[1].second.size() && value_2.hash == value.second.first[2].first && - value_3.hash == value.second.first[0].first) || + value_2.first.size() == value.second.first[2].second.size() && + value_3.hash == value.second.first[0].first && + value_3.first.size() == value.second.first[0].second.size()) || (value_1.hash == value.second.first[2].first && + value_1.first.size() == value.second.first[2].second.size() && value_2.hash == value.second.first[0].first && - value_3.hash == value.second.first[1].first) || + value_2.first.size() == value.second.first[0].second.size() && + value_3.hash == value.second.first[1].first && + value_3.first.size() == value.second.first[1].second.size()) || (value_1.hash == value.second.first[2].first && + value_1.first.size() == value.second.first[2].second.size() && value_2.hash == value.second.first[1].first && - value_3.hash == value.second.first[0].first)) { + value_2.first.size() == value.second.first[1].second.size() && + value_3.hash == value.second.first[0].first && + value_3.first.size() == value.second.first[0].second.size())) { continue; } else { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash3); @@ -2543,8 +2672,11 @@ INSTRUCTION_HANDLER(LoopItemsIntegerBoundedSized) { assume_value(instruction.value)}; const auto &integer_bounds{value.first}; const auto minimum_size{std::get<0>(value.second)}; + const auto &maximum_size{std::get<1>(value.second)}; EVALUATE_BEGIN_NON_STRING(LoopItemsIntegerBoundedSized, true); - if (!target.is_array() || target.array_size() < minimum_size) { + if (!target.is_array() || target.array_size() < minimum_size || + (maximum_size.has_value() && + target.array_size() > maximum_size.value())) { EVALUATE_END(LoopItemsIntegerBoundedSized); } @@ -2584,11 +2716,13 @@ INSTRUCTION_HANDLER(LoopItemsIntegerBoundedSized) { INSTRUCTION_HANDLER(LoopContains) { EVALUATE_BEGIN_NON_STRING(LoopContains, target.is_array()); - assert(!instruction.children.empty()); + // The subschema may compile to no children, in which case every array + // element trivially matches and the range check alone decides the result const auto &value{assume_value(instruction.value)}; const auto &[minimum, maximum, is_exhaustive] = value; - assert(!maximum.has_value() || maximum.value() >= minimum); - result = minimum == 0 && target.empty(); + // Note that the range may be unsatisfiable, as `minContains` and + // `maxContains` are free to invert it, in which case no array matches + result = minimum == 0; auto match_count{ std::numeric_limits>::min()}; diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h index 964d85be..f76ddcab 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_string_set.h @@ -30,8 +30,11 @@ class SOURCEMETA_BLAZE_EVALUATOR_EXPORT StringSet { [[nodiscard]] inline auto contains(const string_type &value, const hash_type hash) const -> bool { if (this->hasher.is_perfect(hash)) { + // A perfect hash captures the key bytes but not its length, so two keys + // that only differ in trailing length hash the same and the size is + // confirmed too for (const auto &entry : this->data) { - if (entry.second == hash) { + if (entry.second == hash && entry.first.size() == value.size()) { return true; } } diff --git a/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h b/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h index 9139299a..0e6d94f5 100644 --- a/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h +++ b/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h @@ -65,11 +65,13 @@ struct SOURCEMETA_BLAZE_FOUNDATION_EXPORT Vocabularies { // https://spec.openapis.org/oas/v3.1.0.html#fixed-fields-19 OpenAPI_3_1_Base = 29, // https://spec.openapis.org/oas/v3.2.0.html#base-vocabulary - OpenAPI_3_2_Base = 30 + OpenAPI_3_2_Base = 30, + // Sourcemeta + Sourcemeta_Extension_V1 = 31 }; // NOTE: Must be kept in sync with the Known enum above - static constexpr std::size_t KNOWN_VOCABULARY_COUNT = 31; + static constexpr std::size_t KNOWN_VOCABULARY_COUNT = 32; /// A vocabulary URI type that can be either a known vocabulary enum or a /// custom string URI diff --git a/vendor/blaze/src/foundation/vocabularies.cc b/vendor/blaze/src/foundation/vocabularies.cc index 081d1d50..65b74530 100644 --- a/vendor/blaze/src/foundation/vocabularies.cc +++ b/vendor/blaze/src/foundation/vocabularies.cc @@ -68,7 +68,9 @@ "http://json-schema.org/draft-00/hyper-schema#") \ /* OpenAPI vocabularies */ \ X(OpenAPI_3_1_Base, "https://spec.openapis.org/oas/3.1/vocab/base") \ - X(OpenAPI_3_2_Base, "https://spec.openapis.org/oas/3.2/vocab/base") + X(OpenAPI_3_2_Base, "https://spec.openapis.org/oas/3.2/vocab/base") \ + /* Sourcemeta vocabularies */ \ + X(Sourcemeta_Extension_V1, "tag:sourcemeta.com,2026:extension/v1") namespace { auto uri_to_known_vocabulary(const std::string_view uri) diff --git a/vendor/blaze/src/frame/frame.cc b/vendor/blaze/src/frame/frame.cc index a2833dd3..38d02b1e 100644 --- a/vendor/blaze/src/frame/frame.cc +++ b/vendor/blaze/src/frame/frame.cc @@ -741,7 +741,8 @@ auto SchemaFrame::analyse(const sourcemeta::core::JSON &root, supports_id_anchors(entry.common.base_dialect.value()) && entry.id.value().starts_with('#'); - if ((!entry.common.subschema.get().defines("$ref") || !ref_overrides) && + if ((!entry.common.subschema.get().is_object() || + !entry.common.subschema.get().defines("$ref") || !ref_overrides) && // If we are dealing with a pre-2019-09 location independent // identifier, we ignore it as a traditional identifier and take // care of it as an anchor diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h index f50e3c53..d441aa7e 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_simple.h @@ -11,13 +11,12 @@ #include +#include // std::size_t +#include // std::uint8_t #include // std::reference_wrapper // TODO(C++23): Consider std::flat_map/std::flat_set when available in libc++ -#include // std::map -#include // std::ostream #include // std::string -#include // std::tie -#include // std::pair +#include // std::move #include // std::vector namespace sourcemeta::blaze { @@ -83,6 +82,14 @@ class SOURCEMETA_BLAZE_OUTPUT_EXPORT SimpleOutput { std::reference_wrapper schema_location; }; + /// A single collected annotation, in evaluation order + struct AnnotationEntry { + sourcemeta::core::WeakPointer instance_location; + sourcemeta::core::WeakPointer evaluate_path; + std::reference_wrapper schema_location; + sourcemeta::core::JSON value; + }; + auto operator()(const EvaluationType type, const bool result, const Instruction &step, const InstructionExtra &step_metadata, @@ -97,12 +104,6 @@ class SOURCEMETA_BLAZE_OUTPUT_EXPORT SimpleOutput { [[nodiscard]] auto cbegin() const -> const_iterator; [[nodiscard]] auto cend() const -> const_iterator; - /// Access annotations that were collected during evaluation, indexed by - /// instance location and evaluation path - [[nodiscard]] auto annotations() const -> const auto & { - return this->annotations_; - } - /// Move out the collected error entries, leaving this output empty. Useful to /// take ownership of the trace without copying when the output is no longer /// needed @@ -114,22 +115,14 @@ class SOURCEMETA_BLAZE_OUTPUT_EXPORT SimpleOutput { return result; } - // NOLINTNEXTLINE(bugprone-exception-escape) - struct Location { - auto operator<(const Location &other) const noexcept -> bool { - // Perform a lexicographical comparison - return std::tie(this->instance_location, this->evaluate_path, - this->schema_location.get()) < - std::tie(other.instance_location, other.evaluate_path, - other.schema_location.get()); - } - - // NOLINTBEGIN(cppcoreguidelines-avoid-const-or-ref-data-members) - const sourcemeta::core::WeakPointer instance_location; - const sourcemeta::core::WeakPointer evaluate_path; - const std::reference_wrapper schema_location; - // NOLINTEND(cppcoreguidelines-avoid-const-or-ref-data-members) - }; + /// Access the annotations collected during evaluation, as a flat log in + /// evaluation order. The log records every emission as-is, so a location may + /// repeat and hold the same value more than once. Consumers that need them + /// grouped by location, or collapsed to distinct values, index this log + /// themselves + [[nodiscard]] auto annotations() const -> const auto & { + return this->annotations_; + } private: // Exporting symbols that depends on the standard C++ library is considered @@ -138,17 +131,28 @@ class SOURCEMETA_BLAZE_OUTPUT_EXPORT SimpleOutput { #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif + /// How much of a branching instruction fails as a unit when one of its + /// subinstructions fails + enum class MaskKind : std::uint8_t { None, Disjunction, Element, Subschema }; + + /// Classify an instruction according to how it absorbs failures + static auto mask_kind(const Instruction &step) noexcept -> MaskKind; + + /// An in-flight branching keyword, along with the number of annotations + /// collected before it started and the error traces it buffers + struct MaskEntry { + sourcemeta::core::WeakPointer evaluate_path; + sourcemeta::core::WeakPointer instance_location; + MaskKind kind; + std::size_t annotations_mark; + std::vector buffered_traces; + }; + const sourcemeta::core::JSON &instance_; const sourcemeta::core::WeakPointer base_; container_type output; - std::vector< - std::pair> - mask; - std::map< - std::pair, - std::vector> - masked_traces; - std::map> annotations_; + std::vector mask; + std::vector annotations_; #if defined(_MSC_VER) #pragma warning(default : 4251) #endif diff --git a/vendor/blaze/src/output/output_jsonld.cc b/vendor/blaze/src/output/output_jsonld.cc index 320a74b2..33f20f00 100644 --- a/vendor/blaze/src/output/output_jsonld.cc +++ b/vendor/blaze/src/output/output_jsonld.cc @@ -9,7 +9,7 @@ #include // std::ranges::sort, std::ranges::any_of #include // std::deque -#include // std::ref +#include // std::ref, std::reference_wrapper #include // std::optional #include // std::ostringstream #include // std::string @@ -38,56 +38,6 @@ struct Facts { using Accumulator = std::unordered_map; -auto fill_collection(sourcemeta::core::JSONLDWeakAnnotationMap &map, - const Accumulator &accumulator, - const sourcemeta::core::WeakPointer &pointer, - const sourcemeta::core::JSON &value) -> void; - -// Give an undescribed collection member a default kind so it still -// materializes, a scalar as a literal and a nested array as an inner collection -auto fill_element(sourcemeta::core::JSONLDWeakAnnotationMap &map, - const Accumulator &accumulator, - sourcemeta::core::WeakPointer element_pointer, - const sourcemeta::core::JSON &element) -> void { - if (accumulator.contains(element_pointer)) { - return; - } - - if (element.is_array()) { - map.emplace( - element_pointer, - sourcemeta::core::JSONLDDescriptor{ - .edges = {}, .value = sourcemeta::core::JSONLDCollection{}}); - fill_collection(map, accumulator, element_pointer, element); - } else if (!element.is_object()) { - map.emplace(std::move(element_pointer), - sourcemeta::core::JSONLDDescriptor{ - .edges = {}, .value = sourcemeta::core::JSONLDLiteral{}}); - } -} - -// Default the undescribed members of a collection, whether it ranges over an -// array or over an object -auto fill_collection(sourcemeta::core::JSONLDWeakAnnotationMap &map, - const Accumulator &accumulator, - const sourcemeta::core::WeakPointer &pointer, - const sourcemeta::core::JSON &value) -> void { - if (value.is_array()) { - for (std::size_t index = 0; index < value.size(); index += 1) { - auto element_pointer{pointer}; - element_pointer.push_back(index); - fill_element(map, accumulator, std::move(element_pointer), - value.at(index)); - } - } else { - for (const auto &entry : value.as_object()) { - auto element_pointer{pointer}; - element_pointer.push_back(std::cref(entry.first)); - fill_element(map, accumulator, std::move(element_pointer), entry.second); - } - } -} - auto add_edge(std::vector &edges, const sourcemeta::core::JSON::String &predicate, const bool reverse) -> void { @@ -340,7 +290,8 @@ auto expand_self(const sourcemeta::core::WeakPointer &pointer, } if (bound->is_string()) { - if (bound->to_string().empty()) { + const auto &text{bound->to_string()}; + if (text.empty()) { if (!failure.has_value()) { failure = facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, @@ -351,8 +302,7 @@ auto expand_self(const sourcemeta::core::WeakPointer &pointer, return std::nullopt; } - return std::make_tuple(std::string_view{bound->to_string()}, - std::nullopt, false); + return std::make_tuple(std::string_view{text}, std::nullopt, false); } // A number or boolean has no direct string, so it binds through its @@ -400,180 +350,212 @@ auto array_of_nodes(const Accumulator &accumulator, return true; } -// Turn the JSON-LD annotations into a resolved map, or the first error found. +// The x-jsonld-* keyword names, pre-hashed for the first-pass dispatch +using namespace std::string_view_literals; +const auto HASH_ID{sourcemeta::core::JSON::Object::hash("x-jsonld-id"sv)}; +const auto HASH_REVERSE{ + sourcemeta::core::JSON::Object::hash("x-jsonld-reverse"sv)}; +const auto HASH_TYPE{sourcemeta::core::JSON::Object::hash("x-jsonld-type"sv)}; +const auto HASH_DATATYPE{ + sourcemeta::core::JSON::Object::hash("x-jsonld-datatype"sv)}; +const auto HASH_LANGUAGE{ + sourcemeta::core::JSON::Object::hash("x-jsonld-language"sv)}; +const auto HASH_DIRECTION{ + sourcemeta::core::JSON::Object::hash("x-jsonld-direction"sv)}; +const auto HASH_JSON{sourcemeta::core::JSON::Object::hash("x-jsonld-json"sv)}; +const auto HASH_GRAPH{sourcemeta::core::JSON::Object::hash("x-jsonld-graph"sv)}; +const auto HASH_CONTAINER{ + sourcemeta::core::JSON::Object::hash("x-jsonld-container"sv)}; +const auto HASH_SELF{sourcemeta::core::JSON::Object::hash("x-jsonld-self"sv)}; + +// Turn the JSON-LD annotations into a resolved list, or the first error found. // The first pass groups the facts by location, the second derives each kind // from the value shape and validates the facts against it auto resolve(const sourcemeta::core::JSON &instance, const sourcemeta::blaze::SimpleOutput &output) - -> std::variant std::variant { Accumulator accumulator; + std::vector> + language_containers; - for (const auto &[location, values] : output.annotations()) { - if (location.evaluate_path.empty()) { + for (const auto &entry : output.annotations()) { + if (entry.evaluate_path.empty()) { continue; } - const auto &keyword{location.evaluate_path.back().to_property()}; - const auto &instance_location{location.instance_location}; + const auto &keyword{entry.evaluate_path.back()}; + const auto &instance_location{entry.instance_location}; + const auto &value{entry.value}; - if (keyword == "x-jsonld-id" || keyword == "x-jsonld-reverse") { - const bool reverse{keyword == "x-jsonld-reverse"}; - for (const auto &value : values) { - if (!is_iri_value(value)) { - 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"); - } - - add_edge(accumulator[instance_location].edges, value.to_string(), - reverse); + if (keyword.property_equals("x-jsonld-id", HASH_ID) || + keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { + const bool reverse{ + keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)}; + if (!is_iri_value(value)) { + 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"); } - } else if (keyword == "x-jsonld-type") { - auto &types{accumulator[instance_location].types}; - for (const auto &value : values) { - if (value.is_array()) { - for (const auto &element : value.as_array()) { - if (!is_iri_value(element)) { - return type_iri_error(instance_location); - } - add_type(types, element.to_string()); - } - } else { - if (!is_iri_value(value)) { + add_edge(accumulator[instance_location].edges, value.to_string(), + reverse); + } else if (keyword.property_equals("x-jsonld-type", HASH_TYPE)) { + auto &types{accumulator[instance_location].types}; + if (value.is_array()) { + for (const auto &element : value.as_array()) { + if (!is_iri_value(element)) { return type_iri_error(instance_location); } - add_type(types, value.to_string()); + add_type(types, element.to_string()); } - } - } else if (keyword == "x-jsonld-datatype") { - for (const auto &value : values) { + } else { if (!is_iri_value(value)) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, - "The value of x-jsonld-datatype must be an absolute IRI"); + return type_iri_error(instance_location); } - auto &datatype{accumulator[instance_location].datatype}; - if (datatype.has_value() && datatype.value() != value.to_string()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, - "A JSON-LD datatype cannot be assigned more than one value"); - } + add_type(types, value.to_string()); + } + } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { + auto &datatype{accumulator[instance_location].datatype}; + if (!is_iri_value(value)) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, + "The value of x-jsonld-datatype must be an absolute IRI"); + } - datatype = value.to_string(); + const auto &text{value.to_string()}; + if (datatype.has_value() && datatype.value() != text) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, + "A JSON-LD datatype cannot be assigned more than one value"); } - } else if (keyword == "x-jsonld-language") { - for (const auto &value : values) { - if (!value.is_string() || - !sourcemeta::core::is_langtag(value.to_string())) { + + datatype = text; + } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { + auto &language{accumulator[instance_location].language}; + if (!value.is_string() || + !sourcemeta::core::is_langtag(value.to_string())) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Language, + "The value of x-jsonld-language must be a BCP 47 language tag"); + } + + const auto &text{value.to_string()}; + if (language.has_value()) { + // Language tags compare case-insensitively, so the first spelling is + // kept and only a genuinely different tag is a conflict + if (!sourcemeta::core::equals_ignore_case(language.value(), text)) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Language, - "The value of x-jsonld-language must be a BCP 47 language tag"); + "A JSON-LD language cannot be assigned more than one value"); } + } else { + language = text; + } + } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { + auto &direction{accumulator[instance_location].direction}; + const auto parsed{parse_direction(value)}; + if (!parsed.has_value()) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Direction, + R"(The value of x-jsonld-direction must be "ltr" or "rtl")"); + } - auto &language{accumulator[instance_location].language}; - if (language.has_value()) { - // Language tags compare case-insensitively, so the first spelling is - // kept and only a genuinely different tag is a conflict - if (!sourcemeta::core::equals_ignore_case(language.value(), - value.to_string())) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Language, - "A JSON-LD language cannot be assigned more than one value"); - } - } else { - language = value.to_string(); - } + if (direction.has_value() && direction.value() != parsed.value()) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Direction, + "A JSON-LD direction cannot be assigned more than one value"); } - } else if (keyword == "x-jsonld-direction") { - for (const auto &value : values) { - const auto parsed{parse_direction(value)}; - if (!parsed.has_value()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Direction, - R"(The value of x-jsonld-direction must be "ltr" or "rtl")"); - } - auto &direction{accumulator[instance_location].direction}; - if (direction.has_value() && direction.value() != parsed.value()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Direction, - "A JSON-LD direction cannot be assigned more than one value"); - } + direction = parsed; + } else if (keyword.property_equals("x-jsonld-json", HASH_JSON)) { + if (!value.is_boolean()) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::JSON, + "The value of x-jsonld-json must be a boolean"); + } - direction = parsed; + // A false value leaves the fact unset, so it must stay a no-op and not + // create an entry + if (value.to_boolean()) { + accumulator[instance_location].json = true; + } + } else if (keyword.property_equals("x-jsonld-graph", HASH_GRAPH)) { + if (!value.is_boolean()) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::Graph, + "The value of x-jsonld-graph must be a boolean"); } - } else if (keyword == "x-jsonld-json") { - for (const auto &value : values) { - if (!value.is_boolean()) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::JSON, - "The value of x-jsonld-json must be a boolean"); - } - // A false value leaves the fact unset, so it must stay a no-op and not - // create an entry - if (value.to_boolean()) { - accumulator[instance_location].json = true; - } + if (value.to_boolean()) { + accumulator[instance_location].graph = true; + } + } else if (keyword.property_equals("x-jsonld-container", HASH_CONTAINER)) { + auto &container{accumulator[instance_location].container}; + const auto parsed{parse_container(value)}; + if (!parsed.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")"); } - } else if (keyword == "x-jsonld-graph") { - for (const auto &value : values) { - if (!value.is_boolean()) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::Graph, - "The value of x-jsonld-graph must be a boolean"); - } - if (value.to_boolean()) { - accumulator[instance_location].graph = true; - } + if (container.has_value() && container.value() != parsed.value()) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD container cannot be assigned more than one value"); } - } else if (keyword == "x-jsonld-container") { - for (const auto &value : values) { - const auto parsed{parse_container(value)}; - if (!parsed.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")"); - } - auto &container{accumulator[instance_location].container}; - if (container.has_value() && container.value() != parsed.value()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD container cannot be assigned more than one value"); - } + container = parsed; - container = parsed; + if (container == sourcemeta::core::JSONLDContainer::Language) { + language_containers.push_back(std::cref(instance_location)); + } + } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { + auto &self{accumulator[instance_location].self}; + if (!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"); } - } else if (keyword == "x-jsonld-self") { - for (const auto &value : values) { - if (!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"); - } - auto &self{accumulator[instance_location].self}; - if (self.has_value() && self.value() != value.to_string()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity cannot be assigned more than one value"); - } + const auto &text{value.to_string()}; + if (self.has_value() && self.value() != text) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity cannot be assigned more than one value"); + } + + self = text; + } + } + + // A language container materializes its members directly as language-tagged + // strings, never consulting their descriptors, so a member cannot carry a + // JSON-LD annotation of its own + for (const auto container_location : language_containers) { + const auto &members{ + sourcemeta::core::get(instance, container_location.get())}; + if (!members.is_object()) { + continue; + } - self = value.to_string(); + for (const auto &entry : members.as_object()) { + auto member{container_location.get()}; + 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"); } } } - sourcemeta::core::JSONLDWeakAnnotationMap map; - map.reserve(accumulator.size()); + sourcemeta::core::JSONLDWeakAnnotationList annotations; + annotations.reserve(accumulator.size()); for (auto &[pointer, facts] : accumulator) { std::ranges::sort(facts.edges, [](const sourcemeta::core::JSONLDEdge &left, @@ -585,22 +567,6 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &value{sourcemeta::core::get(instance, pointer)}; - // A language container materializes its members directly as language-tagged - // strings, never consulting their descriptors, so a member cannot carry a - // JSON-LD annotation of its own - if (!pointer.empty()) { - auto parent{pointer}; - parent.pop_back(); - const auto parent_facts{accumulator.find(parent)}; - if (parent_facts != accumulator.cend() && - parent_facts->second.container == - sourcemeta::core::JSONLDContainer::Language) { - return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD language container member cannot carry a " - "JSON-LD annotation"); - } - } - // 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()) { @@ -738,21 +704,11 @@ auto resolve(const sourcemeta::core::JSON &instance, .direction = facts.direction}; } - map.emplace(pointer, std::move(descriptor)); - - // A language container reads its object members directly, but every other - // collection needs its undescribed members defaulted so they materialize - if (facts.container.has_value()) { - if (facts.container.value() != - sourcemeta::core::JSONLDContainer::Language) { - fill_collection(map, accumulator, pointer, value); - } - } else if (value.is_array() && !facts.json) { - fill_collection(map, accumulator, pointer, value); - } + annotations.push_back( + {.pointer = pointer, .descriptor = std::move(descriptor)}); } - return map; + return annotations; } } // namespace @@ -772,9 +728,9 @@ auto jsonld(Evaluator &evaluator, const Template &schema, return std::get(std::move(resolved)); } - const auto &map{ - std::get(resolved)}; - return sourcemeta::core::jsonld_materialize(instance, map); + const auto &annotations{ + std::get(resolved)}; + return sourcemeta::core::jsonld_materialize(instance, annotations); } } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/output/output_simple.cc b/vendor/blaze/src/output/output_simple.cc index bc0f0b93..30e51c2e 100644 --- a/vendor/blaze/src/output/output_simple.cc +++ b/vendor/blaze/src/output/output_simple.cc @@ -2,9 +2,10 @@ #include -#include // std::any_of, std::sort +#include // std::min, std::remove_if #include // assert -#include // std::back_inserter, std::make_move_iterator +#include // std::make_move_iterator +#include // std::views::reverse #include // std::move namespace sourcemeta::blaze { @@ -13,6 +14,22 @@ SimpleOutput::SimpleOutput(const sourcemeta::core::JSON &instance, sourcemeta::core::WeakPointer base) : instance_{instance}, base_{std::move(base)} {} +auto SimpleOutput::mask_kind(const Instruction &step) noexcept -> MaskKind { + switch (step.type) { + case InstructionIndex::LogicalOr: + case InstructionIndex::LogicalXor: + return MaskKind::Disjunction; + case InstructionIndex::LoopContains: + return MaskKind::Element; + case InstructionIndex::LogicalCondition: + case InstructionIndex::LogicalNot: + case InstructionIndex::LogicalNotEvaluate: + return MaskKind::Subschema; + default: + return MaskKind::None; + } +} + auto SimpleOutput::begin() const -> const_iterator { return this->output.begin(); } @@ -37,22 +54,21 @@ auto SimpleOutput::operator()( return; } - assert(evaluate_path.back().is_property()); - // Fast path: passing non-annotation instructions that are not // closing a mask entry can be skipped entirely if (result && !is_annotation(step.type)) { if (type == EvaluationType::Pre) { - const auto &keyword{evaluate_path.back().to_property()}; - if (keyword == "anyOf" || keyword == "oneOf" || keyword == "not" || - keyword == "if" || keyword == "contains") { - this->mask.emplace_back(evaluate_path, instance_location); + const auto kind{mask_kind(step)}; + if (kind != MaskKind::None) { + this->mask.push_back({.evaluate_path = evaluate_path, + .instance_location = instance_location, + .kind = kind, + .annotations_mark = this->annotations_.size(), + .buffered_traces = {}}); } } else if (type == EvaluationType::Post && !this->mask.empty() && - this->mask.back().first == evaluate_path && - this->mask.back().second == instance_location) { - const auto mask_key{std::make_pair(evaluate_path, instance_location)}; - this->masked_traces.erase(mask_key); + this->mask.back().evaluate_path == evaluate_path && + this->mask.back().instance_location == instance_location) { this->mask.pop_back(); } @@ -66,51 +82,44 @@ auto SimpleOutput::operator()( if (is_annotation(step.type)) { if (type == EvaluationType::Post) { - Location location{.instance_location = instance_location, - .evaluate_path = std::move(effective_evaluate_path), - .schema_location = step_metadata.keyword_location}; - const auto match{this->annotations_.find(location)}; - if (match == this->annotations_.cend()) { - this->annotations_[std::move(location)].push_back(annotation); - - // To avoid emitting the exact same annotation more than once - // This is right now mostly because of `unevaluatedItems` - } else if (match->second.back() != annotation) { - match->second.push_back(annotation); - } + this->annotations_.push_back( + {.instance_location = instance_location, + .evaluate_path = std::move(effective_evaluate_path), + .schema_location = step_metadata.keyword_location, + .value = annotation}); } return; } - const auto &keyword{evaluate_path.back().to_property()}; - if (type == EvaluationType::Pre) { assert(result); // To ease the output - if (keyword == "anyOf" || keyword == "oneOf" || keyword == "not" || - keyword == "if" || keyword == "contains") { - this->mask.emplace_back(evaluate_path, instance_location); + const auto kind{mask_kind(step)}; + if (kind != MaskKind::None) { + this->mask.push_back({.evaluate_path = evaluate_path, + .instance_location = instance_location, + .kind = kind, + .annotations_mark = this->annotations_.size(), + .buffered_traces = {}}); } } else if (type == EvaluationType::Post) { - const auto mask_key{std::make_pair(evaluate_path, instance_location)}; - const auto mask_it{std::ranges::find(this->mask, mask_key)}; + const auto mask_it{std::ranges::find_if( + this->mask, [&](const MaskEntry &mask_entry) -> bool { + return mask_entry.evaluate_path == evaluate_path && + mask_entry.instance_location == instance_location; + })}; if (mask_it != this->mask.end()) { // Present unexpected traces only when needed - if (!result && keyword != "not" && keyword != "if") { - auto buffered{this->masked_traces.find(mask_key)}; - if (buffered != this->masked_traces.end()) { + if (!result && mask_it->kind != MaskKind::Subschema) { #ifdef __cpp_lib_containers_ranges - this->output.append_range(std::move(buffered->second)); + this->output.append_range(std::move(mask_it->buffered_traces)); #else - this->output.insert(this->output.end(), - std::make_move_iterator(buffered->second.begin()), - std::make_move_iterator(buffered->second.end())); + this->output.insert( + this->output.end(), + std::make_move_iterator(mask_it->buffered_traces.begin()), + std::make_move_iterator(mask_it->buffered_traces.end())); #endif - this->masked_traces.erase(buffered); - } - } else { - this->masked_traces.erase(mask_key); } this->mask.erase(mask_it); @@ -122,23 +131,70 @@ auto SimpleOutput::operator()( } if (type == EvaluationType::Post && !this->annotations_.empty()) { - for (auto iterator = this->annotations_.begin(); - iterator != this->annotations_.end();) { - if (iterator->first.evaluate_path.starts_with_initial(evaluate_path) && - iterator->first.instance_location == instance_location) { - iterator = this->annotations_.erase(iterator); - } else { - ++iterator; + // A failure inside a branching keyword discards every annotation that + // the failing unit collected, including the ones at instance locations + // below the unit's own instance location. The unit is the disjunction + // branch for `anyOf` and `oneOf`, the application of the subschema to + // the array element for `contains`, and the entire subschema for `if` + // and negations. A failure outside of every branching keyword cannot + // be absorbed, so the overall result is bound to be false, in which + // case no annotations may be reported at all + const MaskEntry *unit{nullptr}; + for (const auto &mask_entry : std::views::reverse(this->mask)) { + if (evaluate_path.starts_with(mask_entry.evaluate_path) && + instance_location.starts_with(mask_entry.instance_location)) { + unit = &mask_entry; + break; } } + + if (unit) { + auto evaluate_prefix_size{unit->evaluate_path.size()}; + auto instance_prefix_size{unit->instance_location.size()}; + switch (unit->kind) { + case MaskKind::Disjunction: + evaluate_prefix_size = + std::min(evaluate_prefix_size + 1, evaluate_path.size()); + break; + case MaskKind::Element: + instance_prefix_size = + std::min(instance_prefix_size + 1, instance_location.size()); + break; + default: + break; + } + + // Every annotation the failing unit collected, including the ones + // that the equality check below would match, sits at or beyond the + // mark the unit's branching keyword recorded, so older entries do + // not need to be scanned at all + assert(unit->annotations_mark <= this->annotations_.size()); + const auto from{this->annotations_.begin() + + static_cast(unit->annotations_mark)}; + this->annotations_.erase( + std::remove_if( + from, this->annotations_.end(), + [&](const AnnotationEntry &entry) -> bool { + return (entry.evaluate_path.starts_with_initial( + evaluate_path) && + entry.instance_location == instance_location) || + (entry.evaluate_path.shares_prefix( + evaluate_path, evaluate_prefix_size) && + entry.instance_location.shares_prefix( + instance_location, instance_prefix_size)); + }), + this->annotations_.end()); + } else { + this->annotations_.clear(); + } } - if (keyword == "if") { + if (step.type == InstructionIndex::LogicalCondition) { return; } else { - for (const auto &mask_entry : this->mask) { - if (evaluate_path.starts_with(mask_entry.first)) { - this->masked_traces[mask_entry].push_back( + for (auto &mask_entry : this->mask) { + if (evaluate_path.starts_with(mask_entry.evaluate_path)) { + mask_entry.buffered_traces.push_back( {.message = describe(result, step, evaluate_path, instance_location, this->instance_, annotation), .instance_location = instance_location, diff --git a/vendor/blaze/src/output/output_standard.cc b/vendor/blaze/src/output/output_standard.cc index c932c351..a27751ef 100644 --- a/vendor/blaze/src/output/output_standard.cc +++ b/vendor/blaze/src/output/output_standard.cc @@ -1,15 +1,49 @@ #include #include +#include #include #include // assert -#include // std::ref +#include // std::ref, std::reference_wrapper +#include // std::map +#include // std::string +#include // std::tie +#include // std::vector namespace sourcemeta::blaze { namespace { +// NOLINTNEXTLINE(bugprone-exception-escape) +struct AnnotationLocation { + auto operator<(const AnnotationLocation &other) const noexcept -> bool { + return std::tie(this->instance_location, this->evaluate_path, + this->schema_location.get()) < + std::tie(other.instance_location, other.evaluate_path, + other.schema_location.get()); + } + + sourcemeta::core::WeakPointer instance_location; + sourcemeta::core::WeakPointer evaluate_path; + std::reference_wrapper schema_location; +}; + +auto group_annotations(const SimpleOutput &output) + -> std::map> { + std::map> result; + for (const auto &entry : output.annotations()) { + auto &values{result[{.instance_location = entry.instance_location, + .evaluate_path = entry.evaluate_path, + .schema_location = entry.schema_location}]}; + if (values.empty() || values.back() != entry.value) { + values.push_back(entry.value); + } + } + + return result; +} + auto handle_standard(Evaluator &evaluator, const Template &schema, const sourcemeta::core::JSON &instance, const StandardOutput format, @@ -30,7 +64,7 @@ auto handle_standard(Evaluator &evaluator, const Template &schema, auto result{sourcemeta::core::JSON::make_object()}; result.assign_assume_new("valid", sourcemeta::core::JSON{valid}); auto annotations{sourcemeta::core::JSON::make_array()}; - for (const auto &annotation : output.annotations()) { + for (const auto &annotation : group_annotations(output)) { auto unit{sourcemeta::core::JSON::make_object()}; unit.assign_assume_new( "keywordLocation", diff --git a/vendor/core/CMakeLists.txt b/vendor/core/CMakeLists.txt index 928273ed..1be12a1f 100644 --- a/vendor/core/CMakeLists.txt +++ b/vendor/core/CMakeLists.txt @@ -28,6 +28,7 @@ option(SOURCEMETA_CORE_URI "Build the Sourcemeta Core URI library" ON) option(SOURCEMETA_CORE_URITEMPLATE "Build the Sourcemeta Core URI Template library" ON) option(SOURCEMETA_CORE_JSON "Build the Sourcemeta Core JSON library" ON) option(SOURCEMETA_CORE_JSONPOINTER "Build the Sourcemeta Core JSON Pointer library" ON) +option(SOURCEMETA_CORE_JSONPATH "Build the Sourcemeta Core JSON Path library" ON) option(SOURCEMETA_CORE_JSONLD "Build the Sourcemeta Core JSON-LD library" ON) option(SOURCEMETA_CORE_JSONL "Build the Sourcemeta Core JSONL library" ON) option(SOURCEMETA_CORE_YAML "Build the Sourcemeta Core YAML library" ON) @@ -36,6 +37,7 @@ option(SOURCEMETA_CORE_MCP "Build the Sourcemeta Core MCP library" ON) option(SOURCEMETA_CORE_HTTP "Build the Sourcemeta Core HTTP library" ON) option(SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL "Use system cURL for the Sourcemeta Core HTTP library" OFF) option(SOURCEMETA_CORE_JOSE "Build the Sourcemeta Core JOSE library" ON) +option(SOURCEMETA_CORE_OAUTH "Build the Sourcemeta Core OAuth library" ON) option(SOURCEMETA_CORE_SEMVER "Build the Sourcemeta Core SemVer library" ON) option(SOURCEMETA_CORE_GZIP "Build the Sourcemeta Core GZIP library" ON) option(SOURCEMETA_CORE_HTML "Build the Sourcemeta Core HTML library" ON) @@ -183,6 +185,10 @@ if(SOURCEMETA_CORE_JSONPOINTER) add_subdirectory(src/core/jsonpointer) endif() +if(SOURCEMETA_CORE_JSONPATH) + add_subdirectory(src/core/jsonpath) +endif() + if(SOURCEMETA_CORE_JSONLD) add_subdirectory(src/core/jsonld) endif() @@ -216,6 +222,10 @@ if(SOURCEMETA_CORE_JOSE) add_subdirectory(src/core/jose) endif() +if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(src/core/oauth) +endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(src/core/semver) endif() @@ -348,6 +358,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/jsonpointer) endif() + if(SOURCEMETA_CORE_JSONPATH) + add_subdirectory(test/jsonpath) + endif() + if(SOURCEMETA_CORE_JSONLD) add_subdirectory(test/jsonld) endif() @@ -380,6 +394,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/jose) endif() + if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(test/oauth) + endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(test/semver) endif() diff --git a/vendor/core/DEPENDENCIES b/vendor/core/DEPENDENCIES index 29ef0577..fff17ebb 100644 --- a/vendor/core/DEPENDENCIES +++ b/vendor/core/DEPENDENCIES @@ -1,4 +1,4 @@ -vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 +vendorpull https://github.com/sourcemeta/vendorpull 89f348a97842e05aeab45d338d41fb02031fad62 jsontestsuite https://github.com/nst/JSONTestSuite d64aefb55228d9584d3e5b2433f720ea8fd00c82 yaml-test-suite https://github.com/yaml/yaml-test-suite data-2022-01-17 cmark-gfm https://github.com/github/cmark-gfm 587a12bb54d95ac37241377e6ddc93ea0e45439b @@ -12,3 +12,16 @@ unicodetools https://github.com/unicode-org/unicodetools final-17.0-20250910 jose-cookbook https://github.com/ietf-jose/cookbook 13692b68bfc18b99557a5b1ed311fd5077bfff04 w3c-json-ld https://github.com/w3c/json-ld-api 8654ac22b6cf4f441d2fee915ae634d36b5a8067 aws-sig-v4-test-suite https://github.com/saibotsivad/aws-sig-v4-test-suite 38179d1173c9547a964e7f92e9308e73795534ff +jsonpath-compliance-test-suite https://github.com/jsonpath-standard/jsonpath-compliance-test-suite 7be7c1fc28057c91e8eefaf197060fba7ed43acd +iana-oauth/parameters.csv https://www.iana.org/assignments/oauth-parameters/parameters.csv acfe19a091a15279587e761a399704e886447eec906b19a41528cb37dc2afc35 +iana-oauth/extensions-error.csv https://www.iana.org/assignments/oauth-parameters/extensions-error.csv b49d3e1c9904667551170d12d0943ae9613751f42581f6438a4714c774d64838 +iana-oauth/token-types.csv https://www.iana.org/assignments/oauth-parameters/token-types.csv 0f62163952af053013bf5ee5158c4a09dee4ca50b0fff8e5abff87a1d9f2d163 +iana-oauth/token-type-hint.csv https://www.iana.org/assignments/oauth-parameters/token-type-hint.csv a078e9cbb7ecd64b4920bf4d163e966738594c46243823f8c39d08e47e1a03c0 +iana-oauth/token-endpoint-auth-method.csv https://www.iana.org/assignments/oauth-parameters/token-endpoint-auth-method.csv a0b93b5ac25717f45d66f3937bdcd8214f56b3c0a1b9f6a8728f0a148333a275 +iana-oauth/pkce-code-challenge-method.csv https://www.iana.org/assignments/oauth-parameters/pkce-code-challenge-method.csv e6b6f9ce315ee6fda3678be6b9f46d8d888fcb0b968f94b7731e30449c7b85e1 +iana-oauth/authorization-server-metadata.csv https://www.iana.org/assignments/oauth-parameters/authorization-server-metadata.csv ea71c8f3564e78621a9cb45c404b910190ce5f54faaf6066db1cb2168283ce43 +iana-oauth/protected-resource-metadata.csv https://www.iana.org/assignments/oauth-parameters/protected-resource-metadata.csv 2601894c565a3443a4d39c8f120e207cfeebe596ddd5410d968911e3b5d3b28a +iana-oauth/client-metadata.csv https://www.iana.org/assignments/oauth-parameters/client-metadata.csv f885dc450ce4802ff2c81875b637c3fb5a4c8b8e26f088169e57598a4eeede96 +iana-oauth/uri.csv https://www.iana.org/assignments/oauth-parameters/uri.csv dca1a780e342845d2f3b0fe2391f12faf27217f0234933c1a399e27b55dcadec +iana-oauth/token-introspection-response.csv https://www.iana.org/assignments/oauth-parameters/token-introspection-response.csv d1a3e84f98853b72f51ed59e99d796bf20c415a8aca13e04373c0a25bbb9b1f4 +iana-oauth/endpoint.csv https://www.iana.org/assignments/oauth-parameters/endpoint.csv 1b5d125a9cfa06ac62286e322449cf369647069a6336483f372fbd1d84ad5aca diff --git a/vendor/core/config.cmake.in b/vendor/core/config.cmake.in index 4bcd0ab1..fc7845b1 100644 --- a/vendor/core/config.cmake.in +++ b/vendor/core/config.cmake.in @@ -24,12 +24,14 @@ if(NOT SOURCEMETA_CORE_COMPONENTS) list(APPEND SOURCEMETA_CORE_COMPONENTS json) list(APPEND SOURCEMETA_CORE_COMPONENTS jsonl) list(APPEND SOURCEMETA_CORE_COMPONENTS jsonpointer) + list(APPEND SOURCEMETA_CORE_COMPONENTS jsonpath) list(APPEND SOURCEMETA_CORE_COMPONENTS jsonld) list(APPEND SOURCEMETA_CORE_COMPONENTS yaml) list(APPEND SOURCEMETA_CORE_COMPONENTS jsonrpc) list(APPEND SOURCEMETA_CORE_COMPONENTS mcp) list(APPEND SOURCEMETA_CORE_COMPONENTS http) list(APPEND SOURCEMETA_CORE_COMPONENTS jose) + list(APPEND SOURCEMETA_CORE_COMPONENTS oauth) list(APPEND SOURCEMETA_CORE_COMPONENTS semver) list(APPEND SOURCEMETA_CORE_COMPONENTS gzip) list(APPEND SOURCEMETA_CORE_COMPONENTS html) @@ -145,6 +147,19 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jsonpointer.cmake") + elseif(component STREQUAL "jsonpath") + find_dependency(PCRE2 CONFIG) + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_regex.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_uri.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_numeric.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_unicode.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jsonpointer.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jsonpath.cmake") elseif(component STREQUAL "jsonld") find_dependency(PCRE2 CONFIG) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") @@ -214,6 +229,26 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + elseif(component STREQUAL "oauth") + if(@SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL@) + find_dependency(OpenSSL 3.0) + endif() + if(@SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL@) + find_dependency(CURL) + endif() + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_numeric.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_unicode.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_time.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_uri.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_http.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_oauth.cmake") elseif(component STREQUAL "semver") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") diff --git a/vendor/core/src/core/crypto/CMakeLists.txt b/vendor/core/src/core/crypto/CMakeLists.txt index 338f485e..074e6510 100644 --- a/vendor/core/src/core/crypto/CMakeLists.txt +++ b/vendor/core/src/core/crypto/CMakeLists.txt @@ -2,7 +2,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME crypto PRIVATE_HEADERS sha256.h hmac_sha256.h hmac_sha384.h hmac_sha512.h sha384.h sha512.h sha1.h fnv128.h - uuid.h crc32.h base64.h verify.h sign.h aes_gcm.h + uuid.h crc32.h base64.h secure.h verify.h sign.h aes_gcm.h SOURCES crypto_sha256.cc crypto_hmac_sha256.cc crypto_hmac_sha384.cc crypto_hmac_sha512.cc crypto_sha384.cc @@ -22,7 +22,8 @@ if(SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL) crypto_sha384_openssl.cc crypto_sha512_openssl.cc crypto_random_openssl.cc crypto_verify_openssl.cc crypto_secure_equals_openssl.cc - crypto_sign_openssl.cc crypto_aes_gcm_openssl.cc) + crypto_sign_openssl.cc crypto_aes_gcm_openssl.cc + crypto_openssl.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE OpenSSL::Crypto) elseif(APPLE) enable_language(OBJCXX) @@ -113,8 +114,8 @@ elseif(APPLE) crypto_verify_apple.cc crypto_secure_equals_apple.cc crypto_sign_apple.cc crypto_aes_gcm_apple.cc crypto_eddsa_cryptokit.mm "${CRYPTOKIT_OBJECT}" - crypto_eddsa.h crypto_eddsa_apple.h crypto_aes_apple.h crypto_bignum.h - crypto_shake256.h crypto_pkcs8.h) + crypto_eddsa.h crypto_eddsa_apple.h crypto_apple.h crypto_aes_apple.h + crypto_bignum.h crypto_shake256.h crypto_pkcs8.h) # The generated Objective-C interface header lives in the build tree target_include_directories(sourcemeta_core_crypto @@ -142,6 +143,7 @@ elseif(WIN32) crypto_sha512_windows.cc crypto_random_windows.cc crypto_verify_windows.cc crypto_secure_equals_generic.cc crypto_sign_windows.cc crypto_aes_gcm_windows.cc crypto_eddsa.h + crypto_windows.h crypto_bignum.h crypto_ecc.h crypto_shake256.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE bcrypt) else() diff --git a/vendor/core/src/core/crypto/crypto_apple.h b/vendor/core/src/core/crypto/crypto_apple.h new file mode 100644 index 00000000..6b54644c --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_apple.h @@ -0,0 +1,39 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_APPLE_H_ +#define SOURCEMETA_CORE_CRYPTO_APPLE_H_ + +// Security-framework key export helpers shared by the Apple signing and +// verification backends, so the external representation handling stays +// single-sourced + +#include // CF*, kCF* +#include // Sec*, kSec* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string + +namespace sourcemeta::core { + +// Copy the platform's external representation of a key, the PKCS#1 structure +// for RSA and the X9.63 uncompressed point for elliptic curve keys +inline auto copy_external_representation(SecKeyRef key) + -> std::optional { + CFErrorRef error{nullptr}; + auto data{SecKeyCopyExternalRepresentation(key, &error)}; + if (data == nullptr) { + if (error != nullptr) { + CFRelease(error); + } + + return std::nullopt; + } + + std::string result{reinterpret_cast(CFDataGetBytePtr(data)), + static_cast(CFDataGetLength(data))}; + CFRelease(data); + return result; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_base64.cc b/vendor/core/src/core/crypto/crypto_base64.cc index 1357e438..89c97cb0 100644 --- a/vendor/core/src/core/crypto/crypto_base64.cc +++ b/vendor/core/src/core/crypto/crypto_base64.cc @@ -36,8 +36,9 @@ constexpr std::array BASE64_DECODE_TABLE{ constexpr std::array BASE64URL_DECODE_TABLE{ build_decode_table(BASE64URL_ALPHABET)}; +template auto encode(const std::string_view input, const std::string_view alphabet, - const bool padding, std::string &output) -> void { + const bool padding, Output &output) -> void { std::size_t index{0}; while (index + 3 <= input.size()) { const std::uint32_t first{static_cast(input[index])}; @@ -71,9 +72,14 @@ auto encode(const std::string_view input, const std::string_view alphabet, } } -auto decode(const std::string_view input, - const std::array &table, const bool padding) - -> std::optional { +template +auto decode_into(const std::string_view input, + const std::array &table, const bool padding, + Output &output) -> bool { + // Decoding appends to the output, so a failure after some bytes were written + // rolls the output back to its original length rather than leaving a partial + // decode in a reused buffer + const auto base{output.size()}; auto data{input}; if (padding) { @@ -82,7 +88,7 @@ auto decode(const std::string_view input, // quantum is always completed at the end of a quantity", hence the padded // form must be a multiple of four characters if (data.size() % 4 != 0) { - return std::nullopt; + return false; } if (data.ends_with('=')) { @@ -94,11 +100,10 @@ auto decode(const std::string_view input, } if (data.size() % 4 == 1) { - return std::nullopt; + return false; } - std::string output; - output.reserve(((data.size() / 4) * 3) + 2); + output.reserve(output.size() + ((data.size() / 4) * 3) + 2); std::size_t index{0}; while (index + 4 <= data.size()) { @@ -111,7 +116,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 3])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || third == INVALID_SEXTET || fourth == INVALID_SEXTET) { - return std::nullopt; + output.resize(base, '\0'); + return false; } const std::uint32_t group{(first << 18u) | (second << 12u) | (third << 6u) | @@ -132,7 +138,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 1])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || (second & 0x0Fu) != 0) { - return std::nullopt; + output.resize(base, '\0'); + return false; } output.push_back(static_cast((first << 2u) | (second >> 4u))); @@ -144,7 +151,8 @@ auto decode(const std::string_view input, table[static_cast(data[index + 2])]}; if (first == INVALID_SEXTET || second == INVALID_SEXTET || third == INVALID_SEXTET || (third & 0x03u) != 0) { - return std::nullopt; + output.resize(base, '\0'); + return false; } output.push_back(static_cast((first << 2u) | (second >> 4u))); @@ -152,7 +160,7 @@ auto decode(const std::string_view input, static_cast(((second & 0x0Fu) << 4u) | (third >> 2u))); } - return output; + return true; } } // namespace @@ -170,8 +178,29 @@ auto base64_encode(const std::string_view input) -> std::string { return result; } +auto base64_encode(const std::string_view input, SecureString &output) -> void { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + output.reserve(output.size() + ((input_copy.size() + 2) / 3) * 4); + encode(std::string_view{input_copy}, BASE64_ALPHABET, true, output); +} + auto base64_decode(const std::string_view input) -> std::optional { - return decode(input, BASE64_DECODE_TABLE, true); + std::string output; + if (!decode_into(input, BASE64_DECODE_TABLE, true, output)) { + return std::nullopt; + } + + return output; +} + +auto base64_decode(const std::string_view input, SecureString &output) -> bool { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + return decode_into(std::string_view{input_copy}, BASE64_DECODE_TABLE, true, + output); } auto base64url_encode(const std::string_view input, std::ostream &output) @@ -186,9 +215,23 @@ auto base64url_encode(const std::string_view input) -> std::string { return result; } +auto base64url_encode(const std::string_view input, SecureString &output) + -> void { + // The input is copied first so that growing the output cannot invalidate it + // when the two alias the same storage + const SecureString input_copy{input}; + output.reserve(output.size() + ((input_copy.size() + 2) / 3) * 4); + encode(std::string_view{input_copy}, BASE64URL_ALPHABET, false, output); +} + auto base64url_decode(const std::string_view input) -> std::optional { - return decode(input, BASE64URL_DECODE_TABLE, false); + std::string output; + if (!decode_into(input, BASE64URL_DECODE_TABLE, false, output)) { + return std::nullopt; + } + + return output; } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_der.h b/vendor/core/src/core/crypto/crypto_der.h index ba47bee0..da79f612 100644 --- a/vendor/core/src/core/crypto/crypto_der.h +++ b/vendor/core/src/core/crypto/crypto_der.h @@ -8,6 +8,7 @@ #include // std::optional, std::nullopt #include // std::string #include // std::string_view +#include // std::pair namespace sourcemeta::core { @@ -116,6 +117,68 @@ inline auto der_append_unsigned_integer(std::string &output, output.append(value); } +// The minimal big-endian magnitude of a canonical non-negative DER INTEGER +// (ITU-T X.690 Section 8.3), returning no value for an empty, negative, or +// non-canonically sign-prefixed value, so a malformed integer cannot be +// silently reinterpreted as a different positive number +inline auto der_unsigned_integer(std::string_view content) + -> std::optional { + if (content.empty()) { + return std::nullopt; + } + + // A leading octet with the high bit set encodes a negative value, and a + // leading zero octet is only canonical when the next octet would otherwise + // be read as negative + if (static_cast(content.front()) >= 0x80) { + return std::nullopt; + } + + if (content.size() >= 2 && content.front() == '\x00' && + static_cast(content[1]) < 0x80) { + return std::nullopt; + } + + while (!content.empty() && content.front() == '\x00') { + content.remove_prefix(1); + } + + return content; +} + +// Read the modulus and public exponent from a PKCS#1 RSAPublicKey structure +// (RFC 8017 Appendix A.1.1), a DER SEQUENCE of exactly the two integers, each +// returned as its minimal big-endian magnitude. Trailing bytes at either level +// are rejected so the input parses as exactly that structure +inline auto der_read_rsa_public_key(const std::string_view der) + -> std::optional> { + const auto sequence{der_read(der)}; + if (!sequence.has_value() || sequence->tag != 0x30 || + !sequence->rest.empty()) { + return std::nullopt; + } + + const auto modulus{der_read(sequence->content)}; + if (!modulus.has_value() || modulus->tag != 0x02) { + return std::nullopt; + } + + const auto exponent{der_read(modulus->rest)}; + if (!exponent.has_value() || exponent->tag != 0x02 || + !exponent->rest.empty()) { + return std::nullopt; + } + + const auto modulus_value{der_unsigned_integer(modulus->content)}; + const auto exponent_value{der_unsigned_integer(exponent->content)}; + if (!modulus_value.has_value() || !exponent_value.has_value()) { + return std::nullopt; + } + + return std::pair{std::string{modulus_value.value()}, + std::string{exponent_value.value()}}; +} + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/crypto_eddsa.h b/vendor/core/src/core/crypto/crypto_eddsa.h index 81ae154c..a74b8e22 100644 --- a/vendor/core/src/core/crypto/crypto_eddsa.h +++ b/vendor/core/src/core/crypto/crypto_eddsa.h @@ -189,6 +189,18 @@ inline auto edwards_point_encode(const EdwardsPoint &point, const Bignum &prime, return encoding; } +// The public key is the encoded base point multiplied by the pruned secret +// scalar (RFC 8032 Sections 5.1.5 and 5.2.5). The multiplication is +// constant-time because the scalar is secret, though the resulting point is +// public +inline auto edwards_public_key_point(const Bignum &scalar, + const EdwardsParameters ¶meters, + const std::size_t length) -> std::string { + return edwards_point_encode(edwards_point_scalar_multiply_constant_time( + scalar, parameters.base, parameters), + parameters.prime, length); +} + // Recover an Ed25519 point from its 32-byte encoding (RFC 8032 Section 5.1.3), // returning no value when the encoding does not name a point on the curve inline auto edwards25519_decode_point(const std::string_view encoding, @@ -370,8 +382,39 @@ inline auto edwards25519_verify(const std::string_view public_key, return edwards_point_equal(left, right, parameters.prime); } -// Produce an Ed25519 signature over a message (RFC 8032 Section 5.1.6), given -// the 32-byte secret seed +// Prune the 32-byte secret scalar in place (RFC 8032 Section 5.1.5): "The +// lowest three bits of the first octet are cleared, the highest bit of the last +// octet is cleared, and the second highest bit of the last octet is set" +inline auto edwards25519_prune_scalar(std::string &scalar_bytes) noexcept + -> void { + scalar_bytes.front() = static_cast( + static_cast(scalar_bytes.front()) & 0xf8u); + scalar_bytes.back() = static_cast( + (static_cast(scalar_bytes.back()) & 0x7fu) | 0x40u); +} + +// The Ed25519 public key derived from the 32-byte private seed, the encoded +// point [s]B where s is the pruned first half of the seed hash (RFC 8032 +// Section 5.1.5) +inline auto edwards25519_public_key(const std::string_view secret) + -> std::optional { + if (secret.size() != 32) { + return std::nullopt; + } + + const auto parameters{edwards25519()}; + auto hashed{sha512_digest(secret)}; + const SecureBufferScope hashed_scope{hashed.data(), hashed.size()}; + const std::string_view digest{reinterpret_cast(hashed.data()), + hashed.size()}; + std::string scalar_bytes{digest.substr(0, 32)}; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards25519_prune_scalar(scalar_bytes); + auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; + const SecureBignumScope scalar_a_scope{scalar_a}; + return edwards_public_key_point(scalar_a, parameters, 32); +} + inline auto edwards25519_sign(const std::string_view secret, const std::string_view message) -> std::optional { @@ -390,23 +433,17 @@ inline auto edwards25519_sign(const std::string_view secret, // The secret scalar is the pruned first half, the prefix the second half std::string scalar_bytes{digest.substr(0, 32)}; - const SecureScope scalar_bytes_scope{scalar_bytes}; - scalar_bytes.front() = static_cast( - static_cast(scalar_bytes.front()) & 0xf8u); - scalar_bytes.back() = static_cast( - (static_cast(scalar_bytes.back()) & 0x7fu) | 0x40u); + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards25519_prune_scalar(scalar_bytes); auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; const SecureBignumScope scalar_a_scope{scalar_a}; const auto prefix{digest.substr(32)}; - const auto public_key{ - edwards_point_encode(edwards_point_scalar_multiply_constant_time( - scalar_a, parameters.base, parameters), - parameters.prime, 32)}; + const auto public_key{edwards_public_key_point(scalar_a, parameters, 32)}; // r = SHA-512(prefix || M) reduced, then R = [r]B std::string nonce_preimage{prefix}; - const SecureScope nonce_preimage_scope{nonce_preimage}; + const SecureStringScope nonce_preimage_scope{nonce_preimage}; nonce_preimage.append(message); auto nonce_digest{sha512_digest(nonce_preimage)}; const SecureBufferScope nonce_digest_scope{nonce_digest.data(), @@ -607,8 +644,38 @@ inline auto edwards448_verify(const std::string_view public_key, return edwards_point_equal(left, right, parameters.prime); } -// Produce an Ed448 signature over a message (RFC 8032 Section 5.2.6), given the -// 57-byte secret seed +// Prune the 57-byte secret scalar in place (RFC 8032 Section 5.2.5): "The two +// least significant bits of the first octet are cleared, all eight bits of the +// last octet are cleared, and the highest bit of the second to last octet is +// set" +inline auto edwards448_prune_scalar(std::string &scalar_bytes) noexcept + -> void { + scalar_bytes.front() = static_cast( + static_cast(scalar_bytes.front()) & 0xfcu); + scalar_bytes[55] = + static_cast(static_cast(scalar_bytes[55]) | 0x80u); + scalar_bytes[56] = '\x00'; +} + +// The Ed448 public key derived from the 57-byte private seed (RFC 8032 +// Section 5.2.5) +inline auto edwards448_public_key(const std::string_view secret) + -> std::optional { + if (secret.size() != 57) { + return std::nullopt; + } + + const auto parameters{edwards448()}; + auto digest{shake256(secret, 114)}; + const SecureStringScope digest_scope{digest}; + std::string scalar_bytes{digest.substr(0, 57)}; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards448_prune_scalar(scalar_bytes); + auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; + const SecureBignumScope scalar_a_scope{scalar_a}; + return edwards_public_key_point(scalar_a, parameters, 57); +} + inline auto edwards448_sign(const std::string_view secret, const std::string_view message) -> std::optional { @@ -621,24 +688,17 @@ inline auto edwards448_sign(const std::string_view secret, // prefix, so it and everything derived from it below is wiped before // returning auto digest{shake256(secret, 114)}; - const SecureScope digest_scope{digest}; + const SecureStringScope digest_scope{digest}; // The secret scalar is the pruned first half, the prefix the second half std::string scalar_bytes{digest.substr(0, 57)}; - const SecureScope scalar_bytes_scope{scalar_bytes}; - scalar_bytes.front() = static_cast( - static_cast(scalar_bytes.front()) & 0xfcu); - scalar_bytes[55] = - static_cast(static_cast(scalar_bytes[55]) | 0x80u); - scalar_bytes[56] = '\x00'; + const SecureStringScope scalar_bytes_scope{scalar_bytes}; + edwards448_prune_scalar(scalar_bytes); auto scalar_a{bignum_from_bytes_little_endian(scalar_bytes)}; const SecureBignumScope scalar_a_scope{scalar_a}; const auto prefix{std::string_view{digest}.substr(57)}; - const auto public_key{ - edwards_point_encode(edwards_point_scalar_multiply_constant_time( - scalar_a, parameters.base, parameters), - parameters.prime, 57)}; + const auto public_key{edwards_public_key_point(scalar_a, parameters, 57)}; // dom4 is "SigEd448" followed by the zero pre-hash flag and an empty context std::string domain{"SigEd448"}; @@ -647,11 +707,11 @@ inline auto edwards448_sign(const std::string_view secret, // r = SHAKE256(dom4 || prefix || M) reduced, then R = [r]B std::string nonce_preimage{domain}; - const SecureScope nonce_preimage_scope{nonce_preimage}; + const SecureStringScope nonce_preimage_scope{nonce_preimage}; nonce_preimage.append(prefix); nonce_preimage.append(message); auto nonce_hash{shake256(nonce_preimage, 114)}; - const SecureScope nonce_hash_scope{nonce_hash}; + const SecureStringScope nonce_hash_scope{nonce_hash}; auto scalar_r{bignum_from_bytes_little_endian(nonce_hash)}; const SecureBignumScope scalar_r_scope{scalar_r}; bignum_reduce(scalar_r, parameters.order); diff --git a/vendor/core/src/core/crypto/crypto_helpers.h b/vendor/core/src/core/crypto/crypto_helpers.h index 9c2e2e78..b5f99c06 100644 --- a/vendor/core/src/core/crypto/crypto_helpers.h +++ b/vendor/core/src/core/crypto/crypto_helpers.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_CORE_CRYPTO_HELPERS_H_ #define SOURCEMETA_CORE_CRYPTO_HELPERS_H_ +#include #include #include #include @@ -9,6 +10,7 @@ #include // std::size_t #include // std::uint8_t +#include // std::optional, std::nullopt #include // std::string #include // std::string_view #include // std::unreachable @@ -19,41 +21,6 @@ namespace sourcemeta::core { // the range of valid key sizes inline constexpr std::size_t MAXIMUM_KEY_BYTES{512}; -// Overwrite a buffer that held secret material so it does not linger in freed -// memory. The volatile access stops the compiler from eliding the write as a -// dead store -inline auto secure_zero(void *const data, const std::size_t size) noexcept - -> void { - if (data == nullptr) { - return; - } - - auto *pointer{static_cast(data)}; - for (std::size_t index{0}; index < size; index += 1) { - pointer[index] = 0; - } -} - -inline auto secure_zero(std::string &value) noexcept -> void { - secure_zero(value.data(), value.size()); -} - -// Overwrite the referenced buffer when leaving the current scope, so secret -// material a local holds is wiped across every return path without threading a -// manual call through each one. It clears the buffer the string owns at scope -// exit, so a reassignment or a growth that reallocates before then can still -// leave an earlier copy in freed memory, a residual that a wiping allocator -// would be needed to close -struct SecureScope { - explicit SecureScope(std::string &value) noexcept : target{value} {} - SecureScope(const SecureScope &) = delete; - auto operator=(const SecureScope &) -> SecureScope & = delete; - SecureScope(SecureScope &&) = delete; - auto operator=(SecureScope &&) -> SecureScope & = delete; - ~SecureScope() { secure_zero(this->target); } - std::string ⌖ -}; - // The same guard for a raw buffer, so a secret that a fixed-size digest array // holds is wiped when leaving the current scope struct SecureBufferScope { @@ -146,6 +113,22 @@ inline auto curve_field_bytes(const EllipticCurve curve) noexcept std::unreachable(); } +// The inverse mapping, identifying the curve from its field width, so a backend +// that reports a key only by coordinate size resolves it the same way +inline auto ec_curve_from_field_bytes(const std::size_t field_bytes) noexcept + -> std::optional { + switch (field_bytes) { + case 32: + return EllipticCurve::P256; + case 48: + return EllipticCurve::P384; + case 66: + return EllipticCurve::P521; + default: + return std::nullopt; + } +} + // The group order of each NIST prime curve as big-endian octets (FIPS 186-4 // Appendix D.1.2), so a private scalar can be range-checked against it inline auto curve_order_bytes(const EllipticCurve curve) -> std::string { @@ -178,7 +161,7 @@ inline auto ec_private_scalar_in_range(const std::string_view scalar, const auto width{curve_field_bytes(curve)}; std::string folded(width, '\x00'); // The buffer holds a copy of the secret scalar, so it is wiped on return - const SecureScope folded_scope{folded}; + const SecureStringScope folded_scope{folded}; std::uint8_t overflow{0}; for (std::size_t index = 0; index < scalar.size(); ++index) { const auto byte{ @@ -251,6 +234,33 @@ inline auto digest_message(const SignatureHashFunction hash, std::unreachable(); } +// The same digest returned in wiping storage, for the secret-bearing hashing of +// the deterministic nonce generator, where the message and the result derive +// from the private key +inline auto secure_digest_message(const SignatureHashFunction hash, + const std::string_view message) + -> SecureString { + switch (hash) { + case SignatureHashFunction::SHA256: { + auto digest{sha256_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + case SignatureHashFunction::SHA384: { + auto digest{sha384_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + case SignatureHashFunction::SHA512: { + auto digest{sha512_digest(message)}; + const SecureBufferScope digest_scope{digest.data(), digest.size()}; + return {reinterpret_cast(digest.data()), digest.size()}; + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/crypto_openssl.h b/vendor/core/src/core/crypto/crypto_openssl.h new file mode 100644 index 00000000..a292d764 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_openssl.h @@ -0,0 +1,81 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_OPENSSL_H_ +#define SOURCEMETA_CORE_CRYPTO_OPENSSL_H_ + +// OpenSSL key export helpers shared by the signing and verification backends, +// so the provider parameter handling and the curve mapping stay single-sourced + +#include + +#include // BIGNUM, BN_* +#include // OSSL_PKEY_PARAM_* +#include // EVP_PKEY, EVP_PKEY_get_* + +#include // std::array +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +// The platform reports the curve under its canonical name rather than the JWK +// alias the key was built with, so both are accepted +inline auto ec_curve_from_group_name(const std::string_view name) noexcept + -> std::optional { + if (name == "P-256" || name == "prime256v1") { + return EllipticCurve::P256; + } else if (name == "P-384" || name == "secp384r1") { + return EllipticCurve::P384; + } else if (name == "P-521" || name == "secp521r1") { + return EllipticCurve::P521; + } else { + return std::nullopt; + } +} + +// A big-endian minimal-length byte string of a bignum, as the JWK members and +// the signature range check expect +inline auto bignum_to_bytes(const BIGNUM *number) -> std::string { + std::string result(static_cast(BN_num_bytes(number)), '\x00'); + BN_bn2bin(number, reinterpret_cast(result.data())); + return result; +} + +// Read the fixed-width uncompressed public point of an elliptic curve or +// Edwards curve key from the native handle +inline auto read_public_point(EVP_PKEY *key) -> std::optional { + std::size_t length{0}; + if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, + &length) != 1) { + return std::nullopt; + } + + std::string point(length, '\x00'); + if (EVP_PKEY_get_octet_string_param( + key, OSSL_PKEY_PARAM_PUB_KEY, + reinterpret_cast(point.data()), point.size(), + &length) != 1) { + return std::nullopt; + } + + // The second call reports how many octets it actually wrote, which can be + // fewer than the buffer, so the string is trimmed to avoid trailing zeros + point.resize(length); + return point; +} + +inline auto read_ec_curve(EVP_PKEY *key) -> std::optional { + std::array group{}; + std::size_t length{0}; + if (EVP_PKEY_get_utf8_string_param(key, OSSL_PKEY_PARAM_GROUP_NAME, + group.data(), group.size(), + &length) != 1) { + return std::nullopt; + } + + return ec_curve_from_group_name(std::string_view{group.data(), length}); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_pkcs8.h b/vendor/core/src/core/crypto/crypto_pkcs8.h index 1a88cab3..cf95c108 100644 --- a/vendor/core/src/core/crypto/crypto_pkcs8.h +++ b/vendor/core/src/core/crypto/crypto_pkcs8.h @@ -43,7 +43,7 @@ inline auto pem_to_der(const std::string_view pem) } // The base64 body carries the whole private key, so it is wiped once decoded - const SecureScope base64_scope{base64}; + const SecureStringScope base64_scope{base64}; return base64_decode(base64); } diff --git a/vendor/core/src/core/crypto/crypto_random_other.cc b/vendor/core/src/core/crypto/crypto_random_other.cc index f402579b..9f675c24 100644 --- a/vendor/core/src/core/crypto/crypto_random_other.cc +++ b/vendor/core/src/core/crypto/crypto_random_other.cc @@ -1,6 +1,9 @@ #include "crypto_random.h" +// glibc and the BSDs declare the entropy call in the random header while +// musl only declares it in the standard POSIX header, so both are needed #include // getentropy +#include // getentropy #include // std::min #include // std::size_t diff --git a/vendor/core/src/core/crypto/crypto_sign_apple.cc b/vendor/core/src/core/crypto/crypto_sign_apple.cc index 5cd617c3..2a9af5ce 100644 --- a/vendor/core/src/core/crypto/crypto_sign_apple.cc +++ b/vendor/core/src/core/crypto/crypto_sign_apple.cc @@ -1,6 +1,8 @@ #include #include +#include "crypto_apple.h" +#include "crypto_der.h" #include "crypto_eddsa.h" #include "crypto_eddsa_apple.h" #include "crypto_helpers.h" @@ -292,7 +294,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -461,4 +463,64 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + auto *public_key{SecKeyCopyPublicKey(internal->key)}; + if (public_key == nullptr) { + return std::nullopt; + } + + const auto der{copy_external_representation(public_key)}; + CFRelease(public_key); + if (!der.has_value()) { + return std::nullopt; + } + + const auto components{der_read_rsa_public_key(der.value())}; + if (!components.has_value()) { + return std::nullopt; + } + + return make_rsa_public_key(components->first, components->second); + } + case PrivateKey::Type::EllipticCurve: { + auto *public_key{SecKeyCopyPublicKey(internal->key)}; + if (public_key == nullptr) { + return std::nullopt; + } + + const auto point{copy_external_representation(public_key)}; + CFRelease(public_key); + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + if (!point.has_value() || !curve.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return make_ec_public_key( + curve.value(), point->substr(1, internal->field_bytes), + point->substr(1 + internal->field_bytes, internal->field_bytes)); + } + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_openssl.cc b/vendor/core/src/core/crypto/crypto_sign_openssl.cc index a10c7a09..26d70ca9 100644 --- a/vendor/core/src/core/crypto/crypto_sign_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_sign_openssl.cc @@ -2,6 +2,7 @@ #include #include "crypto_helpers.h" +#include "crypto_openssl.h" #include "crypto_pkcs8.h" #include // BN_bn2binpad, BN_bin2bn @@ -287,7 +288,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; if (!parse_pkcs8(der.value()).has_value()) { return std::nullopt; } @@ -453,4 +454,66 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) return result; } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + BIGNUM *modulus{nullptr}; + BIGNUM *exponent{nullptr}; + if (EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_N, + &modulus) != 1 || + EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_E, + &exponent) != 1) { + BN_free(modulus); + BN_free(exponent); + return std::nullopt; + } + + const auto modulus_bytes{bignum_to_bytes(modulus)}; + const auto exponent_bytes{bignum_to_bytes(exponent)}; + BN_free(modulus); + BN_free(exponent); + return make_rsa_public_key(modulus_bytes, exponent_bytes); + } + case PrivateKey::Type::EllipticCurve: { + const auto curve{read_ec_curve(internal->key)}; + const auto point{read_public_point(internal->key)}; + if (!curve.has_value() || !point.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return make_ec_public_key( + curve.value(), point->substr(1, internal->field_bytes), + point->substr(1 + internal->field_bytes, internal->field_bytes)); + } + case PrivateKey::Type::Edwards: { + const auto point{read_public_point(internal->key)}; + if (!point.has_value()) { + return std::nullopt; + } + + std::optional curve; + if (point->size() == eddsa_public_key_bytes(EdwardsCurve::Ed25519)) { + curve = EdwardsCurve::Ed25519; + } else if (point->size() == eddsa_public_key_bytes(EdwardsCurve::Ed448)) { + curve = EdwardsCurve::Ed448; + } + + if (!curve.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(curve.value(), point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_other.cc b/vendor/core/src/core/crypto/crypto_sign_other.cc index f796a3c8..734e45e6 100644 --- a/vendor/core/src/core/crypto/crypto_sign_other.cc +++ b/vendor/core/src/core/crypto/crypto_sign_other.cc @@ -2,6 +2,7 @@ #include #include "crypto_bignum.h" +#include "crypto_der.h" #include "crypto_ecc.h" #include "crypto_eddsa.h" #include "crypto_helpers.h" @@ -165,16 +166,19 @@ auto hash_sizes(const SignatureHashFunction hash) -> HashSizes { } // HMAC (RFC 2104) keyed on the signature hash function, the primitive that the -// deterministic nonce generator is built on +// deterministic nonce generator is built on. The key, the block-sized pads, and +// the result all derive from the private key, so they are kept in wiping +// storage auto hmac(const SignatureHashFunction hash, const std::string_view key, - const std::string_view message) -> std::string { + const std::string_view message) -> SecureString { const auto block_bytes{hash_sizes(hash).block_bytes}; - std::string block_key{key.size() > block_bytes ? digest_message(hash, key) - : std::string{key}}; + SecureString block_key{key.size() > block_bytes + ? secure_digest_message(hash, key) + : SecureString{key}}; block_key.resize(block_bytes, '\x00'); - std::string inner_input(block_bytes, '\x00'); - std::string outer_input(block_bytes, '\x00'); + SecureString inner_input(block_bytes, '\x00'); + SecureString outer_input(block_bytes, '\x00'); for (std::size_t index{0}; index < block_bytes; ++index) { const auto byte{static_cast(block_key[index])}; inner_input[index] = static_cast(byte ^ 0x36u); @@ -182,8 +186,8 @@ auto hmac(const SignatureHashFunction hash, const std::string_view key, } inner_input.append(message); - outer_input.append(digest_message(hash, inner_input)); - return digest_message(hash, outer_input); + outer_input.append(secure_digest_message(hash, inner_input)); + return secure_digest_message(hash, outer_input); } // RFC 6979 Section 2.3.2 bits2int, which is also the FIPS 186-4 Section 6.4 @@ -295,20 +299,18 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, .z = bignum_from_u64(1)}; auto private_octets{bignum_to_bytes(private_scalar, order_bytes)}; - const SecureScope private_octets_scope{private_octets}; + const SecureStringScope private_octets_scope{private_octets}; const auto hashed_octets{ bits2octets(digest, parameters.order, order_bits, order_bytes)}; // RFC 6979 Section 3.2 steps b to g: seed the HMAC generator. The generator - // state and the private octets are derived from the private key, so each is - // wiped when leaving this function + // state and the private octets are derived from the private key, so they are + // held in wiping storage that clears itself on every reassignment and on the + // way out of this function const auto output_bytes{hash_sizes(hash).output_bytes}; - std::string hmac_value(output_bytes, '\x01'); - const SecureScope hmac_value_scope{hmac_value}; - std::string hmac_key(output_bytes, '\x00'); - const SecureScope hmac_key_scope{hmac_key}; - std::string seed{hmac_value}; - const SecureScope seed_scope{seed}; + SecureString hmac_value(output_bytes, '\x01'); + SecureString hmac_key(output_bytes, '\x00'); + SecureString seed{hmac_value}; seed.push_back('\x00'); seed.append(private_octets); seed.append(hashed_octets); @@ -323,9 +325,8 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, for (std::size_t attempt = 0; attempt < 256; ++attempt) { // RFC 6979 Section 3.2 step h.2: draw enough output to cover the order. The - // candidate is the secret nonce, so it is wiped when the attempt ends - std::string candidate; - const SecureScope candidate_scope{candidate}; + // candidate is the secret nonce, so it too lives in wiping storage + SecureString candidate; while (candidate.size() * 8 < order_bits) { hmac_value = hmac(hash, hmac_key, hmac_value); candidate.append(hmac_value); @@ -341,8 +342,7 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, } // RFC 6979 Section 3.2 step h: reseed before the next candidate - std::string reseed{hmac_value}; - const SecureScope reseed_scope{reseed}; + SecureString reseed{hmac_value}; reseed.push_back('\x00'); hmac_key = hmac(hash, hmac_key, reseed); hmac_value = hmac(hash, hmac_key, hmac_value); @@ -351,6 +351,34 @@ auto sign_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, return std::nullopt; } +// The public point [d]G of an elliptic curve private key recovered from its +// scalar, so a key parsed from a PEM document, which carries only the scalar on +// this backend, can still expose its public JWK +auto ec_public_from_scalar(const EllipticCurve curve, + const std::string_view scalar) + -> std::pair { + const auto parameters{to_curve_parameters(curve)}; + const JacobianPoint generator{.x = parameters.generator_x, + .y = parameters.generator_y, + .z = bignum_from_u64(1)}; + auto scalar_number{bignum_from_bytes(scalar)}; + const SecureBignumScope scalar_scope{scalar_number}; + // The complete-formula ladder returns a projective point, whose affine + // coordinates are X / Z and Y / Z, not the Jacobian X / Z^2 and Y / Z^3, so + // the normalization mirrors the constant-time affine recovery of the signing + // path rather than the Jacobian point_to_affine + const auto product{point_scalar_multiply_constant_time( + scalar_number, generator, parameters)}; + const auto field{barrett_context(parameters.prime)}; + const auto z_inverse{field_inverse_ct(product.z, field)}; + auto coordinate_x{field_mod_multiply_ct(product.x, z_inverse, field)}; + auto coordinate_y{field_mod_multiply_ct(product.y, z_inverse, field)}; + bignum_normalize(coordinate_x); + bignum_normalize(coordinate_y); + return {bignum_to_bytes(coordinate_x, parameters.field_bytes), + bignum_to_bytes(coordinate_y, parameters.field_bytes)}; +} + } // namespace // The reference backend parses the key material into big integers inside each @@ -365,6 +393,12 @@ struct PrivateKey::Internal { std::string edwards_seed; EdwardsCurve edwards_curve; bool rsa_pss_restricted{false}; + // The public coordinates, kept so the public key can be recovered without + // recomputing the point from the scalar. A key parsed from a PEM document, + // which carries only the scalar on this backend, recomputes them at parse + // time, so they are populated for every elliptic curve private key + std::string coordinate_x; + std::string coordinate_y; }; PrivateKey::PrivateKey(Internal *internal) noexcept : internal_{internal} {} @@ -412,7 +446,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -447,26 +481,36 @@ auto make_private_key(const std::string_view pem) -> std::optional { return std::nullopt; } - const auto stripped_modulus{strip_left(modulus->content, '\x00')}; - const auto stripped_private_exponent{ - strip_left(private_exponent->content, '\x00')}; - if (stripped_modulus.empty() || - stripped_modulus.size() > MAXIMUM_KEY_BYTES || - stripped_private_exponent.size() > MAXIMUM_KEY_BYTES) { + // Decode each field as a canonical non-negative DER INTEGER, so a + // negative or non-canonically encoded value cannot be silently + // reinterpreted as a different positive number and used for signing + 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)}; + if (!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())) { return std::nullopt; } return PrivateKey{new PrivateKey::Internal{ .kind = PrivateKey::Type::RSA, - .modulus = std::string{stripped_modulus}, - .public_exponent = - std::string{strip_left(public_exponent->content, '\x00')}, - .private_exponent = std::string{stripped_private_exponent}, + .modulus = std::string{modulus_value.value()}, + .public_exponent = std::string{public_exponent_value.value()}, + .private_exponent = std::string{private_exponent_value.value()}, .scalar = {}, .elliptic_curve = {}, .edwards_seed = {}, .edwards_curve = {}, - .rsa_pss_restricted = parsed->rsa_pss_restricted}}; + .rsa_pss_restricted = parsed->rsa_pss_restricted, + .coordinate_x = {}, + .coordinate_y = {}}}; } case PKCS8KeyKind::EllipticCurve: { // RFC 5915 Section 3: ECPrivateKey is a SEQUENCE whose second field is @@ -493,16 +537,25 @@ auto make_private_key(const std::string_view pem) -> std::optional { return std::nullopt; } - return PrivateKey{new PrivateKey::Internal{ - .kind = PrivateKey::Type::EllipticCurve, - .modulus = {}, - .public_exponent = {}, - .private_exponent = {}, - .scalar = - std::string{pad_left(stripped_scalar, scalar_width, '\x00')}, - .elliptic_curve = parsed->curve, - .edwards_seed = {}, - .edwards_curve = {}}}; + auto padded_scalar{pad_left(stripped_scalar, scalar_width, '\x00')}; + const SecureStringScope padded_scalar_scope{padded_scalar}; + // A scalar at or above the curve order is not a valid private key, so it + // is rejected before it drives the public point recovery + if (!ec_private_scalar_in_range(padded_scalar, parsed->curve)) { + return std::nullopt; + } + auto point{ec_public_from_scalar(parsed->curve, padded_scalar)}; + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .modulus = {}, + .public_exponent = {}, + .private_exponent = {}, + .scalar = padded_scalar, + .elliptic_curve = parsed->curve, + .edwards_seed = {}, + .edwards_curve = {}, + .coordinate_x = std::move(point.first), + .coordinate_y = std::move(point.second)}}; } case PKCS8KeyKind::Edwards: { const auto seed{der_read(parsed->key)}; @@ -520,7 +573,9 @@ auto make_private_key(const std::string_view pem) -> std::optional { .scalar = {}, .elliptic_curve = {}, .edwards_seed = std::string{seed->content}, - .edwards_curve = parsed->edwards_curve}}; + .edwards_curve = parsed->edwards_curve, + .coordinate_x = {}, + .coordinate_y = {}}}; } } @@ -534,8 +589,6 @@ auto make_ec_private_key(const EllipticCurve curve, -> std::optional { const auto stripped{strip_left(scalar, '\x00')}; const auto width{curve_field_bytes(curve)}; - // The scalar alone drives signing here, but the public coordinates are still - // range-checked so that malformed input is rejected as on the other backends if (stripped.empty() || stripped.size() > width || strip_left(coordinate_x, '\x00').size() > width || strip_left(coordinate_y, '\x00').size() > width || @@ -543,15 +596,33 @@ auto make_ec_private_key(const EllipticCurve curve, return std::nullopt; } - return PrivateKey{new PrivateKey::Internal{ - .kind = PrivateKey::Type::EllipticCurve, - .modulus = {}, - .public_exponent = {}, - .private_exponent = {}, - .scalar = std::string{pad_left(stripped, width, '\x00')}, - .elliptic_curve = curve, - .edwards_seed = {}, - .edwards_curve = {}}}; + auto padded_scalar{pad_left(stripped, width, '\x00')}; + const SecureStringScope padded_scalar_scope{padded_scalar}; + // The stored public point must be the one the scalar actually generates, so a + // document pairing the scalar with a different or off-curve public key is + // rejected rather than trusted. A signature-based correspondence check cannot + // catch this on a deterministic backend, where an attacker can craft a valid + // point that verifies one precomputed signature (a duplicate-signature + // key-selection attack), so the point is recomputed and compared directly + const auto expected{ec_public_from_scalar(curve, padded_scalar)}; + if (pad_left(strip_left(coordinate_x, '\x00'), width, '\x00') != + expected.first || + pad_left(strip_left(coordinate_y, '\x00'), width, '\x00') != + expected.second) { + return std::nullopt; + } + + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .modulus = {}, + .public_exponent = {}, + .private_exponent = {}, + .scalar = std::string{padded_scalar}, + .elliptic_curve = curve, + .edwards_seed = {}, + .edwards_curve = {}, + .coordinate_x = expected.first, + .coordinate_y = expected.second}}; } auto make_edwards_private_key(const EdwardsCurve curve, @@ -568,7 +639,9 @@ auto make_edwards_private_key(const EdwardsCurve curve, .scalar = {}, .elliptic_curve = {}, .edwards_seed = std::string{seed}, - .edwards_curve = curve}}; + .edwards_curve = curve, + .coordinate_x = {}, + .coordinate_y = {}}}; } auto rsassa_pkcs1_v15_sign(const PrivateKey &key, @@ -642,4 +715,38 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: + return make_rsa_public_key(internal->modulus, internal->public_exponent); + case PrivateKey::Type::EllipticCurve: + // The public coordinates are populated for every elliptic curve private + // key, whether supplied directly or recomputed from a PEM scalar at parse + // time, so the guard is a defensive check rather than a common path + if (internal->coordinate_x.empty()) { + return std::nullopt; + } + + return make_ec_public_key(internal->elliptic_curve, + internal->coordinate_x, internal->coordinate_y); + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_rsa.cc b/vendor/core/src/core/crypto/crypto_sign_rsa.cc index da3701d4..f04b87f9 100644 --- a/vendor/core/src/core/crypto/crypto_sign_rsa.cc +++ b/vendor/core/src/core/crypto/crypto_sign_rsa.cc @@ -54,7 +54,7 @@ auto make_rsa_private_key( // RSAPrivateKey (RFC 8017 Appendix A.1.2), whose version is zero for the // two-prime form std::string rsa_private_key_body; - const SecureScope rsa_private_key_body_scope{rsa_private_key_body}; + const SecureStringScope rsa_private_key_body_scope{rsa_private_key_body}; der_append_unsigned_integer(rsa_private_key_body, std::string_view{}); der_append_unsigned_integer(rsa_private_key_body, modulus); der_append_unsigned_integer(rsa_private_key_body, public_exponent); @@ -65,25 +65,25 @@ auto make_rsa_private_key( der_append_unsigned_integer(rsa_private_key_body, exponent2); der_append_unsigned_integer(rsa_private_key_body, coefficient); std::string rsa_private_key; - const SecureScope rsa_private_key_scope{rsa_private_key}; + const SecureStringScope rsa_private_key_scope{rsa_private_key}; der_append_element(rsa_private_key, 0x30, rsa_private_key_body); // PrivateKeyInfo (RFC 5958 Section 2), whose version is likewise zero std::string document_body; - const SecureScope document_body_scope{document_body}; + const SecureStringScope document_body_scope{document_body}; der_append_unsigned_integer(document_body, std::string_view{}); document_body.append( reinterpret_cast(RSA_ALGORITHM_IDENTIFIER.data()), RSA_ALGORITHM_IDENTIFIER.size()); der_append_element(document_body, 0x04, rsa_private_key); std::string document; - const SecureScope document_scope{document}; + const SecureStringScope document_scope{document}; der_append_element(document, 0x30, document_body); auto encoded{base64_encode(document)}; - const SecureScope encoded_scope{encoded}; + const SecureStringScope encoded_scope{encoded}; std::string pem{"-----BEGIN PRIVATE KEY-----\n"}; - const SecureScope pem_scope{pem}; + const SecureStringScope pem_scope{pem}; for (std::size_t offset{0}; offset < encoded.size(); offset += 64) { pem.append(encoded.substr(offset, 64)); pem.push_back('\n'); diff --git a/vendor/core/src/core/crypto/crypto_sign_windows.cc b/vendor/core/src/core/crypto/crypto_sign_windows.cc index 76d711c9..72205c0d 100644 --- a/vendor/core/src/core/crypto/crypto_sign_windows.cc +++ b/vendor/core/src/core/crypto/crypto_sign_windows.cc @@ -11,6 +11,8 @@ #include // BCrypt*, BCRYPT_* +#include "crypto_windows.h" + #include // std::array #include // std::countl_zero #include // assert @@ -363,7 +365,7 @@ auto make_private_key(const std::string_view pem) -> std::optional { } // The decoded PKCS#8 holds the whole private key, so it is wiped on return - const SecureScope der_scope{der.value()}; + const SecureStringScope der_scope{der.value()}; const auto parsed{parse_pkcs8(der.value())}; if (!parsed.has_value()) { return std::nullopt; @@ -527,4 +529,63 @@ auto eddsa_sign(const PrivateKey &key, const std::string_view message) std::unreachable(); } +auto derive_public_key(const PrivateKey &key) -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr) { + return std::nullopt; + } + + switch (internal->kind) { + case PrivateKey::Type::RSA: { + const auto blob{export_key_blob(internal->key, BCRYPT_RSAPUBLIC_BLOB)}; + if (!blob.has_value() || blob->size() < sizeof(BCRYPT_RSAKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_RSAKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + const std::size_t offset{sizeof(header)}; + if (blob->size() < offset + header.cbPublicExp + header.cbModulus) { + return std::nullopt; + } + + return make_rsa_public_key( + blob->substr(offset + header.cbPublicExp, header.cbModulus), + blob->substr(offset, header.cbPublicExp)); + } + case PrivateKey::Type::EllipticCurve: { + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + const auto blob{export_key_blob(internal->key, BCRYPT_ECCPUBLIC_BLOB)}; + if (!curve.has_value() || !blob.has_value() || + blob->size() < sizeof(BCRYPT_ECCKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_ECCKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + const std::size_t offset{sizeof(header)}; + const std::size_t width{header.cbKey}; + if (width != internal->field_bytes || + blob->size() < offset + (width * 2)) { + return std::nullopt; + } + + return make_ec_public_key(curve.value(), blob->substr(offset, width), + blob->substr(offset + width, width)); + } + case PrivateKey::Type::Edwards: { + const auto point{internal->edwards_curve == EdwardsCurve::Ed25519 + ? edwards25519_public_key(internal->edwards_seed) + : edwards448_public_key(internal->edwards_seed)}; + if (!point.has_value()) { + return std::nullopt; + } + + return make_eddsa_public_key(internal->edwards_curve, point.value()); + } + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_apple.cc b/vendor/core/src/core/crypto/crypto_verify_apple.cc index 2de9240f..4f8b6e15 100644 --- a/vendor/core/src/core/crypto/crypto_verify_apple.cc +++ b/vendor/core/src/core/crypto/crypto_verify_apple.cc @@ -1,6 +1,7 @@ #include #include +#include "crypto_apple.h" #include "crypto_der.h" #include "crypto_eddsa.h" #include "crypto_eddsa_apple.h" @@ -372,4 +373,62 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + const auto der{copy_external_representation(internal->key)}; + if (!der.has_value()) { + return std::nullopt; + } + + auto components{der_read_rsa_public_key(der.value())}; + if (!components.has_value()) { + return std::nullopt; + } + + return RSAPublicComponents{.modulus = std::move(components->first), + .exponent = std::move(components->second)}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + if (!curve.has_value()) { + return std::nullopt; + } + + // The X9.63 uncompressed point is the 0x04 lead byte followed by the two + // fixed-width coordinates + const auto point{copy_external_representation(internal->key)}; + if (!point.has_value() || point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return ECPublicComponents{ + .curve = curve.value(), + .x = point->substr(1, internal->field_bytes), + .y = point->substr(1 + internal->field_bytes, internal->field_bytes)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->edwards_point}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_openssl.cc b/vendor/core/src/core/crypto/crypto_verify_openssl.cc index 51d5a7f4..8644c98d 100644 --- a/vendor/core/src/core/crypto/crypto_verify_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_verify_openssl.cc @@ -2,6 +2,7 @@ #include #include "crypto_helpers.h" +#include "crypto_openssl.h" #include // BN_* #include // OSSL_PKEY_PARAM_* @@ -407,4 +408,69 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, return result; } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + BIGNUM *modulus{nullptr}; + BIGNUM *exponent{nullptr}; + if (EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_N, &modulus) != + 1 || + EVP_PKEY_get_bn_param(internal->key, OSSL_PKEY_PARAM_RSA_E, &exponent) != + 1) { + BN_free(modulus); + BN_free(exponent); + return std::nullopt; + } + + RSAPublicComponents result{.modulus = bignum_to_bytes(modulus), + .exponent = bignum_to_bytes(exponent)}; + BN_free(modulus); + BN_free(exponent); + return result; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{read_ec_curve(internal->key)}; + const auto point{read_public_point(internal->key)}; + if (!curve.has_value() || !point.has_value() || + point->size() != 1 + (internal->field_bytes * 2) || + point->front() != '\x04') { + return std::nullopt; + } + + return ECPublicComponents{ + .curve = curve.value(), + .x = point->substr(1, internal->field_bytes), + .y = point->substr(1 + internal->field_bytes, internal->field_bytes)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + const auto point{read_public_point(internal->key)}; + if (!point.has_value()) { + return std::nullopt; + } + + const auto curve{internal->signature_bytes == + eddsa_signature_bytes(EdwardsCurve::Ed25519) + ? EdwardsCurve::Ed25519 + : EdwardsCurve::Ed448}; + return EdwardsPublicComponents{.curve = curve, .point = point.value()}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_other.cc b/vendor/core/src/core/crypto/crypto_verify_other.cc index 7f54aeba..a0651343 100644 --- a/vendor/core/src/core/crypto/crypto_verify_other.cc +++ b/vendor/core/src/core/crypto/crypto_verify_other.cc @@ -486,4 +486,41 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + return RSAPublicComponents{.modulus = internal->modulus, + .exponent = internal->exponent}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto width{curve_field_bytes(internal->elliptic_curve)}; + return ECPublicComponents{ + .curve = internal->elliptic_curve, + .x = pad_left(internal->coordinate_x, width, '\x00'), + .y = pad_left(internal->coordinate_y, width, '\x00')}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + // The Edwards public point is kept in the coordinate slot on this backend + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->coordinate_x}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_verify_windows.cc b/vendor/core/src/core/crypto/crypto_verify_windows.cc index 6530c92c..9b6deaff 100644 --- a/vendor/core/src/core/crypto/crypto_verify_windows.cc +++ b/vendor/core/src/core/crypto/crypto_verify_windows.cc @@ -10,6 +10,8 @@ #include // BCrypt*, BCRYPT_* +#include "crypto_windows.h" + #include // std::countl_zero #include // assert #include // std::size_t @@ -370,4 +372,71 @@ auto eddsa_verify(const PublicKey &key, const std::string_view message, std::unreachable(); } +auto rsa_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + const auto blob{export_key_blob(internal->key, BCRYPT_RSAPUBLIC_BLOB)}; + if (!blob.has_value() || blob->size() < sizeof(BCRYPT_RSAKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_RSAKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + // The public blob is the header followed by the exponent then the modulus + const std::size_t offset{sizeof(header)}; + if (blob->size() < offset + header.cbPublicExp + header.cbModulus) { + return std::nullopt; + } + + const auto exponent{blob->substr(offset, header.cbPublicExp)}; + const auto modulus{ + blob->substr(offset + header.cbPublicExp, header.cbModulus)}; + return RSAPublicComponents{ + .modulus = std::string{strip_left(modulus, '\x00')}, + .exponent = std::string{strip_left(exponent, '\x00')}}; +} + +auto ec_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::EllipticCurve) { + return std::nullopt; + } + + const auto curve{ec_curve_from_field_bytes(internal->field_bytes)}; + const auto blob{export_key_blob(internal->key, BCRYPT_ECCPUBLIC_BLOB)}; + if (!curve.has_value() || !blob.has_value() || + blob->size() < sizeof(BCRYPT_ECCKEY_BLOB)) { + return std::nullopt; + } + + BCRYPT_ECCKEY_BLOB header{}; + std::memcpy(&header, blob->data(), sizeof(header)); + // The public blob is the header followed by the two fixed-width coordinates + const std::size_t offset{sizeof(header)}; + const std::size_t width{header.cbKey}; + if (width != internal->field_bytes || blob->size() < offset + (width * 2)) { + return std::nullopt; + } + + return ECPublicComponents{.curve = curve.value(), + .x = blob->substr(offset, width), + .y = blob->substr(offset + width, width)}; +} + +auto edwards_public_components(const PublicKey &key) + -> std::optional { + const auto *internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::Edwards) { + return std::nullopt; + } + + return EdwardsPublicComponents{.curve = internal->edwards_curve, + .point = internal->edwards_point}; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_windows.h b/vendor/core/src/core/crypto/crypto_windows.h new file mode 100644 index 00000000..4afae01a --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_windows.h @@ -0,0 +1,43 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_WINDOWS_H_ +#define SOURCEMETA_CORE_CRYPTO_WINDOWS_H_ + +// CNG key export helper shared by the signing and verification backends, so the +// native blob export stays single-sourced + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include // ULONG, LPCWSTR + +#include // BCryptExportKey, BCRYPT_KEY_HANDLE, BCRYPT_SUCCESS + +#include // std::optional, std::nullopt +#include // std::string + +namespace sourcemeta::core { + +// Export a native key blob, sizing the buffer in a first call and filling it in +// a second +inline auto export_key_blob(BCRYPT_KEY_HANDLE key, LPCWSTR blob_type) + -> std::optional { + ULONG size{0}; + if (!BCRYPT_SUCCESS( + BCryptExportKey(key, nullptr, blob_type, nullptr, 0, &size, 0))) { + return std::nullopt; + } + + std::string blob; + blob.resize(size); + if (!BCRYPT_SUCCESS(BCryptExportKey( + key, nullptr, blob_type, + reinterpret_cast(blob.data()), size, &size, 0))) { + return std::nullopt; + } + + blob.resize(size); + return blob; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h index 9baf31a3..be947c01 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h index d54c90c6..2183182b 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h @@ -5,6 +5,8 @@ #include #endif +#include + #include // std::optional #include // std::ostream #include // std::string @@ -40,6 +42,22 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input, auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input) -> std::string; +/// @ingroup crypto +/// Encode a byte sequence using Base64 (RFC 4648 Section 4), appending to a +/// wiping string so that an encoded secret is never held in ordinary storage. +/// The output must not alias the input. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// sourcemeta::core::base64_encode("foobar", result); +/// assert(result == "Zm9vYmFy"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input, + SecureString &output) -> void; + /// @ingroup crypto /// Decode a Base64 string (RFC 4648 Section 4), returning no value unless the /// input is a canonical padded encoding. For example: @@ -55,6 +73,24 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_encode(const std::string_view input) auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_decode(const std::string_view input) -> std::optional; +/// @ingroup crypto +/// Decode a Base64 string (RFC 4648 Section 4) into a wiping string, for a +/// decoded value that is secret such as a client credential, returning whether +/// the input is a canonical padded encoding. On success the decoded bytes are +/// appended, and on failure the output is left with its original contents, so a +/// reused buffer never keeps a partial decode. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// assert(sourcemeta::core::base64_decode("Zm9vYmFy", result)); +/// assert(result == "foobar"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT base64_decode(const std::string_view input, + SecureString &output) -> bool; + /// @ingroup crypto /// Encode a byte sequence using unpadded Base64url (RFC 4648 Section 5) into a /// stream. For example: @@ -84,6 +120,22 @@ base64url_encode(const std::string_view input, std::ostream &output) -> void; auto SOURCEMETA_CORE_CRYPTO_EXPORT base64url_encode(const std::string_view input) -> std::string; +/// @ingroup crypto +/// Append the unpadded Base64url encoding (RFC 4648 Section 5) of a byte +/// sequence to a wiping string, so that encoding a secret does not leave it in +/// an intermediate buffer. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString result; +/// sourcemeta::core::base64url_encode("fo", result); +/// assert(result == "Zm8"); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT +base64url_encode(const std::string_view input, SecureString &output) -> void; + /// @ingroup crypto /// Decode an unpadded Base64url string (RFC 4648 Section 5), returning no /// value unless the input is a canonical encoding. For example: 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 new file mode 100644 index 00000000..f7feb179 --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_secure.h @@ -0,0 +1,267 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_SECURE_H_ +#define SOURCEMETA_CORE_CRYPTO_SECURE_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include // std::size_t +#include // std::less +#include // std::numeric_limits +#include // operator new, operator delete, std::align_val_t +#include // std::string +#include // std::string_view +#include // std::vector + +namespace sourcemeta::core { + +/// @ingroup crypto +/// Overwrite a buffer that held secret material with zeroes, so it does not +/// linger in memory after it is no longer needed. The write goes through a +/// volatile access, so the compiler does not elide it as a dead store. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::array buffer{{1, 2, 3, 4}}; +/// sourcemeta::core::secure_zero(buffer.data(), buffer.size()); +/// ``` +inline auto secure_zero(void *const data, const std::size_t size) noexcept + -> void { + if (data == nullptr) { + return; + } + + auto *pointer{static_cast(data)}; + for (std::size_t index{0}; index < size; index += 1) { + pointer[index] = 0; + } +} + +/// @ingroup crypto +/// Overwrite the storage a string owns with zeroes, so a secret it held does +/// not linger in memory. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string secret{"hunter2"}; +/// sourcemeta::core::secure_zero(secret); +/// assert(secret == std::string(7, '\x00')); +/// ``` +inline auto secure_zero(std::string &value) noexcept -> void { + secure_zero(value.data(), value.size()); +} + +/// @ingroup crypto +/// Overwrite the referenced string when leaving the current scope, so secret +/// material a local holds is wiped across every return path without threading +/// a manual call through each one. It clears only the live bytes the string +/// owns at scope exit, so a reassignment, an in-place shrink, or a growth that +/// reallocates before then can still leave earlier bytes in freed memory or in +/// the capacity beyond the final length, a residual that only a wiping +/// allocator closes. Prefer a wiping string for secrets that change size. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string secret{"hunter2"}; +/// { +/// const sourcemeta::core::SecureStringScope scope{secret}; +/// } +/// assert(secret == std::string(7, '\x00')); +/// ``` +struct SecureStringScope { + /// Capture the string to wipe when leaving the current scope. + explicit SecureStringScope(std::string &value) noexcept : target{value} {} + SecureStringScope(const SecureStringScope &) = delete; + auto operator=(const SecureStringScope &) -> SecureStringScope & = delete; + SecureStringScope(SecureStringScope &&) = delete; + auto operator=(SecureStringScope &&) -> SecureStringScope & = delete; + ~SecureStringScope() { secure_zero(this->target); } + /// The captured string, whose storage is wiped at scope exit. + std::string ⌖ +}; + +/// @ingroup crypto +/// A standard allocator that wipes every block it owns before releasing it, so +/// a secret the storage held does not survive in freed memory. The deallocation +/// covers the whole block, so a reallocation, a reassignment, or the +/// destruction of the owner all clear the earlier bytes, closing the residue +/// that the scope-based cleanup above cannot reach on its own. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// std::vector> secret; +/// secret.push_back('x'); +/// ``` +template struct SecureAllocator { + /// The type of object this allocator hands out storage for. + using value_type = T; + + /// Construct an allocator, which holds no state of its own. + SecureAllocator() noexcept = default; + + /// Construct from an allocator for another type, as the containers require. + template + constexpr SecureAllocator(const SecureAllocator &) noexcept {} + + /// Allocate storage for the given number of objects. + [[nodiscard]] auto allocate(const std::size_t count) -> T * { + if (count > std::numeric_limits::max() / sizeof(T)) { + throw std::bad_array_new_length{}; + } + + return static_cast( + ::operator new(count * sizeof(T), std::align_val_t{alignof(T)})); + } + + /// Wipe and release the storage of the given number of objects. + auto deallocate(T *const pointer, const std::size_t count) noexcept -> void { + secure_zero(pointer, count * sizeof(T)); + ::operator delete(pointer, std::align_val_t{alignof(T)}); + } + + /// Every instance is interchangeable, since the allocator holds no state. + template + auto operator==(const SecureAllocator &) const noexcept -> bool { + return true; + } + + /// Every instance is interchangeable, since the allocator holds no state. + template + auto operator!=(const SecureAllocator &) const noexcept -> bool { + return false; + } +}; + +/// @ingroup crypto +/// A string whose bytes always live in heap storage that is wiped whenever it +/// is released, so a secret it holds never reaches freed memory when the string +/// reallocates on growth, is reassigned, or is destroyed, and never lingers in +/// an inline buffer. Bytes abandoned by an in-place shrink stay in the still +/// owned block only until that block is next reallocated or freed, when they +/// too are wiped. For example: +/// +/// ```cpp +/// #include +/// +/// sourcemeta::core::SecureString secret{"hunter2"}; +/// secret.append("!"); +/// ``` +class SecureString { +public: + /// The type that counts and indexes the held bytes. + using size_type = std::vector>::size_type; + + /// Construct an empty string. + SecureString() = default; + + /// Construct from a view of bytes. + SecureString(const std::string_view value) + : buffer_(value.begin(), value.end()) {} + + /// Construct from a pointer and a length. + SecureString(const char *const data, const size_type length) + : buffer_(data, data + length) {} + + /// Construct a run of a repeated byte. + SecureString(const size_type count, const char value) + : buffer_(count, value) {} + + /// The number of bytes held. + [[nodiscard]] auto size() const noexcept -> size_type { + return this->buffer_.size(); + } + + /// Whether no bytes are held. + [[nodiscard]] auto empty() const noexcept -> bool { + return this->buffer_.empty(); + } + + /// Reserve storage for at least the given number of bytes. + auto reserve(const size_type capacity) -> void { + this->buffer_.reserve(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 { + this->buffer_.resize(count, value); + } + + /// Append a single byte. + auto push_back(const char value) -> void { this->buffer_.push_back(value); } + + /// Append a view of bytes. + auto append(const std::string_view value) -> void { + // Inserting a range whose iterators point into this container is undefined, + // so a view that aliases the storage is taken through an independent buffer + // that wipes itself, while an independent view is inserted directly. The + // ordering uses the total order over pointers, which is defined even for + // pointers into different objects + const char *const first{this->buffer_.data()}; + const std::less before{}; + if (!this->buffer_.empty() && !before(value.data(), first) && + before(value.data(), first + this->buffer_.size())) { + const std::vector> copy(value.begin(), + value.end()); + this->buffer_.insert(this->buffer_.end(), copy.begin(), copy.end()); + } else { + this->buffer_.insert(this->buffer_.end(), value.begin(), value.end()); + } + } + + /// Append a run of a repeated byte. + auto append(const size_type count, const char value) -> void { + this->buffer_.insert(this->buffer_.end(), count, value); + } + + /// A reference to the byte at the given index. + [[nodiscard]] auto operator[](const size_type index) noexcept -> char & { + return this->buffer_[index]; + } + + /// The byte at the given index. + [[nodiscard]] auto operator[](const size_type index) const noexcept -> char { + return this->buffer_[index]; + } + + /// The first byte. + [[nodiscard]] auto front() const noexcept -> char { + return this->buffer_.front(); + } + + /// The last byte. + [[nodiscard]] auto back() const noexcept -> char { + return this->buffer_.back(); + } + + /// A view over the held bytes. + [[nodiscard]] operator std::string_view() const noexcept { + return {this->buffer_.data(), this->buffer_.size()}; + } + + /// Whether the held bytes equal the given view. + [[nodiscard]] auto operator==(const std::string_view other) const noexcept + -> bool { + return std::string_view{*this} == other; + } + +private: + std::vector> buffer_; +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h index 5447737c..ab278659 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h @@ -106,6 +106,24 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT make_rsa_private_key( const std::string_view exponent2, const std::string_view coefficient) -> std::optional; +/// @ingroup crypto +/// Derive the public key from a private key, returning no value when the public +/// part cannot be produced. The derived key exports its components through the +/// public component functions, so a private key parsed from a PEM document can +/// still be rendered to a public key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_private_key(pem)}; +/// assert(key.has_value()); +/// const auto public_key{sourcemeta::core::derive_public_key(key.value())}; +/// assert(public_key.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT derive_public_key(const PrivateKey &key) + -> std::optional; + /// @ingroup crypto /// Produce an RSASSA-PKCS1-v1_5 signature (RFC 8017 Section 8.2.1) over a /// message, returning no value when the key is not an RSA key. For example: diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h index 3e1dedb6..5f048a28 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_verify.h @@ -7,6 +7,7 @@ #include // std::uint8_t #include // std::optional +#include // std::string #include // std::string_view namespace sourcemeta::core { @@ -115,6 +116,89 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT make_eddsa_public_key( const EdwardsCurve curve, const std::string_view public_key) -> std::optional; +/// @ingroup crypto +/// The public components of an RSA key, as raw big-endian bytes in minimal +/// form. +struct RSAPublicComponents { + /// The RSA modulus. + std::string modulus; + /// The RSA public exponent. + std::string exponent; +}; + +/// @ingroup crypto +/// The public coordinates of an elliptic curve key, each padded to the curve +/// field width, together with the curve they belong to. +struct ECPublicComponents { + /// The curve the coordinates belong to. + EllipticCurve curve; + /// The x coordinate. + std::string x; + /// The y coordinate. + std::string y; +}; + +/// @ingroup crypto +/// The public point of an Edwards-curve key, together with the curve it +/// belongs to. +struct EdwardsPublicComponents { + /// The curve the point belongs to. + EdwardsCurve curve; + /// The encoded public point. + std::string point; +}; + +/// @ingroup crypto +/// Extract the public components of an RSA key, returning no value when the key +/// is not an RSA key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_rsa_public_key(modulus, exponent)}; +/// assert(key.has_value()); +/// const auto components{sourcemeta::core::rsa_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT rsa_public_components(const PublicKey &key) + -> std::optional; + +/// @ingroup crypto +/// Extract the public coordinates of an elliptic curve key, returning no value +/// when the key is not an elliptic curve key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_ec_public_key( +/// sourcemeta::core::EllipticCurve::P256, x, y)}; +/// assert(key.has_value()); +/// const auto components{sourcemeta::core::ec_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT ec_public_components(const PublicKey &key) + -> std::optional; + +/// @ingroup crypto +/// Extract the public point of an Edwards-curve key, returning no value when +/// the key is not an Edwards-curve key. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::make_eddsa_public_key( +/// sourcemeta::core::EdwardsCurve::Ed25519, public_key)}; +/// assert(key.has_value()); +/// const auto components{ +/// sourcemeta::core::edwards_public_components(key.value())}; +/// assert(components.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT edwards_public_components( + const PublicKey &key) -> std::optional; + /// @ingroup crypto /// Verify an RSASSA-PKCS1-v1_5 signature (RFC 8017 Section 8.2.2) over a /// message with the given RSA key. The signature is invalid rather than an diff --git a/vendor/core/src/core/http/CMakeLists.txt b/vendor/core/src/core/http/CMakeLists.txt index ad184a81..e3fe59f4 100644 --- a/vendor/core/src/core/http/CMakeLists.txt +++ b/vendor/core/src/core/http/CMakeLists.txt @@ -10,11 +10,12 @@ endif() sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME http PRIVATE_HEADERS problem.h status.h method.h message.h error.h system.h - aws_sigv4.h + aws_sigv4.h syntax.h SOURCES helpers.h problem.cc match_accept.cc match_accept_language.cc negotiate_encoding.cc from_date.cc format_link.cc field_list.cc accept_includes_all.cc content_type_matches.cc parse_bearer.cc cache_control_max_age.cc serialize_cookie.cc aws_sigv4.cc + scan_quoted_string.cc ${SOURCEMETA_CORE_HTTP_CLIENT_SOURCE}) if(SOURCEMETA_CORE_INSTALL) diff --git a/vendor/core/src/core/http/helpers.h b/vendor/core/src/core/http/helpers.h index 2a607910..1d577664 100644 --- a/vendor/core/src/core/http/helpers.h +++ b/vendor/core/src/core/http/helpers.h @@ -1,6 +1,7 @@ #ifndef SOURCEMETA_CORE_HTTP_HELPERS_H_ #define SOURCEMETA_CORE_HTTP_HELPERS_H_ +#include #include #include // std::size_t @@ -12,10 +13,6 @@ // NOLINTBEGIN(bugprone-suspicious-stringview-data-usage) namespace sourcemeta::core { -inline auto http_is_ows(const char character) noexcept -> bool { - return character == ' ' || character == '\t'; -} - inline auto http_subview(const std::string_view value, const std::size_t offset, const std::size_t length) noexcept -> std::string_view { @@ -50,24 +47,6 @@ inline auto http_media_specificity(const std::string_view range, return 2; } -inline auto http_trim_trailing_ows(const std::string_view value) noexcept - -> std::string_view { - std::size_t size{value.size()}; - while (size > 0 && http_is_ows(value[size - 1])) { - --size; - } - return http_subview(value, 0, size); -} - -inline auto http_trim_leading_ows(const std::string_view value) noexcept - -> std::string_view { - std::size_t position{0}; - while (position < value.size() && http_is_ows(value[position])) { - ++position; - } - return http_subview(value, position, value.size() - position); -} - template inline auto http_for_each_list_entry(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 94629f83..c59121be 100644 --- a/vendor/core/src/core/http/include/sourcemeta/core/http.h +++ b/vendor/core/src/core/http/include/sourcemeta/core/http.h @@ -12,6 +12,7 @@ #include #include #include +#include #include // NOLINTEND(misc-include-cleaner) 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 new file mode 100644 index 00000000..46780562 --- /dev/null +++ b/vendor/core/src/core/http/include/sourcemeta/core/http_syntax.h @@ -0,0 +1,251 @@ +#ifndef SOURCEMETA_CORE_HTTP_SYNTAX_H_ +#define SOURCEMETA_CORE_HTTP_SYNTAX_H_ + +#ifndef SOURCEMETA_CORE_HTTP_EXPORT +#include +#endif + +#include + +#include // std::size_t +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup http +/// Whether a character is optional whitespace, a space or a horizontal tab +/// (RFC 9110 Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_ows(' ')); +/// assert(!sourcemeta::core::http_is_ows('x')); +/// ``` +inline auto http_is_ows(const char character) noexcept -> bool { + return character == ' ' || character == '\t'; +} + +/// @ingroup http +/// Whether a character is a token character (RFC 9110 Section 5.6.2), the set a +/// bare header token draws from. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_tchar('a')); +/// assert(!sourcemeta::core::http_is_tchar('"')); +/// ``` +inline auto http_is_tchar(const char character) noexcept -> bool { + switch (character) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return is_alphanum(character); + } +} + +/// @ingroup http +/// Whether a character may appear in the alphabet of a token68 (RFC 7235 +/// Section 2.1) or b64token (RFC 6750 Section 2.1) credential, before its +/// padding. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_b64token_char('/')); +/// assert(!sourcemeta::core::http_is_b64token_char('=')); +/// ``` +inline auto http_is_b64token_char(const char character) noexcept -> bool { + return is_alphanum(character) || character == '-' || character == '.' || + character == '_' || character == '~' || character == '+' || + character == '/'; +} + +/// @ingroup http +/// Whether a string is a well-formed token68 (RFC 7235 Section 2.1) or b64token +/// (RFC 6750 Section 2.1), at least one alphabet character followed by any "=" +/// padding. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_b64token("YWJj==")); +/// assert(!sourcemeta::core::http_is_b64token("a b")); +/// ``` +inline auto http_is_b64token(const std::string_view value) noexcept -> bool { + std::size_t position{0}; + while (position < value.size() && http_is_b64token_char(value[position])) { + position += 1; + } + + if (position == 0) { + return false; + } + + while (position < value.size()) { + if (value[position] != '=') { + return false; + } + + position += 1; + } + + return true; +} + +/// @ingroup http +/// Whether a string is a well-formed token (RFC 9110 Section 5.6.2), at least +/// one token character and nothing else. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_is_token("Bearer")); +/// assert(!sourcemeta::core::http_is_token("has space")); +/// ``` +inline auto http_is_token(const std::string_view value) noexcept -> bool { + if (value.empty()) { + return false; + } + + for (const auto character : value) { + if (!http_is_tchar(character)) { + return false; + } + } + + return true; +} + +/// @ingroup http +/// Append a string to the sink as a quoted-string (RFC 9110 Section 5.6.4), +/// wrapping it in double quotes and escaping any double quote or backslash, +/// returning whether it was encodable. Nothing is appended when the string +/// carries a byte no quoted-string admits, such as a control character, which +/// keeps a value from injecting into a header. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string sink; +/// assert(sourcemeta::core::http_encode_quoted_string(R"(a"b)", sink)); +/// assert(sink == R"("a\"b")"); +/// ``` +inline auto http_encode_quoted_string(const std::string_view value, + std::string &sink) -> bool { + for (const auto character : value) { + const auto byte{static_cast(character)}; + // RFC 9110 Section 5.6.4: qdtext and quoted-pair admit HTAB, SP through + // VCHAR, and obs-text, so every other control character (CR and LF among + // them) makes the value unencodable + if (byte != 0x09 && (byte < 0x20 || byte == 0x7F)) { + return false; + } + } + + sink.push_back('"'); + for (const auto character : value) { + if (character == '"' || character == '\\') { + sink.push_back('\\'); + } + + sink.push_back(character); + } + + sink.push_back('"'); + return true; +} + +/// @ingroup http +/// The view with any leading optional whitespace removed (RFC 9110 +/// Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_trim_leading_ows(" x") == "x"); +/// ``` +inline auto http_trim_leading_ows(std::string_view value) noexcept + -> std::string_view { + while (!value.empty() && http_is_ows(value.front())) { + value.remove_prefix(1); + } + + return value; +} + +/// @ingroup http +/// The view with any trailing optional whitespace removed (RFC 9110 +/// Section 5.6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::http_trim_trailing_ows("x ") == "x"); +/// ``` +inline auto http_trim_trailing_ows(std::string_view value) noexcept + -> std::string_view { + while (!value.empty() && http_is_ows(value.back())) { + value.remove_suffix(1); + } + + return value; +} + +/// @ingroup http +/// Scan a quoted-string (RFC 9110 Section 5.6.4) whose opening double quote is +/// at position in input, returning the position just past the closing quote, or +/// no value when it is malformed. The unescaped content is written to value, +/// borrowing from input when it carries no quoted-pair and appending to storage +/// otherwise, whose capacity should be reserved up front so that earlier views +/// into it stay valid. Control characters other than horizontal tab are +/// rejected as a header-injection defense. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// std::string_view value; +/// const auto next{sourcemeta::core::http_scan_quoted_string( +/// R"("hello")", 0, storage, value)}; +/// assert(next.has_value()); +/// assert(value == "hello"); +/// ``` +SOURCEMETA_CORE_HTTP_EXPORT +auto http_scan_quoted_string(const std::string_view input, + const std::size_t position, std::string &storage, + std::string_view &value) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/http/parse_bearer.cc b/vendor/core/src/core/http/parse_bearer.cc index cb6c8bca..3c676112 100644 --- a/vendor/core/src/core/http/parse_bearer.cc +++ b/vendor/core/src/core/http/parse_bearer.cc @@ -5,41 +5,6 @@ #include // std::size_t #include // std::string_view -namespace { - -auto is_b64token_character(const char character) noexcept -> bool { - return (character >= 'A' && character <= 'Z') || - (character >= 'a' && character <= 'z') || - (character >= '0' && character <= '9') || character == '-' || - character == '.' || character == '_' || character == '~' || - character == '+' || character == '/'; -} - -// RFC 6750 §2.1: b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / -// "/" ) *"=". At least one character from the alphabet, followed by optional -// trailing padding. -auto is_b64token(const std::string_view token) noexcept -> bool { - std::size_t position{0}; - while (position < token.size() && is_b64token_character(token[position])) { - ++position; - } - - if (position == 0) { - return false; - } - - while (position < token.size()) { - if (token[position] != '=') { - return false; - } - ++position; - } - - return true; -} - -} // namespace - namespace sourcemeta::core { auto http_parse_bearer(const std::string_view authorization) noexcept @@ -58,7 +23,7 @@ auto http_parse_bearer(const std::string_view authorization) noexcept const auto token{http_trim_trailing_ows(http_trim_leading_ows( http_subview(authorization, scheme.size() + 1, authorization.size() - scheme.size() - 1)))}; - if (!is_b64token(token)) { + if (!http_is_b64token(token)) { return {}; } diff --git a/vendor/core/src/core/http/scan_quoted_string.cc b/vendor/core/src/core/http/scan_quoted_string.cc new file mode 100644 index 00000000..c0da0590 --- /dev/null +++ b/vendor/core/src/core/http/scan_quoted_string.cc @@ -0,0 +1,80 @@ +#include + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { + +// RFC 9110 Section 5.6.4: qdtext and the escaped octet of a quoted-pair both +// admit HTAB, SP, the visible characters, and obs-text, so every other control +// character is rejected. The double quote and the backslash are handled by the +// caller before this check +auto http_is_quoted_content(const char character) noexcept -> bool { + const auto byte{static_cast(character)}; + return byte == 0x09 || (byte >= 0x20 && byte != 0x7f); +} + +} // namespace + +namespace sourcemeta::core { + +auto http_scan_quoted_string(const std::string_view input, + const std::size_t position, std::string &storage, + std::string_view &value) + -> std::optional { + if (position >= input.size() || input[position] != '"') { + return std::nullopt; + } + + const std::size_t content_start{position + 1}; + std::size_t scan{content_start}; + bool escaped{false}; + while (scan < input.size()) { + const char character{input[scan]}; + if (character == '\\') { + if (scan + 1 >= input.size() || + !http_is_quoted_content(input[scan + 1])) { + return std::nullopt; + } + + escaped = true; + scan += 2; + continue; + } + + if (character == '"') { + if (!escaped) { + value = input.substr(content_start, scan - content_start); + } else { + const std::size_t base{storage.size()}; + storage.reserve(base + (scan - content_start)); + std::size_t index{content_start}; + while (index < scan) { + if (input[index] == '\\') { + storage.push_back(input[index + 1]); + index += 2; + } else { + storage.push_back(input[index]); + index += 1; + } + } + + value = std::string_view{storage}.substr(base); + } + + return scan + 1; + } + + if (!http_is_quoted_content(character)) { + return std::nullopt; + } + + scan += 1; + } + + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/http/serialize_cookie.cc b/vendor/core/src/core/http/serialize_cookie.cc index 008b653a..21e342c3 100644 --- a/vendor/core/src/core/http/serialize_cookie.cc +++ b/vendor/core/src/core/http/serialize_cookie.cc @@ -1,6 +1,5 @@ #include #include -#include #include // std::array #include // std::to_chars @@ -14,31 +13,6 @@ namespace { -auto is_token_character(const char character) noexcept -> bool { - // RFC 9110 §5.6.2: tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / - // "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA - switch (character) { - case '!': - case '#': - case '$': - case '%': - case '&': - case '\'': - case '*': - case '+': - case '-': - case '.': - case '^': - case '_': - case '`': - case '|': - case '~': - return true; - default: - return sourcemeta::core::is_alphanum(character); - } -} - auto is_cookie_octet(const char character) noexcept -> bool { // RFC 6265 §4.1.1: cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / // %x5D-7E, that is US-ASCII excluding CTLs, whitespace, DQUOTE, comma, @@ -57,20 +31,6 @@ auto is_attribute_octet(const char character) noexcept -> bool { return value >= 0x20 && value <= 0x7E && character != ';'; } -auto is_token(const std::string_view value) noexcept -> bool { - if (value.empty()) { - return false; - } - - for (const auto character : value) { - if (!is_token_character(character)) { - return false; - } - } - - return true; -} - auto is_cookie_value(const std::string_view value) noexcept -> bool { // RFC 6265 §4.1.1: cookie-value is either bare cookie-octets or the same // wrapped in a matched pair of double quotes @@ -160,7 +120,8 @@ auto required_size(const sourcemeta::core::HTTPCookie &cookie, namespace sourcemeta::core { auto http_cookie_valid(const HTTPCookie &cookie) -> bool { - if (!is_token(cookie.name) || !is_cookie_value(cookie.value)) { + if (!sourcemeta::core::http_is_token(cookie.name) || + !is_cookie_value(cookie.value)) { return false; } diff --git a/vendor/core/src/core/jose/CMakeLists.txt b/vendor/core/src/core/jose/CMakeLists.txt index 2d11654c..ea752575 100644 --- a/vendor/core/src/core/jose/CMakeLists.txt +++ b/vendor/core/src/core/jose/CMakeLists.txt @@ -2,7 +2,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jose PRIVATE_HEADERS algorithm.h error.h jwk.h jwk_private.h jwks.h jwks_provider.h jwt.h sign.h verify.h SOURCES jose_algorithm.cc jose_jwk.cc jose_jwk_private.cc jose_jwks.cc - jose_jwks_provider.cc + jose_jwks_provider.cc jose_jwk_thumbprint.cc jose_jwt.cc jose_jwt_check_claims.cc jose_jws_verify_signature.cc jose_jwt_verify_signature.cc jose_jwt_verify.cc jose_jws_sign.cc jose_jwt_sign.cc jose_key.h) diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h index 43d41b27..bef1d3da 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h @@ -61,6 +61,38 @@ SOURCEMETA_CORE_JOSE_EXPORT auto to_jws_algorithm(const std::string_view value) noexcept -> std::optional; +/// @ingroup jose +/// Map a JSON Web Signature algorithm to its `alg` value, the inverse of +/// parsing (RFC 7515 Section 4.1.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jws_algorithm_name( +/// sourcemeta::core::JWSAlgorithm::ES256) == "ES256"); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jws_algorithm_name(const JWSAlgorithm algorithm) noexcept + -> std::string_view; + +/// @ingroup jose +/// Whether an algorithm is an asymmetric digital signature algorithm rather +/// than a symmetric message authentication code (RFC 7518 Section 3.1). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jws_algorithm_is_asymmetric( +/// sourcemeta::core::JWSAlgorithm::ES256)); +/// assert(!sourcemeta::core::jws_algorithm_is_asymmetric( +/// sourcemeta::core::JWSAlgorithm::HS256)); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h index 634b015c..cd13d7cf 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h @@ -114,6 +114,14 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWK { return this->secret_; } + /// Serialize the public part of this key as a JSON Web Key (RFC 7517), + /// returning no value for a symmetric key, which has no public form. + [[nodiscard]] auto public_jwk() const -> std::optional; + + /// The SHA-256 JSON Web Key thumbprint of this key (RFC 7638), + /// base64url-encoded. + [[nodiscard]] auto thumbprint() const -> std::optional; + private: JWK() = default; static auto parse(const JSON &value, JWK &result) -> bool; @@ -127,6 +135,11 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWK { std::string curve_; std::optional public_key_; std::string secret_; + std::string modulus_; + std::string exponent_; + std::string coordinate_x_; + std::string coordinate_y_; + std::string public_point_; #if defined(_MSC_VER) #pragma warning(default : 4251) #endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h index 7d60b98d..6ac81e71 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h @@ -64,8 +64,8 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { [[nodiscard]] static auto from(JSON &&value) -> std::optional; /// Parse a private key from an unencrypted PKCS#8 PEM document (RFC 5958), - /// returning no value on invalid input. The key identifier, algorithm, and - /// curve are left unset, as a PEM document carries no such metadata. + /// returning no value on invalid input. The key identifier and algorithm are + /// left unset, as a PEM document carries no such metadata. [[nodiscard]] static auto from_pem(const std::string_view pem) -> std::optional; @@ -89,7 +89,9 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { // Elliptic curve keys (RFC 7518 Section 6.2) and octet key pairs (RFC 8037 // Section 2) carry a curve name, which the elliptic curve algorithms pin to - // exactly one curve. It is empty for keys parsed from a PEM document + // exactly one curve. A key parsed from a PEM document carries it once its + // public part has been recovered, and it stays empty for a key that carries + // no curve, such as an RSA or symmetric key /// The curve this key is pinned to, empty when it carries none. [[nodiscard]] auto curve() const noexcept -> std::string_view { return this->curve_; @@ -111,6 +113,16 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { return this->secret_; } + /// Serialize the public part of this key as a JSON Web Key (RFC 7517), + /// returning no value for a symmetric key, which has no public form, or for a + /// key parsed from a PEM document whose public part could not be recovered. + [[nodiscard]] auto public_jwk() const -> std::optional; + + /// The SHA-256 JSON Web Key thumbprint of this key (RFC 7638), + /// base64url-encoded, returning no value for a key whose public part could + /// not be recovered. + [[nodiscard]] auto thumbprint() const -> std::optional; + private: JWKPrivate() = default; static auto parse(const JSON &value, JWKPrivate &result) -> bool; @@ -124,6 +136,11 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { std::string curve_; std::optional private_key_; std::string secret_; + std::string modulus_; + std::string exponent_; + std::string coordinate_x_; + std::string coordinate_y_; + std::string public_point_; #if defined(_MSC_VER) #pragma warning(default : 4251) #endif diff --git a/vendor/core/src/core/jose/jose_algorithm.cc b/vendor/core/src/core/jose/jose_algorithm.cc index f74f8bb7..8044a08c 100644 --- a/vendor/core/src/core/jose/jose_algorithm.cc +++ b/vendor/core/src/core/jose/jose_algorithm.cc @@ -2,6 +2,7 @@ #include // std::optional, std::nullopt #include // std::string_view +#include // std::unreachable namespace sourcemeta::core { @@ -38,4 +39,61 @@ auto to_jws_algorithm(const std::string_view value) noexcept } } +auto jws_algorithm_name(const JWSAlgorithm algorithm) noexcept + -> std::string_view { + switch (algorithm) { + case JWSAlgorithm::RS256: + return "RS256"; + case JWSAlgorithm::RS384: + return "RS384"; + case JWSAlgorithm::RS512: + return "RS512"; + case JWSAlgorithm::PS256: + return "PS256"; + case JWSAlgorithm::PS384: + return "PS384"; + case JWSAlgorithm::PS512: + return "PS512"; + case JWSAlgorithm::ES256: + return "ES256"; + case JWSAlgorithm::ES384: + return "ES384"; + case JWSAlgorithm::ES512: + return "ES512"; + case JWSAlgorithm::EdDSA: + return "EdDSA"; + case JWSAlgorithm::HS256: + return "HS256"; + case JWSAlgorithm::HS384: + return "HS384"; + case JWSAlgorithm::HS512: + return "HS512"; + } + + std::unreachable(); +} + +auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept + -> bool { + switch (algorithm) { + case JWSAlgorithm::RS256: + case JWSAlgorithm::RS384: + case JWSAlgorithm::RS512: + case JWSAlgorithm::PS256: + case JWSAlgorithm::PS384: + case JWSAlgorithm::PS512: + case JWSAlgorithm::ES256: + case JWSAlgorithm::ES384: + case JWSAlgorithm::ES512: + case JWSAlgorithm::EdDSA: + return true; + case JWSAlgorithm::HS256: + case JWSAlgorithm::HS384: + case JWSAlgorithm::HS512: + return false; + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwk.cc b/vendor/core/src/core/jose/jose_jwk.cc index 79d5158e..de344187 100644 --- a/vendor/core/src/core/jose/jose_jwk.cc +++ b/vendor/core/src/core/jose/jose_jwk.cc @@ -92,8 +92,9 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { } result.type_ = Type::RSA; - parsed_key = - make_rsa_public_key(decoded_modulus.value(), decoded_exponent.value()); + result.modulus_ = decoded_modulus.value(); + result.exponent_ = decoded_exponent.value(); + parsed_key = make_rsa_public_key(result.modulus_, result.exponent_); } else if (key_type_value == "EC") { // A public key must not carry the private parameter (RFC 7518 Section // 6.2.2) @@ -126,8 +127,10 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { result.type_ = Type::EllipticCurve; result.curve_ = curve->to_string(); + result.coordinate_x_ = decoded_x.value(); + result.coordinate_y_ = decoded_y.value(); parsed_key = make_ec_public_key(jwk_to_elliptic_curve(result.curve_), - decoded_x.value(), decoded_y.value()); + result.coordinate_x_, result.coordinate_y_); } else if (key_type_value == "OKP") { // A public key must not carry the private parameter (RFC 8037 Section 2) if (value.try_at("d", HASH_D) != nullptr) { @@ -154,8 +157,9 @@ auto JWK::parse(const JSON &value, JWK &result) -> bool { result.type_ = Type::OctetKeyPair; result.curve_ = curve->to_string(); + result.public_point_ = decoded_public_key.value(); parsed_key = make_eddsa_public_key(jwk_to_edwards_curve(result.curve_), - decoded_public_key.value()); + result.public_point_); } else if (key_type_value == "oct") { const auto *key_value{value.try_at("k", HASH_K)}; if (key_value == nullptr || !key_value->is_string()) { diff --git a/vendor/core/src/core/jose/jose_jwk_private.cc b/vendor/core/src/core/jose/jose_jwk_private.cc index 378a15eb..a164e6f4 100644 --- a/vendor/core/src/core/jose/jose_jwk_private.cc +++ b/vendor/core/src/core/jose/jose_jwk_private.cc @@ -109,6 +109,8 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { } result.type_ = Type::RSA; + result.modulus_ = modulus.value(); + result.exponent_ = public_exponent.value(); parsed_key = make_rsa_private_key(modulus.value(), public_exponent.value(), private_exponent.value(), prime1.value(), prime2.value(), exponent1.value(), @@ -140,6 +142,8 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { result.type_ = Type::EllipticCurve; result.curve_ = curve->to_string(); + result.coordinate_x_ = coordinate_x.value(); + result.coordinate_y_ = coordinate_y.value(); parsed_key = make_ec_private_key(jwk_to_elliptic_curve(result.curve_), scalar.value(), coordinate_x.value(), coordinate_y.value()); @@ -166,6 +170,7 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { result.type_ = Type::OctetKeyPair; result.curve_ = curve->to_string(); + result.public_point_ = public_key.value(); parsed_key = make_edwards_private_key(jwk_to_edwards_curve(result.curve_), seed.value()); } else if (key_type_value == "oct") { @@ -215,6 +220,48 @@ auto JWKPrivate::parse(const JSON &value, JWKPrivate &result) -> bool { // reuses it. A key that cannot be turned into one stays null and simply fails // to sign result.private_key_ = std::move(parsed_key); + + // A private JWK carries the public key beside the private key (RFC 7518 + // Section 6.2.2, RFC 8037 Section 2). For the curve types, reject a document + // whose stored public part does not correspond to its private part, verified + // through a signature the private key produces and the public key checks. For + // elliptic curve keys the platform key construction additionally binds the + // coordinates to the scalar, closing the gap where a crafted point could + // satisfy one precomputed signature on a deterministic backend. RSA + // correspondence is not checked here, so an inconsistent RSA key instead + // stays null and fails to sign + if (result.type_ == Type::EllipticCurve || + result.type_ == Type::OctetKeyPair) { + if (!result.private_key_.has_value()) { + return false; + } + + using namespace std::string_view_literals; + const auto probe{"sourcemeta core JWK keypair consistency probe"sv}; + bool consistent{false}; + if (result.type_ == Type::EllipticCurve) { + const auto public_key{ + make_ec_public_key(jwk_to_elliptic_curve(result.curve_), + result.coordinate_x_, result.coordinate_y_)}; + const auto signature{ecdsa_sign(result.private_key_.value(), + SignatureHashFunction::SHA256, probe)}; + consistent = + public_key.has_value() && signature.has_value() && + ecdsa_verify(public_key.value(), SignatureHashFunction::SHA256, probe, + signature.value()); + } else { + const auto public_key{make_eddsa_public_key( + jwk_to_edwards_curve(result.curve_), result.public_point_)}; + const auto signature{eddsa_sign(result.private_key_.value(), probe)}; + consistent = public_key.has_value() && signature.has_value() && + eddsa_verify(public_key.value(), probe, signature.value()); + } + + if (!consistent) { + return false; + } + } + return true; } @@ -252,6 +299,43 @@ auto JWKPrivate::from_pem(const std::string_view pem) } result.private_key_ = std::move(parsed_key); + + // A PEM document carries no encoded public members, so recover them from the + // parsed key to support public serialization and thumbprints + const auto public_key{derive_public_key(result.private_key_.value())}; + if (public_key.has_value()) { + switch (public_key.value().type()) { + case PublicKey::Type::RSA: { + const auto components{rsa_public_components(public_key.value())}; + if (components.has_value()) { + result.modulus_ = components.value().modulus; + result.exponent_ = components.value().exponent; + } + + break; + } + case PublicKey::Type::EllipticCurve: { + const auto components{ec_public_components(public_key.value())}; + if (components.has_value()) { + result.curve_ = elliptic_curve_to_jwk(components.value().curve); + result.coordinate_x_ = components.value().x; + result.coordinate_y_ = components.value().y; + } + + break; + } + case PublicKey::Type::Edwards: { + const auto components{edwards_public_components(public_key.value())}; + if (components.has_value()) { + result.curve_ = edwards_curve_to_jwk(components.value().curve); + result.public_point_ = components.value().point; + } + + break; + } + } + } + return result; } diff --git a/vendor/core/src/core/jose/jose_jwk_thumbprint.cc b/vendor/core/src/core/jose/jose_jwk_thumbprint.cc new file mode 100644 index 00000000..323356c4 --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwk_thumbprint.cc @@ -0,0 +1,142 @@ +#include +#include + +#include +#include + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { + +// The minimal big-endian magnitude of an integer, so the RSA members serialize +// and thumbprint as the Base64urlUInt form RFC 7518 Section 2 requires, with no +// leading zero octets, regardless of how the source JWK padded them. A single +// octet is kept so the value zero stays a valid member rather than an empty one +auto minimal(std::string_view value) -> std::string_view { + while (value.size() > 1 && value.front() == '\x00') { + value.remove_prefix(1); + } + + return value; +} + +// The public members are mutually exclusive by construction, so a non-empty +// modulus identifies an RSA key, a non-empty coordinate an elliptic curve key, +// and a non-empty point an octet key pair. All empty means a symmetric key or +// a key whose public part could not be recovered, which has no public form +auto build_public_jwk(const std::string_view curve, + const std::string_view modulus, + const std::string_view exponent, + const std::string_view coordinate_x, + const std::string_view coordinate_y, + const std::string_view point) + -> std::optional { + using sourcemeta::core::base64url_encode; + using sourcemeta::core::JSON; + if (!modulus.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"RSA"}}); + object.assign("n", JSON{base64url_encode(minimal(modulus))}); + object.assign("e", JSON{base64url_encode(minimal(exponent))}); + return object; + } + + if (!coordinate_x.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"EC"}}); + object.assign("crv", JSON{std::string{curve}}); + object.assign("x", JSON{base64url_encode(coordinate_x)}); + object.assign("y", JSON{base64url_encode(coordinate_y)}); + return object; + } + + if (!point.empty()) { + auto object{JSON::make_object()}; + object.assign("kty", JSON{std::string{"OKP"}}); + object.assign("crv", JSON{std::string{curve}}); + object.assign("x", JSON{base64url_encode(point)}); + return object; + } + + return std::nullopt; +} + +// RFC 7638 Section 3: the hash input is a JSON object of the required members +// only, in lexicographic member order, with no whitespace, the values being +// the base64url component strings, which contain no character JSON must escape +auto build_thumbprint( + const std::string_view curve, const std::string_view modulus, + const std::string_view exponent, const std::string_view coordinate_x, + const std::string_view coordinate_y, const std::string_view point, + const std::string_view secret) -> std::optional { + using sourcemeta::core::base64url_encode; + // The canonical form embeds the secret for a symmetric key, so it is held in + // wiping storage that clears itself on every growth and on the way out + sourcemeta::core::SecureString canonical; + if (!modulus.empty()) { + canonical.append(R"({"e":")"); + canonical.append(base64url_encode(minimal(exponent))); + canonical.append(R"(","kty":"RSA","n":")"); + canonical.append(base64url_encode(minimal(modulus))); + canonical.append(R"("})"); + } else if (!coordinate_x.empty()) { + canonical.append(R"({"crv":")"); + canonical.append(curve); + canonical.append(R"(","kty":"EC","x":")"); + canonical.append(base64url_encode(coordinate_x)); + canonical.append(R"(","y":")"); + canonical.append(base64url_encode(coordinate_y)); + canonical.append(R"("})"); + } else if (!point.empty()) { + canonical.append(R"({"crv":")"); + canonical.append(curve); + canonical.append(R"(","kty":"OKP","x":")"); + canonical.append(base64url_encode(point)); + canonical.append(R"("})"); + } else if (!secret.empty()) { + // RFC 7638 Section 3.2.1: the octet sequence thumbprint is over the secret, + // encoded straight into the wiping buffer so it leaves no intermediate copy + canonical.append(R"({"k":")"); + base64url_encode(secret, canonical); + canonical.append(R"(","kty":"oct"})"); + } else { + return std::nullopt; + } + + const auto digest{sourcemeta::core::sha256_digest(canonical)}; + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return base64url_encode(std::string_view{ + reinterpret_cast(digest.data()), digest.size()}); +} + +} // namespace + +namespace sourcemeta::core { + +auto JWK::public_jwk() const -> std::optional { + return build_public_jwk(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_); +} + +auto JWK::thumbprint() const -> std::optional { + return build_thumbprint(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_, this->secret_); +} + +auto JWKPrivate::public_jwk() const -> std::optional { + return build_public_jwk(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_); +} + +auto JWKPrivate::thumbprint() const -> std::optional { + return build_thumbprint(this->curve_, this->modulus_, this->exponent_, + this->coordinate_x_, this->coordinate_y_, + this->public_point_, this->secret_); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwt.cc b/vendor/core/src/core/jose/jose_jwt.cc index 3a297cbb..48496b1c 100644 --- a/vendor/core/src/core/jose/jose_jwt.cc +++ b/vendor/core/src/core/jose/jose_jwt.cc @@ -38,6 +38,19 @@ auto string_claim(const sourcemeta::core::JSON &object, return std::string_view{member->to_string()}; } +// The JSON layer preserves repeated members rather than collapsing them, so +// uniqueness is checked here +auto has_unique_members(const sourcemeta::core::JSON &object) -> bool { + std::unordered_set names; + for (const auto &entry : object.as_object()) { + if (!names.emplace(entry.first).second) { + return false; + } + } + + return true; +} + auto date_claim(const sourcemeta::core::JSON &object, const sourcemeta::core::JSON::StringView name, const sourcemeta::core::JSON::Object::hash_type hash) @@ -101,14 +114,12 @@ auto JWT::parse(const std::string_view input, JWT &result) -> bool { return false; } - // RFC 7515 Section 4: the header parameter names must be unique, so a header - // with a duplicate is rejected, since the JSON layer preserves repeated - // members rather than collapsing them - std::unordered_set header_parameters; - for (const auto ¶meter : header_json.value().as_object()) { - if (!header_parameters.emplace(parameter.first).second) { - return false; - } + // RFC 7515 Section 4: the header parameter names must be unique, and RFC 7519 + // Section 4: the claim names must be unique, so a duplicate in either the + // header or the payload is rejected (RFC 8725 Section 2.4) + if (!has_unique_members(header_json.value()) || + !has_unique_members(payload_json.value())) { + return false; } // The algorithm header parameter is required and must be a string (RFC 7515 diff --git a/vendor/core/src/core/jose/jose_key.h b/vendor/core/src/core/jose/jose_key.h index 6023213f..74033d20 100644 --- a/vendor/core/src/core/jose/jose_key.h +++ b/vendor/core/src/core/jose/jose_key.h @@ -90,6 +90,37 @@ inline auto jwk_to_edwards_curve(const std::string_view curve) noexcept } } +// The JWK curve name each elliptic curve carries (RFC 7518 Section 6.2.1.1), +// the reverse of the parsing mapping, so a key built without a curve name can +// recover it from the parsed material +inline auto elliptic_curve_to_jwk(const EllipticCurve curve) noexcept + -> std::string_view { + switch (curve) { + case EllipticCurve::P256: + return "P-256"; + case EllipticCurve::P384: + return "P-384"; + case EllipticCurve::P521: + return "P-521"; + } + + std::unreachable(); +} + +// The JWK curve name each Edwards curve carries (RFC 8037 Section 2), the +// reverse of the parsing mapping +inline auto edwards_curve_to_jwk(const EdwardsCurve curve) noexcept + -> std::string_view { + switch (curve) { + case EdwardsCurve::Ed25519: + return "Ed25519"; + case EdwardsCurve::Ed448: + return "Ed448"; + } + + std::unreachable(); +} + // The RSA algorithms only require an RSA key, each ECDSA algorithm is tied to a // specific curve (RFC 7518 Section 3.1), and the Edwards-curve algorithm // requires an octet key pair of either curve (RFC 8037 Section 3.1) diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_object.h b/vendor/core/src/core/json/include/sourcemeta/core/json_object.h index 44db7b90..49a8b991 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_object.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_object.h @@ -413,6 +413,54 @@ template class JSONObject { std::unreachable(); } + /// Try to access an object entry by its key name + [[nodiscard]] inline auto try_at(const Key &key, const hash_type key_hash) + -> mapped_type * { + assert(this->hash(key) == key_hash); + + // Move the perfect hash condition out of the loop for extra performance + if (this->hasher.is_perfect(key_hash)) { + for (auto &entry : this->data) { + if (entry.hash == key_hash && entry.first.size() == key.size()) { + return &entry.second; + } + } + } else { + for (auto &entry : this->data) { + if (entry.hash == key_hash && entry.first == key) { + return &entry.second; + } + } + } + + return nullptr; + } + + /// Try to access an object entry by its key name + template + requires std::same_as, KeyView> + [[nodiscard]] inline auto try_at(T key, const hash_type key_hash) + -> mapped_type * { + assert(this->hash(key) == key_hash); + + // Move the perfect hash condition out of the loop for extra performance + if (this->hasher.is_perfect(key_hash)) { + for (auto &entry : this->data) { + if (entry.hash == key_hash && entry.first.size() == key.size()) { + return &entry.second; + } + } + } else { + for (auto &entry : this->data) { + if (entry.hash == key_hash && entry.first == key) { + return &entry.second; + } + } + } + + return nullptr; + } + /// Try to access an object entry by its underlying positional index [[nodiscard]] inline auto try_at(const Key &key, const hash_type key_hash) const 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 55cd02c5..f1219176 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 @@ -1419,6 +1419,67 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { return object.try_at(key, hash); } + /// This method tries to retrieve a mutable object element by key. For + /// example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = + /// sourcemeta::core::parse_json("{ \"foo\": 1 }"); + /// auto result{document.try_at("foo")}; + /// assert(result); + /// result->into(sourcemeta::core::JSON{2}); + /// assert(document.at("foo").to_integer() == 2); + /// ``` + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto try_at(const String &key) + -> JSON * { + assert(this->is_object()); + auto &object{this->data_object}; + return object.try_at(key, object.hash(key)); + } + + /// This method tries to retrieve a mutable object element by string view key + template + requires std::same_as, StringView> + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto try_at(T key) -> JSON * { + assert(this->is_object()); + auto &object{this->data_object}; + return object.try_at(key, object.hash(key)); + } + + /// This method tries to retrieve a mutable object element given a + /// pre-calculated property hash. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = + /// sourcemeta::core::parse_json("{ \"foo\": 1 }"); + /// auto result{document.try_at("foo", + /// document.as_object().hash("foo"))}; + /// assert(result); + /// result->into(sourcemeta::core::JSON{2}); + /// assert(document.at("foo").to_integer() == 2); + /// ``` + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto + try_at(const String &key, const typename Object::hash_type hash) -> JSON * { + assert(this->is_object()); + return this->data_object.try_at(key, hash); + } + + /// This method tries to retrieve a mutable object element by string view key + /// given a pre-calculated property hash + template + requires std::same_as, StringView> + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto + try_at(T key, const typename Object::hash_type hash) -> JSON * { + assert(this->is_object()); + return this->data_object.try_at(key, hash); + } + /// Try to get a property, scanning from a caller-provided start offset. /// On hit, advances `start` past the found index. When looking up multiple /// keys in insertion order, each lookup hits on the first probe, making the diff --git a/vendor/core/src/core/jsonld/CMakeLists.txt b/vendor/core/src/core/jsonld/CMakeLists.txt index fbcc41e4..6effb504 100644 --- a/vendor/core/src/core/jsonld/CMakeLists.txt +++ b/vendor/core/src/core/jsonld/CMakeLists.txt @@ -8,7 +8,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jsonld jsonld_value_compaction.cc jsonld_compaction.cc jsonld_node_map.cc jsonld_flatten.cc jsonld_materialize.cc - jsonld_algorithms.h jsonld_keywords.h) + jsonld_algorithms.h jsonld_keywords.h jsonld_serialise.h) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME jsonld) 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 4a0383fa..d1eb1e1f 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 @@ -8,11 +8,10 @@ #include #include -#include // std::uint8_t -#include // std::optional -#include // std::unordered_map -#include // std::variant -#include // std::vector +#include // std::uint8_t +#include // std::optional +#include // std::variant +#include // std::vector namespace sourcemeta::core { @@ -53,7 +52,9 @@ struct JSONLDNode { /// JSON value, a language may carry a direction, and the JSON flag preserves an /// opaque JSON literal verbatim. struct JSONLDLiteral { - /// The literal datatype, defaulting to the native type of the value. + /// The literal datatype, defaulting to the native type of the value. An + /// explicit datatype carries a native number or boolean as its canonical + /// string lexical form. std::optional datatype{}; /// The language tag of the literal. std::optional language{}; @@ -108,27 +109,36 @@ struct JSONLDDescriptor { }; /// @ingroup jsonld -/// A resolved mapping of instance positions to their JSON-LD semantics, keyed -/// by a JSON Pointer of the given kind +/// An instance position paired with its JSON-LD semantics +template struct JSONLDBasicAnnotation { + /// The instance position the annotation describes. + PointerT pointer{}; + /// The JSON-LD semantics of the position. + JSONLDDescriptor descriptor{}; +}; + +/// @ingroup jsonld +/// A flat collection of annotated instance positions, in any order. When more +/// than one entry describes the same position, the first one wins. template -using JSONLDBasicAnnotationMap = - std::unordered_map; +using JSONLDBasicAnnotationList = std::vector>; /// @ingroup jsonld -/// A resolved annotation map keyed by an owning JSON Pointer -using JSONLDAnnotationMap = JSONLDBasicAnnotationMap; +/// An annotation list whose positions are owning JSON Pointers +using JSONLDAnnotationList = JSONLDBasicAnnotationList; /// @ingroup jsonld -/// A resolved annotation map keyed by a non-owning weak JSON Pointer. The keys -/// reference strings owned elsewhere that must outlive any materialization -/// call. -using JSONLDWeakAnnotationMap = JSONLDBasicAnnotationMap; +/// An annotation list whose positions are non-owning weak JSON Pointers. The +/// positions reference strings owned elsewhere that must outlive any +/// materialization call. +using JSONLDWeakAnnotationList = JSONLDBasicAnnotationList; /// @ingroup jsonld /// -/// Materialize an instance into expanded JSON-LD using an annotation map that -/// assigns JSON-LD semantics to instance positions. The result is always a JSON -/// array. For example: +/// 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: /// /// ```cpp /// #include @@ -138,30 +148,34 @@ using JSONLDWeakAnnotationMap = JSONLDBasicAnnotationMap; /// const auto instance{sourcemeta::core::parse_json( /// R"({ "name": "Sourcemeta" })")}; /// -/// sourcemeta::core::JSONLDAnnotationMap map; -/// map.emplace(sourcemeta::core::Pointer{}, -/// sourcemeta::core::JSONLDDescriptor{ -/// {}, sourcemeta::core::JSONLDNode{ -/// "https://example.com/org", {}, false }}); -/// map.emplace(sourcemeta::core::Pointer{"name"}, -/// sourcemeta::core::JSONLDDescriptor{ -/// { { "https://schema.org/name", false } }, -/// sourcemeta::core::JSONLDLiteral{}}); +/// sourcemeta::core::JSONLDAnnotationList annotations; +/// annotations.push_back( +/// {sourcemeta::core::Pointer{}, +/// sourcemeta::core::JSONLDDescriptor{ +/// {}, sourcemeta::core::JSONLDNode{ +/// "https://example.com/org", {}, false }}}); +/// annotations.push_back( +/// {sourcemeta::core::Pointer{"name"}, +/// sourcemeta::core::JSONLDDescriptor{ +/// { { "https://schema.org/name", false } }, +/// sourcemeta::core::JSONLDLiteral{}}}); /// -/// const auto expanded{sourcemeta::core::jsonld_materialize(instance, map)}; +/// const auto expanded{ +/// sourcemeta::core::jsonld_materialize(instance, annotations)}; /// sourcemeta::core::prettify(expanded, std::cout); /// std::cout << std::endl; /// ``` SOURCEMETA_CORE_JSONLD_EXPORT -auto jsonld_materialize(const JSON &instance, const JSONLDAnnotationMap &map) - -> JSON; +auto jsonld_materialize(const JSON &instance, + const JSONLDAnnotationList &annotations) -> JSON; /// @ingroup jsonld /// -/// Materialize an instance into expanded JSON-LD using a weak annotation map -/// whose keys are non-owning views into strings owned elsewhere. The backing -/// strings must outlive the call. The result is always a JSON array. For -/// example: +/// 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: /// /// ```cpp /// #include @@ -174,23 +188,26 @@ auto jsonld_materialize(const JSON &instance, const JSONLDAnnotationMap &map) /// /// const sourcemeta::core::JSON::String name_key{"name"}; /// -/// sourcemeta::core::JSONLDWeakAnnotationMap map; -/// map.emplace(sourcemeta::core::WeakPointer{}, -/// sourcemeta::core::JSONLDDescriptor{ -/// {}, sourcemeta::core::JSONLDNode{ -/// "https://example.com/org", {}, false }}); -/// map.emplace(sourcemeta::core::WeakPointer{std::cref(name_key)}, -/// sourcemeta::core::JSONLDDescriptor{ -/// { { "https://schema.org/name", false } }, -/// sourcemeta::core::JSONLDLiteral{}}); +/// sourcemeta::core::JSONLDWeakAnnotationList annotations; +/// annotations.push_back( +/// {sourcemeta::core::WeakPointer{}, +/// sourcemeta::core::JSONLDDescriptor{ +/// {}, sourcemeta::core::JSONLDNode{ +/// "https://example.com/org", {}, false }}}); +/// annotations.push_back( +/// {sourcemeta::core::WeakPointer{std::cref(name_key)}, +/// sourcemeta::core::JSONLDDescriptor{ +/// { { "https://schema.org/name", false } }, +/// sourcemeta::core::JSONLDLiteral{}}}); /// -/// const auto expanded{sourcemeta::core::jsonld_materialize(instance, map)}; +/// const auto expanded{ +/// sourcemeta::core::jsonld_materialize(instance, annotations)}; /// sourcemeta::core::prettify(expanded, std::cout); /// std::cout << std::endl; /// ``` SOURCEMETA_CORE_JSONLD_EXPORT auto jsonld_materialize(const JSON &instance, - const JSONLDWeakAnnotationMap &map) -> JSON; + const JSONLDWeakAnnotationList &annotations) -> JSON; } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jsonld/jsonld_materialize.cc b/vendor/core/src/core/jsonld/jsonld_materialize.cc index 7ed01d81..24bd629a 100644 --- a/vendor/core/src/core/jsonld/jsonld_materialize.cc +++ b/vendor/core/src/core/jsonld/jsonld_materialize.cc @@ -3,10 +3,11 @@ #include #include "jsonld_keywords.h" +#include "jsonld_serialise.h" -#include // std::ranges::sort, std::ranges::none_of -#include // assert -#include // std::size_t +#include // std::ranges::sort, std::ranges::stable_sort, std::ranges::unique, std::ranges::none_of +#include // assert +#include // std::size_t #include // std::reference_wrapper, std::cref #include // std::optional, std::nullopt #include // std::is_same_v @@ -18,18 +19,53 @@ namespace sourcemeta::core { namespace { +template +using AnnotationIndex = std::vector *>; + +// A contiguous run of sorted annotations whose positions are all extensions +// of, or equal to, the position currently being visited +template struct AnnotationRange { + typename AnnotationIndex::const_iterator begin; + typename AnnotationIndex::const_iterator end; +}; + +// The sub-run of annotations that belong to the child position the pointer +// currently names, advancing the scan iterator past it. Annotations sorting +// before the child correspond to positions the walk does not visit and are +// skipped for good. +template +auto child_range(typename AnnotationIndex::const_iterator &iterator, + const typename AnnotationIndex::const_iterator end, + const PointerT &pointer) -> AnnotationRange { + const auto depth{pointer.size() - 1}; + const auto &token{pointer.at(depth)}; + while (iterator != end && (*iterator)->pointer.at(depth) < token) { + iterator += 1; + } + const auto begin{iterator}; + while (iterator != end && (*iterator)->pointer.at(depth) == token) { + iterator += 1; + } + return {begin, iterator}; +} + template auto materialize_value(const JSON &value, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + AnnotationRange range, std::vector &standalone, const std::vector **matched_edges = nullptr) -> std::optional; template auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + const AnnotationRange &range, std::vector &standalone) -> void; +template +auto materialize_member(const JSON &value, PointerT &pointer, + const AnnotationRange &range, + std::vector &standalone) -> 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. template @@ -70,23 +106,24 @@ auto types_to_array(const std::vector &types) -> JSON { // The property array of node under the given predicate, creating it as needed. auto property_target(JSON &node, const JSON::StringView predicate) -> JSON & { - if (!node.defines(predicate)) { - node.assign_assume_new(JSON::String{predicate}, JSON::make_array()); + const auto hash{node.as_object().hash(predicate)}; + const auto existing{node.try_at(predicate, hash)}; + if (existing != nullptr) { + return *existing; } - return node.at(predicate); + return node.assign_assume_new(JSON::String{predicate}, JSON::make_array(), + hash); } // The property array nested under @reverse and the given predicate. auto reverse_target(JSON &node, const JSON::StringView predicate) -> JSON & { - if (!node.defines(KEYWORD_REVERSE, KEYWORD_REVERSE_HASH)) { - node.assign_assume_new(JSON::String{KEYWORD_REVERSE}, JSON::make_object(), - KEYWORD_REVERSE_HASH); - } - auto &reverse{node.at(KEYWORD_REVERSE, KEYWORD_REVERSE_HASH)}; - if (!reverse.defines(predicate)) { - reverse.assign_assume_new(JSON::String{predicate}, JSON::make_array()); - } - return reverse.at(predicate); + const auto existing{node.try_at(KEYWORD_REVERSE, KEYWORD_REVERSE_HASH)}; + auto &reverse{existing != nullptr + ? *existing + : node.assign_assume_new(JSON::String{KEYWORD_REVERSE}, + JSON::make_object(), + KEYWORD_REVERSE_HASH)}; + return property_target(reverse, predicate); } // Attach a value under a single edge. A set, represented as a bare array, @@ -119,18 +156,27 @@ auto attach(JSON &node, const std::vector &edges, JSON value) auto materialize_literal(const JSONLDLiteral &descriptor, const JSON &value) -> JSON { auto result{JSON::make_object()}; - result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, - KEYWORD_VALUE_HASH); if (descriptor.json) { + result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, + KEYWORD_VALUE_HASH); result.assign_assume_new(JSON::String{KEYWORD_TYPE}, JSON{KEYWORD_JSON}, KEYWORD_TYPE_HASH); return result; } if (descriptor.datatype.has_value()) { + auto lexical{ + typed_literal_lexical_form(value, descriptor.datatype.value())}; + result.assign_assume_new( + JSON::String{KEYWORD_VALUE}, + lexical.has_value() ? JSON{std::move(lexical).value()} : JSON{value}, + KEYWORD_VALUE_HASH); result.assign_assume_new(JSON::String{KEYWORD_TYPE}, JSON{descriptor.datatype.value()}, KEYWORD_TYPE_HASH); + } else { + result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{value}, + KEYWORD_VALUE_HASH); } if (descriptor.language.has_value()) { result.assign_assume_new(JSON::String{KEYWORD_LANGUAGE}, @@ -160,13 +206,16 @@ auto materialize_reference(const JSONLDReference &descriptor) -> JSON { template auto build_collection(const JSON &value, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + const AnnotationRange &range, std::vector &standalone, const bool ordered) -> JSON { auto elements{JSON::make_array()}; + auto iterator{range.begin}; for (std::size_t index = 0; index < value.size(); index += 1) { pointer.push_back(index); - auto element{materialize_value(value.at(index), pointer, map, standalone)}; + auto element{materialize_member(value.at(index), pointer, + child_range(iterator, range.end, pointer), + standalone)}; pointer.pop_back(); if (!element.has_value()) { continue; @@ -246,13 +295,15 @@ auto build_language_collection(const JSON &value) -> JSON { // The index keys carry no RDF and are dropped. template auto build_index_collection(const JSON &value, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + const AnnotationRange &range, std::vector &standalone) -> JSON { auto elements{JSON::make_array()}; + auto iterator{range.begin}; for (const auto key : sorted_keys(value)) { push_property(pointer, key.get()); - auto element{ - materialize_value(value.at(key.get()), pointer, map, standalone)}; + auto element{materialize_member(value.at(key.get()), pointer, + child_range(iterator, range.end, pointer), + standalone)}; pointer.pop_back(); if (!element.has_value()) { continue; @@ -270,10 +321,34 @@ auto build_index_collection(const JSON &value, PointerT &pointer, return elements; } +// 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. +template +auto materialize_member(const JSON &value, PointerT &pointer, + const AnnotationRange &range, + std::vector &standalone) -> std::optional { + const auto described{range.begin != range.end && + (*range.begin)->pointer.size() == pointer.size()}; + if (described || value.is_object()) { + return materialize_value(value, pointer, range, standalone); + } + + if (value.is_null()) { + return std::nullopt; + } + + if (value.is_array()) { + return build_collection(value, pointer, range, standalone, false); + } + + return materialize_literal(JSONLDLiteral{}, value); +} + template auto materialize_node(const JSONLDNode &descriptor, const JSON &value, - PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + PointerT &pointer, const AnnotationRange &range, std::vector &standalone) -> JSON { auto node{JSON::make_object()}; if (descriptor.id.has_value()) { @@ -298,7 +373,7 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, JSON{descriptor.id.value()}, KEYWORD_ID_HASH); } std::vector graph_nodes; - fill_node(inner, value, pointer, map, graph_nodes); + fill_node(inner, value, pointer, range, graph_nodes); auto graph{JSON::make_array()}; if (inner.object_size() > (descriptor.id.has_value() ? 1 : 0)) { graph.push_back(std::move(inner)); @@ -311,13 +386,13 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, return node; } - fill_node(node, value, pointer, map, standalone); + fill_node(node, value, pointer, range, standalone); return node; } template auto materialize_value(const JSON &value, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + AnnotationRange range, std::vector &standalone, const std::vector **matched_edges) -> std::optional { @@ -329,14 +404,16 @@ auto materialize_value(const JSON &value, PointerT &pointer, return std::nullopt; } - const auto iterator{map.find(pointer)}; - if (iterator == map.cend()) { + // 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 || + (*range.begin)->pointer.size() != pointer.size()) { // An undescribed object with described descendants becomes an anonymous // blank node so its children have a subject. Anything else is not // annotated. - if (value.is_object()) { + if (value.is_object() && range.begin != range.end) { auto node{JSON::make_object()}; - fill_node(node, value, pointer, map, standalone); + fill_node(node, value, pointer, range, standalone); if (node.empty()) { return std::nullopt; } @@ -345,13 +422,14 @@ auto materialize_value(const JSON &value, PointerT &pointer, return std::nullopt; } - const auto &descriptor{iterator->second}; + const auto &descriptor{(*range.begin)->descriptor}; + 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, map, standalone); + pointer, range, standalone); } if (std::holds_alternative(descriptor.value)) { return materialize_literal(std::get(descriptor.value), @@ -368,7 +446,7 @@ auto materialize_value(const JSON &value, PointerT &pointer, if (!value.is_array()) { return std::nullopt; } - return build_collection(value, pointer, map, standalone, + return build_collection(value, pointer, range, standalone, collection.container == JSONLDContainer::List); case JSONLDContainer::Language: assert(value.is_object()); @@ -379,7 +457,7 @@ auto materialize_value(const JSON &value, PointerT &pointer, return build_language_collection(value); case JSONLDContainer::Index: assert(value.is_object()); - return build_index_collection(value, pointer, map, standalone); + return build_index_collection(value, pointer, range, standalone); } std::unreachable(); @@ -387,7 +465,7 @@ auto materialize_value(const JSON &value, PointerT &pointer, template auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, - const JSONLDBasicAnnotationMap &map, + const AnnotationRange &range, std::vector &standalone) -> void { std::vector> keys; keys.reserve(instance_object.object_size()); @@ -398,11 +476,13 @@ auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, return left.get() < right.get(); }); + auto iterator{range.begin}; for (const auto key : keys) { push_property(pointer, key.get()); const std::vector *edges{nullptr}; - auto child_value{materialize_value(instance_object.at(key.get()), pointer, - map, standalone, &edges)}; + auto child_value{materialize_value( + instance_object.at(key.get()), pointer, + child_range(iterator, range.end, pointer), standalone, &edges)}; pointer.pop_back(); if (!child_value.has_value()) { continue; @@ -424,10 +504,28 @@ auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, template auto materialize_root(const JSON &instance, - const JSONLDBasicAnnotationMap &map) -> JSON { + const JSONLDBasicAnnotationList &annotations) + -> JSON { + AnnotationIndex index; + index.reserve(annotations.size()); + for (const auto &annotation : annotations) { + index.push_back(&annotation); + } + + std::ranges::stable_sort(index, + [](const auto *left, const auto *right) -> bool { + return left->pointer < right->pointer; + }); + const auto duplicates{std::ranges::unique( + index, [](const auto *left, const auto *right) -> bool { + return left->pointer == right->pointer; + })}; + index.erase(duplicates.begin(), duplicates.end()); + std::vector standalone; PointerT pointer; - auto root{materialize_value(instance, pointer, map, standalone)}; + auto root{materialize_value(instance, pointer, {index.cbegin(), index.cend()}, + standalone)}; auto result{JSON::make_array()}; if (root.has_value()) { @@ -454,14 +552,14 @@ auto materialize_root(const JSON &instance, } // namespace -auto jsonld_materialize(const JSON &instance, const JSONLDAnnotationMap &map) - -> JSON { - return materialize_root(instance, map); +auto jsonld_materialize(const JSON &instance, + const JSONLDAnnotationList &annotations) -> JSON { + return materialize_root(instance, annotations); } auto jsonld_materialize(const JSON &instance, - const JSONLDWeakAnnotationMap &map) -> JSON { - return materialize_root(instance, map); + const JSONLDWeakAnnotationList &annotations) -> JSON { + return materialize_root(instance, annotations); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jsonld/jsonld_serialise.h b/vendor/core/src/core/jsonld/jsonld_serialise.h new file mode 100644 index 00000000..b186878c --- /dev/null +++ b/vendor/core/src/core/jsonld/jsonld_serialise.h @@ -0,0 +1,245 @@ +#ifndef SOURCEMETA_CORE_JSONLD_SERIALISE_H_ +#define SOURCEMETA_CORE_JSONLD_SERIALISE_H_ + +#include +#include + +#include // std::copy +#include // std::array +#include // assert +#include // std::to_chars, std::from_chars, std::chars_format +#include // std::size_t +#include // std::int32_t +#include // std::optional, std::nullopt +#include // std::unreachable + +// Serialisers that turn a native number or boolean into the string lexical +// form of an RDF literal when a position carries an explicit datatype. Plain +// JSON stringification is not enough for two reasons. It picks the shortest +// round-trip spelling in whichever notation is shorter, so magnitudes like +// 0.00001 or 1e21 come out in scientific notation, which is outside the +// lexical space of exponent-free datatypes like xsd:decimal (XSD 1.1 Part 2 +// Section 3.3.3). And for xsd:double and xsd:float the output must be the +// exact canonical form that conforming RDF conversion derives from native +// numbers (JSON-LD 1.1 API Section 8.6), so that the literal terms consumers +// obtain stay byte-identical to the native behaviour they get today + +namespace sourcemeta::core { + +inline constexpr JSON::StringView DATATYPE_XSD_DOUBLE{ + "http://www.w3.org/2001/XMLSchema#double"}; +inline constexpr JSON::StringView DATATYPE_XSD_FLOAT{ + "http://www.w3.org/2001/XMLSchema#float"}; + +// Both floating point datatypes take the scientific canonical form +inline auto is_floating_point_datatype(const JSON::StringView datatype) + -> bool { + return datatype == DATATYPE_XSD_DOUBLE || datatype == DATATYPE_XSD_FLOAT; +} + +// The double form of JSON-LD 1.1 API Section 8.6, where the mantissa is +// "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 +// 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"; + } + + std::array buffer{}; + const auto conversion{std::to_chars(buffer.data(), + buffer.data() + buffer.size(), value, + std::chars_format::scientific, 15)}; + assert(conversion.ec == std::errc{}); + const JSON::StringView digits{ + buffer.data(), static_cast(conversion.ptr - buffer.data())}; + const auto exponent_marker{digits.find('e')}; + assert(exponent_marker != JSON::StringView::npos); + + // The exponent characters are copied out first, as the assembled form is + // written over the region they occupy + auto exponent{digits.substr(exponent_marker + 1)}; + const bool negative_exponent{exponent.front() == '-'}; + if (negative_exponent || exponent.front() == '+') { + exponent.remove_prefix(1); + } + const auto first_significant_exponent{exponent.find_first_not_of('0')}; + std::array exponent_digits{}; + std::size_t exponent_size{0}; + if (first_significant_exponent == JSON::StringView::npos) { + exponent_digits[exponent_size] = '0'; + exponent_size += 1; + } else { + for (const auto character : exponent.substr(first_significant_exponent)) { + exponent_digits[exponent_size] = character; + exponent_size += 1; + } + } + + const auto mantissa{digits.substr(0, exponent_marker)}; + const auto last_significant{mantissa.find_last_not_of('0')}; + assert(last_significant != JSON::StringView::npos); + const auto mantissa_size{mantissa.at(last_significant) == '.' + ? last_significant + 2 + : last_significant + 1}; + auto *cursor{buffer.data() + mantissa_size}; + *cursor = 'E'; + cursor += 1; + if (negative_exponent) { + *cursor = '-'; + cursor += 1; + } + cursor = std::copy(exponent_digits.data(), + exponent_digits.data() + exponent_size, cursor); + return {buffer.data(), static_cast(cursor - buffer.data())}; +} + +// The shortest decimal expansion that parses back to the same value, with no +// exponent so that it stays within the lexical space of datatypes that do not +// admit scientific notation +inline auto fixed_lexical_form(const double value) -> JSON::String { + std::array buffer{}; + const auto conversion{std::to_chars(buffer.data(), + buffer.data() + buffer.size(), value, + std::chars_format::fixed)}; + assert(conversion.ec == std::errc{}); + return {buffer.data(), + static_cast(conversion.ptr - buffer.data())}; +} + +// The most extreme decimal exponent that still expands to an exponent-free +// spelling. A tiny exponent notation input would otherwise demand an +// expansion of up to two billion digits, so magnitudes beyond this bound +// produce no result and the native value passes through untouched +inline constexpr std::int64_t MAXIMUM_EXPANSION_EXPONENT{10000}; + +// The exact exponent-free expansion of an arbitrary precision decimal, with +// trailing zeros stripped so that the spelling is minimal. The expansion is +// assembled once into a preallocated result out of views over the scientific +// form +inline auto fixed_lexical_form(const Decimal &value) + -> std::optional { + assert(value.is_finite()); + if (!value.is_zero()) { + const auto magnitude{value.logb().to_int64()}; + if (magnitude > MAXIMUM_EXPANSION_EXPONENT || + magnitude < -MAXIMUM_EXPANSION_EXPONENT) { + return std::nullopt; + } + } + + const auto scientific{value.reduce().to_scientific_string()}; + const JSON::StringView view{scientific}; + const auto exponent_marker{view.find('e')}; + assert(exponent_marker != JSON::StringView::npos); + auto mantissa{view.substr(0, exponent_marker)}; + const bool negative{mantissa.front() == '-'}; + if (negative) { + mantissa.remove_prefix(1); + } + const auto point{mantissa.find('.')}; + const auto head{mantissa.substr( + 0, point == JSON::StringView::npos ? mantissa.size() : point)}; + const auto tail{point == JSON::StringView::npos ? JSON::StringView{} + : mantissa.substr(point + 1)}; + assert(head.size() == 1); + + auto exponent{view.substr(exponent_marker + 1)}; + if (exponent.front() == '+') { + exponent.remove_prefix(1); + } + std::int32_t adjusted{0}; + [[maybe_unused]] const auto conversion{std::from_chars( + exponent.data(), exponent.data() + exponent.size(), adjusted)}; + assert(conversion.ec == std::errc{}); + + const auto count{static_cast(head.size() + tail.size())}; + const auto sign_size{static_cast(negative ? 1 : 0)}; + JSON::String result; + if (adjusted >= count - 1) { + result.reserve(sign_size + static_cast(adjusted) + 1); + if (negative) { + result += '-'; + } + result += head; + result += tail; + result.append(static_cast(adjusted - (count - 1)), '0'); + } else if (adjusted >= 0) { + result.reserve(sign_size + static_cast(count) + 1); + if (negative) { + result += '-'; + } + result += head; + result += tail.substr(0, static_cast(adjusted)); + result += '.'; + result += tail.substr(static_cast(adjusted)); + } else { + const auto padding{static_cast(-adjusted) - 1}; + result.reserve(sign_size + 2 + padding + static_cast(count)); + if (negative) { + result += '-'; + } + result += "0."; + result.append(padding, '0'); + result += head; + result += tail; + } + return result; +} + +// A native number or boolean paired with an explicit datatype becomes its +// canonical string lexical form, as no result is produced for other values. +// JSON-LD 1.1 API Section 8.2.2 converts a native number with a non-zero +// fractional part to the "canonical lexical form of an xsd:double" no matter +// which datatype was requested, yielding ill-typed literals for datatypes +// whose lexical space has no exponent, whereas a string value passes through +// RDF conversion verbatim +inline auto typed_literal_lexical_form(const JSON &value, + const JSON::StringView datatype) + -> std::optional { + switch (value.type()) { + case JSON::Type::Boolean: + return JSON::String{value.to_boolean() ? "true" : "false"}; + case JSON::Type::Integer: { + if (is_floating_point_datatype(datatype)) { + return scientific_lexical_form(static_cast(value.to_integer())); + } + std::array buffer{}; + const auto conversion{std::to_chars( + buffer.data(), buffer.data() + buffer.size(), value.to_integer())}; + assert(conversion.ec == std::errc{}); + return JSON::String{buffer.data(), static_cast( + conversion.ptr - buffer.data())}; + } + case JSON::Type::Real: + return is_floating_point_datatype(datatype) + ? scientific_lexical_form(value.to_real()) + : fixed_lexical_form(value.to_real()); + case JSON::Type::Decimal: { + const auto &decimal{value.to_decimal()}; + if (is_floating_point_datatype(datatype)) { + const auto converted{to_double(decimal.to_scientific_string())}; + if (converted.has_value()) { + return scientific_lexical_form(converted.value()); + } + // A magnitude outside the double range has no scientific canonical + // form to borrow, so the exact expansion is the deterministic + // fallback + } + return fixed_lexical_form(decimal); + } + case JSON::Type::Null: + case JSON::Type::String: + case JSON::Type::Array: + case JSON::Type::Object: + return std::nullopt; + } + + std::unreachable(); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jsonpath/CMakeLists.txt b/vendor/core/src/core/jsonpath/CMakeLists.txt new file mode 100644 index 00000000..c85e3e43 --- /dev/null +++ b/vendor/core/src/core/jsonpath/CMakeLists.txt @@ -0,0 +1,20 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jsonpath + PRIVATE_HEADERS error.h + SOURCES jsonpath.cc parser.h) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME jsonpath) +endif() + +target_link_libraries(sourcemeta_core_jsonpath PUBLIC + sourcemeta::core::json) +target_link_libraries(sourcemeta_core_jsonpath PUBLIC + sourcemeta::core::jsonpointer) +target_link_libraries(sourcemeta_core_jsonpath PUBLIC + sourcemeta::core::regex) +target_link_libraries(sourcemeta_core_jsonpath PRIVATE + sourcemeta::core::numeric) +target_link_libraries(sourcemeta_core_jsonpath PRIVATE + sourcemeta::core::text) +target_link_libraries(sourcemeta_core_jsonpath PRIVATE + sourcemeta::core::unicode) diff --git a/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath.h b/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath.h new file mode 100644 index 00000000..65119b9e --- /dev/null +++ b/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath.h @@ -0,0 +1,334 @@ +#ifndef SOURCEMETA_CORE_JSONPATH_H_ +#define SOURCEMETA_CORE_JSONPATH_H_ + +#ifndef SOURCEMETA_CORE_JSONPATH_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +// NOLINTEND(misc-include-cleaner) + +#include +#include +#include + +#include // std::int64_t, std::uint8_t +#include // std::function +#include // std::optional +#include // std::variant +#include // std::vector + +/// @defgroup jsonpath JSONPath +/// @brief A strict RFC 9535 JSONPath implementation. +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +namespace sourcemeta::core { + +// Exporting symbols that depends on the standard C++ library is considered +// safe. +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-2-c4275?view=msvc-170&redirectedfrom=MSDN +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4251) +#endif + +/// @ingroup jsonpath +/// A parsed RFC 9535 JSONPath query that can be evaluated many times. +class SOURCEMETA_CORE_JSONPATH_EXPORT JSONPath { +public: + /// The callback invoked for every query result node + using Callback = + std::function; + + /// A selector that matches a single object member by name + struct SelectorName { + /// The decoded member name + JSON::String name; + /// The precomputed object key hash of the member name + JSON::Object::hash_type hash; + }; + + /// A selector that matches every member or element of a node + struct SelectorWildcard {}; + + /// A selector that matches a single array element by position + struct SelectorIndex { + /// The element position, where a negative value counts from the end + std::int64_t index; + }; + + /// A selector that matches a range of array elements + struct SelectorSlice { + /// The position the range starts at, if any + std::optional start; + /// The position the range stops before, if any + std::optional end; + /// The distance between selected positions + std::int64_t step; + }; + + /// The function extensions that filter expressions can invoke + enum class FilterFunctionName : std::uint8_t { + /// The length of a string, array, or object value + Length, + /// The number of nodes a query selects + Count, + /// Whether a string entirely matches a regular expression + Match, + /// Whether a string contains a match of a regular expression + Search, + /// The value of the single node a query selects + Value + }; + + /// The operators that filter comparisons can use + enum class FilterComparisonOperator : std::uint8_t { + /// Both sides are equal + Equal, + /// Both sides are not equal + NotEqual, + /// The left side orders before the right side + Less, + /// The left side orders before or equals the right side + LessEqual, + /// The right side orders before the left side + Greater, + /// The right side orders before or equals the left side + GreaterEqual + }; + +#if !defined(DOXYGEN) + // Required by the recursive grammar and defined further below + struct Segment; +#endif + + /// A query embedded in a filter expression + struct FilterQuery { + /// Whether the query starts at the candidate node instead of the root + bool relative; + /// The segments of the query + std::vector segments; + /// Whether the query selects at most one node + bool singular; + }; + +#if !defined(DOXYGEN) + // Required by the recursive grammar and defined further below + struct FilterOperand; +#endif + + /// A function invocation within a filter expression + struct FilterFunctionCall { + /// The function to invoke + FilterFunctionName function; + /// The arguments to invoke the function with + std::vector arguments; + /// The compiled form of a constant regular expression argument, which + /// stays empty when the pattern is not a valid RFC 9485 expression + std::optional compiled; + }; + + /// A comparison side or function argument within a filter expression + struct FilterOperand { + /// The literal, query, or function invocation this operand stands for + std::variant value; + }; + + /// A comparison between two filter operands + struct FilterComparison { + /// The left side of the comparison + FilterOperand left; + /// The operator to compare with + FilterComparisonOperator operation; + /// The right side of the comparison + FilterOperand right; + }; + + /// An existence or function test within a filter expression + struct FilterTest { + /// Whether the outcome of the test is negated + bool negated; + /// The query or function invocation under test + std::variant subject; + }; + +#if !defined(DOXYGEN) + // Required by the recursive grammar and defined further below + struct FilterExpression; +#endif + + /// A conjunction of filter expressions + struct FilterConjunction { + /// The expressions that must all hold + std::vector children; + }; + + /// A disjunction of filter expressions + struct FilterDisjunction { + /// The expressions of which at least one must hold + std::vector children; + }; + + /// A negated parenthesized filter expression + struct FilterNegation { + /// The single expression whose outcome is negated + std::vector children; + }; + +#if !defined(DOXYGEN) + // For fast internal dispatching. It must stay in sync with the expression + // variant below + enum class FilterExpressionKind : std::uint8_t { + Comparison = 0, + Test, + Conjunction, + Disjunction, + Negation + }; +#endif + + /// A logical expression within a filter selector + struct FilterExpression { + /// The comparison, test, or combination this expression stands for + std::variant + value; + }; + + /// A selector that matches the members or elements a logical expression + /// holds for + struct SelectorFilter { + /// The logical expression to apply + FilterExpression expression; + }; + + /// A single selector within a query segment + using Selector = std::variant; + +#if !defined(DOXYGEN) + // For fast internal dispatching. It must stay in sync with the variant above + enum class SelectorKind : std::uint8_t { + Name = 0, + Wildcard, + Index, + Slice, + Filter + }; +#endif + + /// The precomputed shape of a query segment + enum class SegmentKind : std::uint8_t { + /// A single name selector applied to children + SingleName, + /// A single index selector applied to children + SingleIndex, + /// Any other combination of selectors + General + }; + + /// A step in a query + struct Segment { + /// Whether the segment also applies to every descendant of its input + bool descendant; + /// The precomputed shape of the segment for fast dispatching + SegmentKind kind; + /// The selectors the segment applies + std::vector selectors; + }; + + /// The compiled representation of a whole query + struct Query { + /// The segments of the query + std::vector segments; + }; + + /// Parse a query expression, throwing on invalid input. For example: + /// + /// ```cpp + /// #include + /// + /// const sourcemeta::core::JSONPath path{"$.store.book[0].title"}; + /// ``` + explicit JSONPath(const JSON::StringView expression); + + /// Evaluate the query against a document, invoking the callback once per + /// result node. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON document{ + /// sourcemeta::core::parse_json("{ \"foo\": [ 1, 2 ] }")}; + /// const sourcemeta::core::JSONPath path{"$.foo[0]"}; + /// path.evaluate(document, [](const auto &value, const auto &location) { + /// assert(value.is_integer()); + /// assert(location.size() == 2); + /// }); + /// ``` + auto evaluate(const JSON &document, const Callback &callback) const -> void; + + /// Serialize a location into the RFC 9535 normalized path form. For + /// example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON document{ + /// sourcemeta::core::parse_json("{ \"foo\": [ 1, 2 ] }")}; + /// const sourcemeta::core::JSONPath path{"$.foo[0]"}; + /// path.evaluate(document, [](const auto &value, const auto &location) { + /// assert(sourcemeta::core::JSONPath::normalize(location) == + /// "$['foo'][0]"); + /// }); + /// ``` + [[nodiscard]] static auto normalize(const WeakPointer &location) + -> JSON::String; + + /// Serialize the query into its expression string as JSON. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSONPath path{"$.foo[0]"}; + /// const sourcemeta::core::JSON result{path.to_json()}; + /// assert(result.is_string()); + /// assert(result.to_string() == "$.foo[0]"); + /// ``` + [[nodiscard]] auto to_json() const -> JSON; + + /// Deserialize a query from its expression string as JSON, returning no + /// result on invalid input. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON input{"$.foo[0]"}; + /// const auto path{sourcemeta::core::JSONPath::from_json(input)}; + /// assert(path.has_value()); + /// ``` + [[nodiscard]] static auto from_json(const JSON &value) + -> std::optional; + +private: + JSON::String expression_; + Query query_; +}; + +#if defined(_MSC_VER) +#pragma warning(pop) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath_error.h b/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath_error.h new file mode 100644 index 00000000..1773df96 --- /dev/null +++ b/vendor/core/src/core/jsonpath/include/sourcemeta/core/jsonpath_error.h @@ -0,0 +1,47 @@ +#ifndef SOURCEMETA_CORE_JSONPATH_ERROR_H_ +#define SOURCEMETA_CORE_JSONPATH_ERROR_H_ + +#ifndef SOURCEMETA_CORE_JSONPATH_EXPORT +#include +#endif + +#include // std::uint64_t +#include // std::exception + +namespace sourcemeta::core { + +// Exporting symbols that depends on the standard C++ library is considered +// safe. +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-2-c4275?view=msvc-170&redirectedfrom=MSDN +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup jsonpath +/// An error that represents a JSONPath parsing failure +class SOURCEMETA_CORE_JSONPATH_EXPORT JSONPathParseError + : public std::exception { +public: + /// Construct an error given the column number where parsing failed + JSONPathParseError(const std::uint64_t column) : column_{column} {} + + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid JSON Path query"; + } + + /// Get the column number of the error + [[nodiscard]] auto column() const noexcept -> std::uint64_t { + return this->column_; + } + +private: + std::uint64_t column_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jsonpath/jsonpath.cc b/vendor/core/src/core/jsonpath/jsonpath.cc new file mode 100644 index 00000000..b821d0ed --- /dev/null +++ b/vendor/core/src/core/jsonpath/jsonpath.cc @@ -0,0 +1,1446 @@ +#include +#include +#include + +#include "parser.h" + +#include // std::max, std::min +#include // assert +#include // std::size_t +#include // std::int64_t +#include // std::cref +#include // std::optional +#include // std::to_string +#include // std::get, std::get_if, std::holds_alternative, std::monostate, std::variant +#include // std::vector + +namespace sourcemeta::core { + +namespace { + +// One step of the current traversal location. The full pointer is only +// materialized when a result node fires, so the walk itself performs raw +// stores instead of token constructions +struct LocationFrame { + const JSON::String *property; + JSON::Object::hash_type hash; + std::size_t index; +}; + +class LocationStack { +public: + LocationStack() { this->storage_.resize(this->capacity_); } + + auto push_property(const JSON::String &property, + const JSON::Object::hash_type hash) -> void { + if (this->depth_ == this->capacity_) { + this->grow(); + } + + auto &frame{*(this->storage_.data() + this->depth_)}; + frame.property = &property; + frame.hash = hash; + this->depth_ += 1; + } + + auto push_index(const std::size_t index) -> void { + if (this->depth_ == this->capacity_) { + this->grow(); + } + + auto &frame{*(this->storage_.data() + this->depth_)}; + frame.property = nullptr; + frame.index = index; + this->depth_ += 1; + } + + auto pop() -> void { this->depth_ -= 1; } + + auto truncate(const std::size_t depth) -> void { this->depth_ = depth; } + + [[nodiscard]] auto depth() const -> std::size_t { return this->depth_; } + + auto materialize(WeakPointer &location) const -> void { + location.pop_back(location.size()); + const auto *frame{this->storage_.data()}; + const auto *const frames_end{frame + this->depth_}; + for (; frame != frames_end; ++frame) { + if (frame->property == nullptr) { + location.emplace_back(frame->index); + } else { + location.emplace_back(std::cref(*frame->property), frame->hash); + } + } + } + +private: + auto grow() -> void { + this->capacity_ *= 2; + this->storage_.resize(this->capacity_); + } + + std::size_t depth_{0}; + std::size_t capacity_{64}; + std::vector storage_; +}; + +// A pending traversal step. A frame carrying step pushes its location frame +// when it executes and leaves a paired marker that restores the location +// once its whole subtree has been processed +enum class WorkKind : std::uint8_t { Visit, VisitProperty, VisitIndex }; + +struct WorkItem { + WorkKind kind; + const JSON *node; + std::size_t cursor; + std::size_t frame_depth; + const JSON::String *property; + JSON::Object::hash_type hash; + std::size_t index; +}; + +class WorkStack { +public: + WorkStack() = default; + + [[nodiscard]] auto empty() const -> bool { return this->depth_ == 0; } + + [[nodiscard]] auto mark() const -> std::size_t { return this->depth_; } + + auto reverse_from(const std::size_t from) -> void { + std::reverse(this->storage_.data() + from, + this->storage_.data() + this->depth_); + } + + auto pop() -> WorkItem { + this->depth_ -= 1; + return *(this->storage_.data() + this->depth_); + } + + auto push_visit(const JSON *node, const std::size_t cursor, + const std::size_t frame_depth) -> void { + auto &item{this->next()}; + item.kind = WorkKind::Visit; + item.node = node; + item.cursor = cursor; + item.frame_depth = frame_depth; + } + + auto push_visit_property(const JSON *node, const std::size_t cursor, + const std::size_t frame_depth, + const JSON::String &property, + const JSON::Object::hash_type hash) -> void { + auto &item{this->next()}; + item.kind = WorkKind::VisitProperty; + item.node = node; + item.cursor = cursor; + item.frame_depth = frame_depth; + item.property = &property; + item.hash = hash; + } + + auto push_visit_index(const JSON *node, const std::size_t cursor, + const std::size_t frame_depth, const std::size_t index) + -> void { + auto &item{this->next()}; + item.kind = WorkKind::VisitIndex; + item.node = node; + item.cursor = cursor; + item.frame_depth = frame_depth; + item.index = index; + } + +private: + auto next() -> WorkItem & { + if (this->depth_ == this->capacity_) { + this->capacity_ = this->capacity_ == 0 ? 64 : this->capacity_ * 2; + this->storage_.resize(this->capacity_); + } + + auto &item{*(this->storage_.data() + this->depth_)}; + this->depth_ += 1; + return item; + } + + std::size_t depth_{0}; + std::size_t capacity_{0}; + std::vector storage_; +}; + +struct EvaluationState { + const JSON *root; + const JSONPath::Callback *callback; + LocationStack frames; + WorkStack work; + WeakPointer location; +}; + +auto filter_matches(const JSONPath::FilterExpression &expression, + const JSON &candidate, const JSON &root) -> bool; + +// The underlying containers are contiguous, so iterating through raw +// pointers instead of iterators keeps the hot traversal loops free of the +// unoptimized iterator call chains of debug builds. Empty containers yield +// an empty range of null pointers without any pointer arithmetic +inline auto member_range(const JSON::Object &object) + -> std::pair { + if (object.empty()) { + return {nullptr, nullptr}; + } + + const auto *begin{&*object.cbegin()}; + return {begin, begin + object.size()}; +} + +inline auto element_range(const JSON::Array &array) + -> std::pair { + if (array.size() == 0) { + return {nullptr, nullptr}; + } + + const auto *begin{&*array.cbegin()}; + return {begin, begin + array.size()}; +} + +inline auto first_element(const JSON::Array &array) -> const JSON * { + return array.size() == 0 ? nullptr : &*array.cbegin(); +} + +// RFC 9535 Section 2.3.4.2.2: slice expression bounds against a concrete +// array length, where an omitted start or end defaults according to the +// sign of the step and a zero step selects nothing +inline auto slice_bounds(const JSONPath::SelectorSlice &slice, + const std::int64_t size, std::int64_t &lower, + std::int64_t &upper) -> bool { + if (slice.step == 0) { + return false; + } + + const auto start{slice.start.has_value() ? slice.start.value() + : (slice.step > 0 ? 0 : size - 1)}; + const auto end{slice.end.has_value() ? slice.end.value() + : (slice.step > 0 ? size : -size - 1)}; + const auto normalized_start{start >= 0 ? start : size + start}; + const auto normalized_end{end >= 0 ? end : size + end}; + if (slice.step > 0) { + lower = std::min(std::max(normalized_start, std::int64_t{0}), size); + upper = std::min(std::max(normalized_end, std::int64_t{0}), size); + } else { + upper = std::min(std::max(normalized_start, std::int64_t{-1}), size - 1); + lower = std::min(std::max(normalized_end, std::int64_t{-1}), size - 1); + } + + return true; +} + +// Walk the nodes selected by a filter query without tracking locations. The +// visitor returns whether to continue, and the traversal reports whether it +// ran to completion without the visitor stopping it. The walk is iterative +// over an explicit stack, so document depth cannot exhaust the machine +// stack, as RFC 9535 Section 4.1 warns about naive recursive descendants +struct FilterWorkItem { + const JSON *node; + std::size_t cursor; +}; + +template +auto filter_query_visit_iterative( + const std::vector &segments, const std::size_t cursor, + const JSON &origin, const JSON &root, const Visitor &visitor) -> bool { + // Filters nest, so invocations share the pool as a segmented stack, each + // one operating strictly above its entry depth + thread_local std::vector pool; + const auto base{pool.size()}; + pool.push_back({.node = &origin, .cursor = cursor}); + const auto total{segments.size()}; + bool completed{true}; + while (pool.size() > base) { + const auto item{pool.back()}; + pool.pop_back(); + if (item.cursor == total) { + if (!visitor(*item.node)) { + completed = false; + break; + } + + continue; + } + + const auto &segment{*(segments.data() + item.cursor)}; + const auto ¤t{*item.node}; + const auto type{current.type()}; + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleName) { + if (type == JSON::Type::Object) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + pool.push_back({.node = child, .cursor = item.cursor + 1}); + } + } + + continue; + } + + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleIndex) { + if (type == JSON::Type::Array) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size : entry->index}; + if (index >= 0 && index < size) { + pool.push_back( + {.node = first_element(array) + static_cast(index), + .cursor = item.cursor + 1}); + } + } + + continue; + } + + const auto mark{pool.size()}; + const auto *selector{segment.selectors.data()}; + const auto *const selectors_end{selector + segment.selectors.size()}; + for (; selector != selectors_end; ++selector) { + switch (static_cast(selector->index())) { + case JSONPath::SelectorKind::Name: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + pool.push_back({.node = child, .cursor = item.cursor + 1}); + } + } + + break; + } + case JSONPath::SelectorKind::Wildcard: + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + pool.push_back( + {.node = &member->second, .cursor = item.cursor + 1}); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + pool.push_back({.node = element, .cursor = item.cursor + 1}); + } + } + + break; + case JSONPath::SelectorKind::Index: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size + : entry->index}; + if (index >= 0 && index < size) { + pool.push_back({.node = first_element(array) + + static_cast(index), + .cursor = item.cursor + 1}); + } + } + + break; + } + case JSONPath::SelectorKind::Slice: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + std::int64_t lower{0}; + std::int64_t upper{0}; + if (!slice_bounds(*entry, size, lower, upper)) { + break; + } + + const auto *const array_base{first_element(array)}; + if (entry->step > 0) { + for (auto index{lower}; index < upper; index += entry->step) { + pool.push_back( + {.node = array_base + static_cast(index), + .cursor = item.cursor + 1}); + } + } else { + for (auto index{upper}; index > lower; index += entry->step) { + pool.push_back( + {.node = array_base + static_cast(index), + .cursor = item.cursor + 1}); + } + } + } + + break; + } + case JSONPath::SelectorKind::Filter: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (filter_matches(entry->expression, member->second, root)) { + pool.push_back( + {.node = &member->second, .cursor = item.cursor + 1}); + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + if (filter_matches(entry->expression, *element, root)) { + pool.push_back({.node = element, .cursor = item.cursor + 1}); + } + } + } + + break; + } + } + } + + if (segment.descendant) { + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + pool.push_back({.node = &member->second, .cursor = item.cursor}); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + pool.push_back({.node = element, .cursor = item.cursor}); + } + } + } + + std::reverse(pool.begin() + static_cast(mark), pool.end()); + } + + pool.resize(base); + return completed; +} + +// The recursion depth after which the walks defer to their iterative +// twins, so that absurdly deep documents cannot exhaust the machine stack, +// as RFC 9535 Section 4.1 warns, while typical documents keep the cheaper +// native call frames +constexpr std::size_t JSONPATH_EVALUATION_RECURSION_LIMIT{256}; + +template +auto filter_query_visit(const std::vector &segments, + const std::size_t cursor, const JSON ¤t, + const JSON &root, const Visitor &visitor, + const std::size_t depth = 0) -> bool { + if (depth >= JSONPATH_EVALUATION_RECURSION_LIMIT) { + return filter_query_visit_iterative(segments, cursor, current, root, + visitor); + } + + if (cursor == segments.size()) { + return visitor(current); + } + + const auto &segment{*(segments.data() + cursor)}; + const auto type{current.type()}; + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleName) { + if (type == JSON::Type::Object) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + return filter_query_visit(segments, cursor + 1, *child, root, visitor, + depth + 1); + } + } + + return true; + } + + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleIndex) { + if (type == JSON::Type::Array) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size : entry->index}; + if (index >= 0 && index < size) { + return filter_query_visit( + segments, cursor + 1, + *(first_element(array) + static_cast(index)), root, + visitor, depth + 1); + } + } + + return true; + } + + const auto *selector{segment.selectors.data()}; + const auto *const selectors_end{selector + segment.selectors.size()}; + for (; selector != selectors_end; ++selector) { + switch (static_cast(selector->index())) { + case JSONPath::SelectorKind::Name: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr && + !filter_query_visit(segments, cursor + 1, *child, root, visitor, + depth + 1)) { + return false; + } + } + + break; + } + case JSONPath::SelectorKind::Wildcard: + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (!filter_query_visit(segments, cursor + 1, member->second, root, + visitor, depth + 1)) { + return false; + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + if (!filter_query_visit(segments, cursor + 1, *element, root, + visitor, depth + 1)) { + return false; + } + } + } + + break; + case JSONPath::SelectorKind::Index: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size + : entry->index}; + if (index >= 0 && index < size && + !filter_query_visit( + segments, cursor + 1, + *(first_element(array) + static_cast(index)), + root, visitor, depth + 1)) { + return false; + } + } + + break; + } + case JSONPath::SelectorKind::Slice: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + std::int64_t lower{0}; + std::int64_t upper{0}; + if (!slice_bounds(*entry, size, lower, upper)) { + break; + } + + const auto *const array_base{first_element(array)}; + if (entry->step > 0) { + for (auto index{lower}; index < upper; index += entry->step) { + if (!filter_query_visit( + segments, cursor + 1, + *(array_base + static_cast(index)), root, + visitor, depth + 1)) { + return false; + } + } + } else { + for (auto index{upper}; index > lower; index += entry->step) { + if (!filter_query_visit( + segments, cursor + 1, + *(array_base + static_cast(index)), root, + visitor, depth + 1)) { + return false; + } + } + } + } + + break; + } + case JSONPath::SelectorKind::Filter: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (filter_matches(entry->expression, member->second, root) && + !filter_query_visit(segments, cursor + 1, member->second, root, + visitor, depth + 1)) { + return false; + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + if (filter_matches(entry->expression, *element, root) && + !filter_query_visit(segments, cursor + 1, *element, root, + visitor, depth + 1)) { + return false; + } + } + } + + break; + } + } + } + + if (segment.descendant) { + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (!filter_query_visit(segments, cursor, member->second, root, visitor, + depth + 1)) { + return false; + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (; element != elements_end; ++element) { + if (!filter_query_visit(segments, cursor, *element, root, visitor, + depth + 1)) { + return false; + } + } + } + } + + return true; +} + +// RFC 9535 Section 2.3.5.1: a singular query "produces a nodelist containing +// at most one node", so it resolves directly without traversal +inline auto resolve_singular(const JSONPath::FilterQuery &query, + const JSON &candidate, const JSON &root) + -> const JSON * { + const auto *current{query.relative ? &candidate : &root}; + const auto *segment{query.segments.data()}; + const auto *const segments_end{segment + query.segments.size()}; + for (; segment != segments_end; ++segment) { + assert(!segment->descendant); + assert(segment->selectors.size() == 1); + const auto *selector{segment->selectors.data()}; + if (selector->index() == 0) { + const auto *entry{std::get_if(selector)}; + current = current->is_object() ? current->try_at(entry->name, entry->hash) + : nullptr; + } else { + const auto *entry{std::get_if(selector)}; + if (current->is_array()) { + const auto &array{current->as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size : entry->index}; + current = index >= 0 && index < size + ? first_element(array) + static_cast(index) + : nullptr; + } else { + current = nullptr; + } + } + + if (current == nullptr) { + return nullptr; + } + } + + return current; +} + +auto evaluate_value_function(const JSONPath::FilterFunctionCall &call, + const JSON &candidate, const JSON &root, + std::optional &storage) -> const JSON *; + +// The value of a comparable during evaluation is a borrowed pointer, or no +// pointer for the special result Nothing. Computed function results live in +// the caller provided storage +inline auto evaluate_operand(const JSONPath::FilterOperand &operand, + const JSON &candidate, const JSON &root, + std::optional &storage) -> const JSON * { + switch (operand.value.index()) { + case 0: + return std::get_if(&operand.value); + case 1: + return resolve_singular( + *std::get_if(&operand.value), candidate, root); + default: + return evaluate_value_function( + *std::get_if(&operand.value), candidate, + root, storage); + } +} + +inline auto evaluate_value_function(const JSONPath::FilterFunctionCall &call, + const JSON &candidate, const JSON &root, + std::optional &storage) + -> const JSON * { + switch (call.function) { + // RFC 9535 Section 2.4.4: the length function counts string characters, + // array elements, or object members, and is nothing for anything else + case JSONPath::FilterFunctionName::Length: { + std::optional argument_storage; + const auto *target{evaluate_operand(call.arguments.front(), candidate, + root, argument_storage)}; + if (target == nullptr) { + return nullptr; + } + + if (target->is_string()) { + storage.emplace(static_cast( + utf8_codepoint_count(target->to_string()))); + return &storage.value(); + } + + if (target->is_array()) { + storage.emplace(static_cast(target->as_array().size())); + return &storage.value(); + } + + if (target->is_object()) { + storage.emplace(static_cast(target->as_object().size())); + return &storage.value(); + } + + return nullptr; + } + // RFC 9535 Section 2.4.5: the count function yields the number of nodes + case JSONPath::FilterFunctionName::Count: { + const auto &query{ + std::get(call.arguments.front().value)}; + std::int64_t count{0}; + filter_query_visit(query.segments, 0, query.relative ? candidate : root, + root, [&count](const JSON &) -> bool { + count += 1; + return true; + }); + storage.emplace(count); + return &storage.value(); + } + // RFC 9535 Section 2.4.8: the value function yields the value of a + // single node and nothing otherwise + case JSONPath::FilterFunctionName::Value: { + const auto &query{ + std::get(call.arguments.front().value)}; + const JSON *single{nullptr}; + std::size_t count{0}; + filter_query_visit(query.segments, 0, query.relative ? candidate : root, + root, [&single, &count](const JSON &node) -> bool { + single = &node; + count += 1; + return count < 2; + }); + return count == 1 ? single : nullptr; + } + default: + assert(false); + return nullptr; + } +} + +// RFC 9535 Sections 2.4.6 and 2.4.7: the match function considers the whole +// input while the search function considers any substring, and any argument +// mismatch yields a false outcome rather than an error +inline auto evaluate_logical_function(const JSONPath::FilterFunctionCall &call, + const JSON &candidate, const JSON &root) + -> bool { + std::optional input_storage; + const auto *subject{ + evaluate_operand(call.arguments.front(), candidate, root, input_storage)}; + if (subject == nullptr || !subject->is_string()) { + return false; + } + + if (std::holds_alternative(call.arguments.back().value) && + std::get(call.arguments.back().value).is_string()) { + return call.compiled.has_value() && + matches(call.compiled.value(), subject->to_string()); + } + + std::optional pattern_storage; + const auto *expression{evaluate_operand(call.arguments.back(), candidate, + root, pattern_storage)}; + if (expression == nullptr || !expression->is_string()) { + return false; + } + + const auto compiled{ + to_regex(expression->to_string(), + call.function == JSONPath::FilterFunctionName::Match + ? RegexDialect::IRegexp + : RegexDialect::IRegexpSearch)}; + return compiled.has_value() && + matches(compiled.value(), subject->to_string()); +} + +// RFC 9535 Section 2.3.5.2.2: equality is deep JSON equality where numbers +// compare mathematically, and an absent value equals only an absent value +inline auto filter_equals(const JSON *left, const JSON *right) -> bool { + if (left == nullptr || right == nullptr) { + return left == nullptr && right == nullptr; + } + + return *left == *right; +} + +// RFC 9535 Section 2.3.5.2.2: ordering applies only when both sides are +// numbers or both sides are strings +inline auto filter_less(const JSON *left, const JSON *right) -> bool { + if (left == nullptr || right == nullptr) { + return false; + } + + // Same representation comparisons are trivially exact without the + // generic value ordering dispatch + if (left->is_integer() && right->is_integer()) { + return left->to_integer() < right->to_integer(); + } + + if (left->is_real() && right->is_real()) { + return left->to_real() < right->to_real(); + } + + // RFC 9535 Section 2.3.5.2.2: ordering applies only when both sides are + // numbers or both sides are strings. The value comparison itself orders + // numbers of different representations by exact value + if ((left->is_number() && right->is_number()) || + (left->is_string() && right->is_string())) { + return *left < *right; + } + + return false; +} + +inline auto filter_compare(const JSONPath::FilterComparison &comparison, + const JSON &candidate, const JSON &root) -> bool { + std::optional left_storage; + std::optional right_storage; + const auto *left{ + evaluate_operand(comparison.left, candidate, root, left_storage)}; + const auto *right{ + evaluate_operand(comparison.right, candidate, root, right_storage)}; + switch (comparison.operation) { + case JSONPath::FilterComparisonOperator::Equal: + return filter_equals(left, right); + case JSONPath::FilterComparisonOperator::NotEqual: + return !filter_equals(left, right); + case JSONPath::FilterComparisonOperator::Less: + return filter_less(left, right); + case JSONPath::FilterComparisonOperator::LessEqual: + return filter_less(left, right) || filter_equals(left, right); + case JSONPath::FilterComparisonOperator::Greater: + return filter_less(right, left); + case JSONPath::FilterComparisonOperator::GreaterEqual: + return filter_less(right, left) || filter_equals(left, right); + } + + assert(false); + return false; +} + +inline auto filter_matches(const JSONPath::FilterExpression &expression, + const JSON &candidate, const JSON &root) -> bool { + switch ( + static_cast(expression.value.index())) { + case JSONPath::FilterExpressionKind::Comparison: + return filter_compare( + *std::get_if(&expression.value), + candidate, root); + case JSONPath::FilterExpressionKind::Test: { + const auto &test{*std::get_if(&expression.value)}; + bool result{false}; + if (test.subject.index() == 0) { + const auto &query{*std::get_if(&test.subject)}; + result = !filter_query_visit( + query.segments, 0, query.relative ? candidate : root, root, + [](const JSON &) -> bool { return false; }); + } else { + result = evaluate_logical_function( + *std::get_if(&test.subject), + candidate, root); + } + + return test.negated ? !result : result; + } + case JSONPath::FilterExpressionKind::Conjunction: { + const auto &conjunction{ + *std::get_if(&expression.value)}; + const auto *child{conjunction.children.data()}; + const auto *const children_end{child + conjunction.children.size()}; + for (; child != children_end; ++child) { + if (!filter_matches(*child, candidate, root)) { + return false; + } + } + + return true; + } + case JSONPath::FilterExpressionKind::Disjunction: { + const auto &disjunction{ + *std::get_if(&expression.value)}; + const auto *child{disjunction.children.data()}; + const auto *const children_end{child + disjunction.children.size()}; + for (; child != children_end; ++child) { + if (filter_matches(*child, candidate, root)) { + return true; + } + } + + return false; + } + case JSONPath::FilterExpressionKind::Negation: + return !filter_matches( + std::get_if(&expression.value) + ->children.front(), + candidate, root); + } + + assert(false); + return false; +} + +// The main walk is iterative over an explicit stack, so document depth +// cannot exhaust the machine stack, as RFC 9535 Section 4.1 warns about +// naive recursive implementations of the descendant segment +inline auto evaluate_query(const std::vector &segments, + const std::size_t cursor, const JSON &origin, + EvaluationState &state) -> void { + auto &stack{state.work}; + stack.push_visit(&origin, cursor, state.frames.depth()); + const auto total{segments.size()}; + while (!stack.empty()) { + const auto item{stack.pop()}; + switch (item.kind) { + case WorkKind::VisitProperty: + state.frames.truncate(item.frame_depth); + state.frames.push_property(*item.property, item.hash); + break; + case WorkKind::VisitIndex: + state.frames.truncate(item.frame_depth); + state.frames.push_index(item.index); + break; + case WorkKind::Visit: + state.frames.truncate(item.frame_depth); + break; + } + + if (item.cursor == total) { + state.frames.materialize(state.location); + (*state.callback)(*item.node, state.location); + continue; + } + + const auto &segment{*(segments.data() + item.cursor)}; + const auto ¤t{*item.node}; + const auto type{current.type()}; + const auto child_depth{state.frames.depth()}; + // The overwhelmingly common segment shape skips the general selector + // loop, the variant dispatch, and the ordering reversal entirely + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleName) { + if (type == JSON::Type::Object) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + stack.push_visit_property(child, item.cursor + 1, child_depth, + entry->name, entry->hash); + } + } + + continue; + } + + if (!segment.descendant && + segment.kind == JSONPath::SegmentKind::SingleIndex) { + if (type == JSON::Type::Array) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size : entry->index}; + if (index >= 0 && index < size) { + stack.push_visit_index( + first_element(array) + static_cast(index), + item.cursor + 1, child_depth, static_cast(index)); + } + } + + continue; + } + + // Steps are appended in document order and then reversed, so the stack + // pops them back in document order with every subtree fully processed + // before its next sibling + const auto mark{stack.mark()}; + const auto *selector{segment.selectors.data()}; + const auto *const selectors_end{selector + segment.selectors.size()}; + for (; selector != selectors_end; ++selector) { + switch (static_cast(selector->index())) { + case JSONPath::SelectorKind::Name: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + stack.push_visit_property(child, item.cursor + 1, child_depth, + entry->name, entry->hash); + } + } + + break; + } + case JSONPath::SelectorKind::Wildcard: + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + stack.push_visit_property(&member->second, item.cursor + 1, + child_depth, member->first, + member->hash); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; + ++element, ++index) { + stack.push_visit_index(element, item.cursor + 1, child_depth, + index); + } + } + + break; + case JSONPath::SelectorKind::Index: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size + : entry->index}; + if (index >= 0 && index < size) { + stack.push_visit_index(first_element(array) + + static_cast(index), + item.cursor + 1, child_depth, + static_cast(index)); + } + } + + break; + } + case JSONPath::SelectorKind::Slice: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + std::int64_t lower{0}; + std::int64_t upper{0}; + if (!slice_bounds(*entry, size, lower, upper)) { + break; + } + + const auto *const array_base{first_element(array)}; + if (entry->step > 0) { + for (auto index{lower}; index < upper; index += entry->step) { + stack.push_visit_index(array_base + + static_cast(index), + item.cursor + 1, child_depth, + static_cast(index)); + } + } else { + for (auto index{upper}; index > lower; index += entry->step) { + stack.push_visit_index(array_base + + static_cast(index), + item.cursor + 1, child_depth, + static_cast(index)); + } + } + } + + break; + } + case JSONPath::SelectorKind::Filter: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (filter_matches(entry->expression, member->second, + *state.root)) { + stack.push_visit_property(&member->second, item.cursor + 1, + child_depth, member->first, + member->hash); + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; + ++element, ++index) { + if (filter_matches(entry->expression, *element, *state.root)) { + stack.push_visit_index(element, item.cursor + 1, child_depth, + index); + } + } + } + + break; + } + } + } + + if (segment.descendant) { + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + stack.push_visit_property(&member->second, item.cursor, child_depth, + member->first, member->hash); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; + ++element, ++index) { + stack.push_visit_index(element, item.cursor, child_depth, index); + } + } + } + + stack.reverse_from(mark); + } +} + +auto evaluate_segments(const std::vector &segments, + const std::size_t cursor, const JSON ¤t, + EvaluationState &state, const std::size_t depth) -> void; + +inline auto evaluate_selectors(const std::vector &segments, + const std::size_t cursor, const JSON ¤t, + EvaluationState &state, const std::size_t depth) + -> void { + const auto &segment{*(segments.data() + cursor)}; + const auto type{current.type()}; + // The overwhelmingly common segment shape skips the general selector + // loop and variant dispatch entirely. Unlike the iterative walk, this + // function only applies selectors, so the shape check needs no descendant + // distinction + if (segment.kind == JSONPath::SegmentKind::SingleName) { + if (type == JSON::Type::Object) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + state.frames.push_property(entry->name, entry->hash); + evaluate_segments(segments, cursor + 1, *child, state, depth); + state.frames.pop(); + } + } + + return; + } + + if (segment.kind == JSONPath::SegmentKind::SingleIndex) { + if (type == JSON::Type::Array) { + const auto *entry{ + std::get_if(segment.selectors.data())}; + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size : entry->index}; + if (index >= 0 && index < size) { + state.frames.push_index(static_cast(index)); + evaluate_segments( + segments, cursor + 1, + *(first_element(array) + static_cast(index)), state, + depth); + state.frames.pop(); + } + } + + return; + } + + const auto *selector{segment.selectors.data()}; + const auto *const selectors_end{selector + segment.selectors.size()}; + for (; selector != selectors_end; ++selector) { + switch (static_cast(selector->index())) { + case JSONPath::SelectorKind::Name: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto *child{current.try_at(entry->name, entry->hash)}; + if (child != nullptr) { + state.frames.push_property(entry->name, entry->hash); + evaluate_segments(segments, cursor + 1, *child, state, depth); + state.frames.pop(); + } + } + + break; + } + case JSONPath::SelectorKind::Wildcard: + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + state.frames.push_property(member->first, member->hash); + evaluate_segments(segments, cursor + 1, member->second, state, + depth); + state.frames.pop(); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; + ++element, ++index) { + state.frames.push_index(index); + evaluate_segments(segments, cursor + 1, *element, state, depth); + state.frames.pop(); + } + } + + break; + case JSONPath::SelectorKind::Index: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + const auto index{entry->index < 0 ? entry->index + size + : entry->index}; + if (index >= 0 && index < size) { + state.frames.push_index(static_cast(index)); + evaluate_segments( + segments, cursor + 1, + *(first_element(array) + static_cast(index)), + state, depth); + state.frames.pop(); + } + } + + break; + } + case JSONPath::SelectorKind::Slice: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + const auto size{static_cast(array.size())}; + std::int64_t lower{0}; + std::int64_t upper{0}; + if (!slice_bounds(*entry, size, lower, upper)) { + break; + } + + const auto *const base{first_element(array)}; + if (entry->step > 0) { + for (auto index{lower}; index < upper; index += entry->step) { + state.frames.push_index(static_cast(index)); + evaluate_segments(segments, cursor + 1, + *(base + static_cast(index)), + state, depth); + state.frames.pop(); + } + } else { + for (auto index{upper}; index > lower; index += entry->step) { + state.frames.push_index(static_cast(index)); + evaluate_segments(segments, cursor + 1, + *(base + static_cast(index)), + state, depth); + state.frames.pop(); + } + } + } + + break; + } + case JSONPath::SelectorKind::Filter: { + const auto *entry{std::get_if(selector)}; + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + if (filter_matches(entry->expression, member->second, + *state.root)) { + state.frames.push_property(member->first, member->hash); + evaluate_segments(segments, cursor + 1, member->second, state, + depth); + state.frames.pop(); + } + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; + ++element, ++index) { + if (filter_matches(entry->expression, *element, *state.root)) { + state.frames.push_index(index); + evaluate_segments(segments, cursor + 1, *element, state, depth); + state.frames.pop(); + } + } + } + + break; + } + } + } +} + +// RFC 9535 Section 2.5.2.2: a descendant segment visits the input node and +// every descendant in depth-first document order, applying its selectors at +// each visited node +inline auto evaluate_descendant(const std::vector &segments, + const std::size_t cursor, const JSON ¤t, + EvaluationState &state, const std::size_t depth) + -> void { + // The descendant recursion advances on document depth without passing + // through the segment dispatcher, so it needs its own handoff to the + // iterative tier + if (depth >= JSONPATH_EVALUATION_RECURSION_LIMIT) { + evaluate_query(segments, cursor, current, state); + return; + } + + evaluate_selectors(segments, cursor, current, state, depth); + const auto type{current.type()}; + if (type == JSON::Type::Object) { + const auto &object{current.as_object()}; + auto [member, members_end]{member_range(object)}; + for (; member != members_end; ++member) { + state.frames.push_property(member->first, member->hash); + evaluate_descendant(segments, cursor, member->second, state, depth + 1); + state.frames.pop(); + } + } else if (type == JSON::Type::Array) { + const auto &array{current.as_array()}; + auto [element, elements_end]{element_range(array)}; + for (std::size_t index{0}; element != elements_end; ++element, ++index) { + state.frames.push_index(index); + evaluate_descendant(segments, cursor, *element, state, depth + 1); + state.frames.pop(); + } + } +} + +inline auto evaluate_segments(const std::vector &segments, + const std::size_t cursor, const JSON ¤t, + EvaluationState &state, const std::size_t depth) + -> void { + if (depth >= JSONPATH_EVALUATION_RECURSION_LIMIT) { + evaluate_query(segments, cursor, current, state); + return; + } + + if (cursor == segments.size()) { + state.frames.materialize(state.location); + (*state.callback)(current, state.location); + return; + } + + if ((segments.data() + cursor)->descendant) { + evaluate_descendant(segments, cursor, current, state, depth + 1); + } else { + evaluate_selectors(segments, cursor, current, state, depth + 1); + } +} + +} // namespace + +JSONPath::JSONPath(const JSON::StringView expression) + : expression_{expression}, query_{parse_jsonpath(expression)} {} + +auto JSONPath::evaluate(const JSON &document, const Callback &callback) const + -> void { + EvaluationState state{.root = &document, + .callback = &callback, + .frames = LocationStack{}, + .work = WorkStack{}, + .location = WeakPointer{}}; + state.location.reserve(32); + evaluate_segments(this->query_.segments, 0, document, state, 0); +} + +auto JSONPath::normalize(const WeakPointer &location) -> JSON::String { + JSON::String result{"$"}; + for (const auto &token : location) { + if (token.is_property()) { + result += "['"; + for (const char character : token.to_property()) { + // RFC 9535 Section 2.7: "normal-escapable" covers the apostrophe, + // the backslash, and the control characters with a two character + // JSON escape, while "normal-hexchar" covers the remaining control + // characters in lowercase hexadecimal form + switch (character) { + case '\x08': + result += "\\b"; + break; + case '\x0C': + result += "\\f"; + break; + case '\n': + result += "\\n"; + break; + case '\r': + result += "\\r"; + break; + case '\t': + result += "\\t"; + break; + case '\'': + result += "\\'"; + break; + case '\\': + result += "\\\\"; + break; + default: + if (static_cast(character) < 0x20) { + result += "\\u00"; + result += bytes_to_hex(JSON::StringView{&character, 1}); + } else { + result += character; + } + } + } + + result += "']"; + } else { + result += '['; + result += std::to_string(token.to_index()); + result += ']'; + } + } + + return result; +} + +auto JSONPath::to_json() const -> JSON { return JSON{this->expression_}; } + +auto JSONPath::from_json(const JSON &value) -> std::optional { + if (!value.is_string()) { + return std::nullopt; + } + + try { + return JSONPath{value.to_string()}; + } catch (const JSONPathParseError &) { + return std::nullopt; + } +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jsonpath/parser.h b/vendor/core/src/core/jsonpath/parser.h new file mode 100644 index 00000000..114d6aac --- /dev/null +++ b/vendor/core/src/core/jsonpath/parser.h @@ -0,0 +1,1085 @@ +#ifndef SOURCEMETA_CORE_JSONPATH_PARSER_H_ +#define SOURCEMETA_CORE_JSONPATH_PARSER_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include // assert +#include // std::size_t +#include // std::int64_t, std::uint32_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move +#include // std::get, std::holds_alternative + +namespace sourcemeta::core { + +namespace { + +// RFC 9535 Section 2.1: "an I-JSON number ... within the range of exact +// values" bounds every int production +constexpr std::int64_t JSONPATH_MAXIMUM_INTEGER{9007199254740991}; + +// RFC 9535 Section 4.1: crafted queries must not "trigger surprisingly +// high, possibly exponential, CPU usage or ... stack overflow", so the +// recursive filter expression productions are capped in nesting depth +constexpr std::size_t JSONPATH_MAXIMUM_NESTING_DEPTH{64}; + +class JSONPathParser { +public: + JSONPathParser(const std::string_view input) : input_{input} {} + + auto parse() -> JSONPath::Query { + // jsonpath-query = root-identifier segments + if (this->at_end() || this->peek() != '$') { + this->fail(); + } + + this->position_ += 1; + JSONPath::Query result; + bool singular{true}; + result.segments = this->parse_segments(singular); + if (!this->at_end()) { + this->fail(); + } + + return result; + } + +private: + [[noreturn]] auto fail() const -> void { + throw JSONPathParseError{this->position_ + 1}; + } + + [[nodiscard]] auto at_end() const -> bool { + return this->position_ >= this->input_.size(); + } + + [[nodiscard]] auto peek() const -> char { + assert(!this->at_end()); + return this->input_[this->position_]; + } + + // S = *( %x20 / %x09 / %x0A / %x0D ) + auto skip_whitespace() -> bool { + bool consumed{false}; + while (!this->at_end()) { + const char character{this->peek()}; + if (character == ' ' || character == '\t' || character == '\n' || + character == '\r') { + this->position_ += 1; + consumed = true; + } else { + break; + } + } + + return consumed; + } + + // Copy one full UTF-8 encoded code point, rejecting malformed sequences, + // overlong encodings, surrogates, and code points beyond U+10FFFF + auto copy_character(JSON::String &output) -> void { + const auto size{utf8_codepoint_length(this->input_, this->position_)}; + if (size == 0) { + this->fail(); + } + + output.append(this->input_.substr(this->position_, size)); + this->position_ += size; + } + + // segments = *(S segment) + auto parse_segments(bool &singular) -> std::vector { + std::vector segments; + while (true) { + const auto saved{this->position_}; + this->skip_whitespace(); + if (this->at_end() || (this->peek() != '.' && this->peek() != '[')) { + this->position_ = saved; + break; + } + + auto segment{this->parse_segment(singular)}; + segment.kind = classify_segment(segment); + segments.push_back(std::move(segment)); + } + + return segments; + } + + [[nodiscard]] static auto classify_segment(const JSONPath::Segment &segment) + -> JSONPath::SegmentKind { + if (segment.selectors.size() != 1) { + return JSONPath::SegmentKind::General; + } + + switch (static_cast( + segment.selectors.front().index())) { + case JSONPath::SelectorKind::Name: + return JSONPath::SegmentKind::SingleName; + case JSONPath::SelectorKind::Index: + return JSONPath::SegmentKind::SingleIndex; + default: + return JSONPath::SegmentKind::General; + } + } + + // segment = child-segment / descendant-segment + auto parse_segment(bool &singular) -> JSONPath::Segment { + JSONPath::Segment segment; + segment.descendant = false; + if (this->peek() == '[') { + this->parse_bracketed_selection(segment, singular); + return segment; + } + + this->position_ += 1; + if (!this->at_end() && this->peek() == '.') { + // descendant-segment = ".." (bracketed-selection / wildcard-selector / + // member-name-shorthand) + this->position_ += 1; + segment.descendant = true; + singular = false; + if (this->at_end()) { + this->fail(); + } + + if (this->peek() == '[') { + this->parse_bracketed_selection(segment, singular); + } else if (this->peek() == '*') { + this->position_ += 1; + segment.selectors.emplace_back(JSONPath::SelectorWildcard{}); + } else { + segment.selectors.emplace_back(this->parse_shorthand_name()); + } + + return segment; + } + + // child-segment = bracketed-selection / ("." (wildcard-selector / + // member-name-shorthand)) + if (this->at_end()) { + this->fail(); + } + + if (this->peek() == '*') { + this->position_ += 1; + singular = false; + segment.selectors.emplace_back(JSONPath::SelectorWildcard{}); + } else { + segment.selectors.emplace_back(this->parse_shorthand_name()); + } + + return segment; + } + + // bracketed-selection = "[" S selector *(S "," S selector) S "]" + // Whitespace and multiple selectors disqualify the enclosing query from + // the stricter singular-query grammar of RFC 9535 Section 2.3.5.1 + auto parse_bracketed_selection(JSONPath::Segment &segment, bool &singular) + -> void { + this->position_ += 1; + bool internal_whitespace{this->skip_whitespace()}; + segment.selectors.push_back(this->parse_selector(singular)); + while (true) { + internal_whitespace = this->skip_whitespace() || internal_whitespace; + if (this->at_end()) { + this->fail(); + } + + if (this->peek() == ',') { + this->position_ += 1; + this->skip_whitespace(); + segment.selectors.push_back(this->parse_selector(singular)); + } else if (this->peek() == ']') { + this->position_ += 1; + break; + } else { + this->fail(); + } + } + + if (segment.selectors.size() > 1 || internal_whitespace) { + singular = false; + } + } + + // selector = name-selector / wildcard-selector / slice-selector / + // index-selector / filter-selector + auto parse_selector(bool &singular) -> JSONPath::Selector { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + if (character == '"' || character == '\'') { + auto name{this->parse_string_literal()}; + const auto hash{JSON::Object::hash(name)}; + return JSONPath::SelectorName{.name = std::move(name), .hash = hash}; + } + + if (character == '*') { + this->position_ += 1; + singular = false; + return JSONPath::SelectorWildcard{}; + } + + if (character == '?') { + // filter-selector = "?" S logical-expr + this->position_ += 1; + singular = false; + this->skip_whitespace(); + return JSONPath::SelectorFilter{this->parse_logical_or()}; + } + + if (character == ':') { + singular = false; + return this->parse_slice(std::nullopt); + } + + if (character == '-' || is_digit(character)) { + const auto value{this->parse_integer()}; + // slice-selector = [start S] ":" S [end S] [":" [S step ]] + const auto saved{this->position_}; + this->skip_whitespace(); + if (!this->at_end() && this->peek() == ':') { + singular = false; + return this->parse_slice(value); + } + + this->position_ = saved; + return JSONPath::SelectorIndex{value}; + } + + this->fail(); + } + + auto parse_slice(const std::optional start) + -> JSONPath::SelectorSlice { + JSONPath::SelectorSlice slice; + slice.start = start; + slice.step = 1; + assert(this->peek() == ':'); + this->position_ += 1; + this->skip_whitespace(); + if (!this->at_end() && (this->peek() == '-' || is_digit(this->peek()))) { + slice.end = this->parse_integer(); + this->skip_whitespace(); + } + + if (!this->at_end() && this->peek() == ':') { + this->position_ += 1; + const auto saved{this->position_}; + this->skip_whitespace(); + if (!this->at_end() && (this->peek() == '-' || is_digit(this->peek()))) { + slice.step = this->parse_integer(); + } else { + this->position_ = saved; + } + } + + return slice; + } + + // int = "0" / (["-"] DIGIT1 *DIGIT) + auto parse_integer() -> std::int64_t { + const auto begin{this->position_}; + if (this->peek() == '-') { + this->position_ += 1; + } + + if (this->at_end() || !is_digit(this->peek())) { + this->fail(); + } + + if (this->peek() == '0') { + this->position_ += 1; + if (!this->at_end() && is_digit(this->peek())) { + this->fail(); + } + + if (this->input_[begin] == '-') { + this->fail(); + } + + return 0; + } + + while (!this->at_end() && is_digit(this->peek())) { + this->position_ += 1; + } + + const auto value{ + to_int64_t(this->input_.substr(begin, this->position_ - begin))}; + if (!value.has_value() || value.value() > JSONPATH_MAXIMUM_INTEGER || + value.value() < -JSONPATH_MAXIMUM_INTEGER) { + this->fail(); + } + + return value.value(); + } + + // string-literal = %x22 *double-quoted %x22 / %x27 *single-quoted %x27 + auto parse_string_literal() -> JSON::String { + const char quote{this->peek()}; + this->position_ += 1; + JSON::String result; + while (true) { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + if (character == quote) { + this->position_ += 1; + return result; + } + + if (character == '\\') { + this->position_ += 1; + this->parse_string_escape(result, quote); + continue; + } + + // unescaped starts at %x20, so raw control characters are not allowed + if (static_cast(character) < 0x20) { + this->fail(); + } + + this->copy_character(result); + } + } + + // escapable = %x62 / %x66 / %x6E / %x72 / %x74 / "/" / "\" / (%x75 hexchar) + // plus the quote of the enclosing kind + auto parse_string_escape(JSON::String &result, const char quote) -> void { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + switch (character) { + case 'b': + result += '\x08'; + this->position_ += 1; + return; + case 'f': + result += '\x0C'; + this->position_ += 1; + return; + case 'n': + result += '\n'; + this->position_ += 1; + return; + case 'r': + result += '\r'; + this->position_ += 1; + return; + case 't': + result += '\t'; + this->position_ += 1; + return; + case '/': + result += '/'; + this->position_ += 1; + return; + case '\\': + result += '\\'; + this->position_ += 1; + return; + case 'u': { + this->position_ += 1; + const auto code{this->parse_hex_quad()}; + // hexchar = non-surrogate / (high-surrogate "\" %x75 low-surrogate) + if (code >= 0xDC00 && code <= 0xDFFF) { + this->fail(); + } + + if (code >= 0xD800 && code <= 0xDBFF) { + if (this->at_end() || this->peek() != '\\') { + this->fail(); + } + + this->position_ += 1; + if (this->at_end() || this->peek() != 'u') { + this->fail(); + } + + this->position_ += 1; + const auto low{this->parse_hex_quad()}; + if (low < 0xDC00 || low > 0xDFFF) { + this->fail(); + } + + const auto combined{0x10000 + ((code - 0xD800) << 10) + + (low - 0xDC00)}; + codepoint_to_utf8(static_cast(combined), result); + return; + } + + codepoint_to_utf8(static_cast(code), result); + return; + } + default: + if (character == quote) { + result += quote; + this->position_ += 1; + return; + } + + this->fail(); + } + } + + auto parse_hex_quad() -> std::uint32_t { + std::uint32_t result{0}; + for (std::size_t count{0}; count < 4; ++count) { + if (this->at_end()) { + this->fail(); + } + + const auto value{hex_digit_value(this->peek())}; + if (value < 0) { + this->fail(); + } + + result = (result << 4) | static_cast(value); + this->position_ += 1; + } + + return result; + } + + // member-name-shorthand = name-first *name-char + auto parse_shorthand_name() -> JSONPath::SelectorName { + JSON::String name; + if (this->at_end()) { + this->fail(); + } + + const auto first{static_cast(this->peek())}; + if (first == '_' || is_alpha(static_cast(first))) { + name += static_cast(first); + this->position_ += 1; + } else if (first >= 0x80) { + this->copy_character(name); + } else { + this->fail(); + } + + while (!this->at_end()) { + const auto next{static_cast(this->peek())}; + if (next == '_' || is_alpha(static_cast(next)) || + is_digit(static_cast(next))) { + name += static_cast(next); + this->position_ += 1; + } else if (next >= 0x80) { + this->copy_character(name); + } else { + break; + } + } + + const auto hash{JSON::Object::hash(name)}; + return JSONPath::SelectorName{.name = std::move(name), .hash = hash}; + } + + // logical-or-expr = logical-and-expr *(S "||" S logical-and-expr) + auto parse_logical_or() -> JSONPath::FilterExpression { + // Every cycle in the filter grammar, through parentheses, nested filter + // selectors, or function arguments, passes through this production or + // the function one, so guarding both bounds the parser recursion + this->depth_ += 1; + if (this->depth_ > JSONPATH_MAXIMUM_NESTING_DEPTH) { + this->fail(); + } + + std::vector children; + children.push_back(this->parse_logical_and()); + while (true) { + const auto saved{this->position_}; + this->skip_whitespace(); + if (this->position_ + 1 < this->input_.size() && + this->input_[this->position_] == '|' && + this->input_[this->position_ + 1] == '|') { + this->position_ += 2; + this->skip_whitespace(); + children.push_back(this->parse_logical_and()); + } else { + this->position_ = saved; + break; + } + } + + this->depth_ -= 1; + if (children.size() == 1) { + return std::move(children.front()); + } + + return JSONPath::FilterExpression{ + JSONPath::FilterDisjunction{std::move(children)}}; + } + + // logical-and-expr = basic-expr *(S "&&" S basic-expr) + auto parse_logical_and() -> JSONPath::FilterExpression { + std::vector children; + children.push_back(this->parse_basic_expression()); + while (true) { + const auto saved{this->position_}; + this->skip_whitespace(); + if (this->position_ + 1 < this->input_.size() && + this->input_[this->position_] == '&' && + this->input_[this->position_ + 1] == '&') { + this->position_ += 2; + this->skip_whitespace(); + children.push_back(this->parse_basic_expression()); + } else { + this->position_ = saved; + break; + } + } + + if (children.size() == 1) { + return std::move(children.front()); + } + + return JSONPath::FilterExpression{ + JSONPath::FilterConjunction{std::move(children)}}; + } + + // basic-expr = paren-expr / comparison-expr / test-expr + auto parse_basic_expression() -> JSONPath::FilterExpression { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + if (character == '!') { + this->position_ += 1; + this->skip_whitespace(); + if (this->at_end()) { + this->fail(); + } + + if (this->peek() == '(') { + std::vector children; + children.push_back(this->parse_parenthesized()); + return JSONPath::FilterExpression{ + JSONPath::FilterNegation{.children = std::move(children)}}; + } + + return JSONPath::FilterExpression{this->parse_test(true)}; + } + + if (character == '(') { + return this->parse_parenthesized(); + } + + return this->parse_comparison_or_test(); + } + + // paren-expr = [logical-not-op S] "(" S logical-expr S ")" + auto parse_parenthesized() -> JSONPath::FilterExpression { + assert(this->peek() == '('); + this->position_ += 1; + this->skip_whitespace(); + auto inner{this->parse_logical_or()}; + this->skip_whitespace(); + if (this->at_end() || this->peek() != ')') { + this->fail(); + } + + this->position_ += 1; + return inner; + } + + // test-expr = [logical-not-op S] (filter-query / function-expr) + auto parse_test(const bool negated) -> JSONPath::FilterTest { + if (this->peek() == '@' || this->peek() == '$') { + bool singular{true}; + auto query{this->parse_filter_query(singular)}; + return JSONPath::FilterTest{.negated = negated, + .subject = std::move(query)}; + } + + if (is_alpha(this->peek()) && is_lowercase(this->peek())) { + auto call{this->parse_function()}; + // RFC 9535 Section 2.4.3: a test expression admits functions whose + // declared result is of logical or nodes type only + if (call.function != JSONPath::FilterFunctionName::Match && + call.function != JSONPath::FilterFunctionName::Search) { + this->fail(); + } + + return JSONPath::FilterTest{.negated = negated, + .subject = std::move(call)}; + } + + this->fail(); + } + + [[nodiscard]] auto comparison_operator_ahead() const -> bool { + if (this->at_end()) { + return false; + } + + const char character{this->input_[this->position_]}; + if (character == '<' || character == '>') { + return true; + } + + return (character == '=' || character == '!') && + this->position_ + 1 < this->input_.size() && + this->input_[this->position_ + 1] == '='; + } + + // comparison-op = "==" / "!=" / "<=" / ">=" / "<" / ">" + auto parse_comparison_operator() -> JSONPath::FilterComparisonOperator { + const char character{this->peek()}; + if (character == '=' || character == '!') { + this->position_ += 1; + if (this->at_end() || this->peek() != '=') { + this->fail(); + } + + this->position_ += 1; + return character == '=' ? JSONPath::FilterComparisonOperator::Equal + : JSONPath::FilterComparisonOperator::NotEqual; + } + + if (character == '<' || character == '>') { + this->position_ += 1; + const bool inclusive{!this->at_end() && this->peek() == '='}; + if (inclusive) { + this->position_ += 1; + } + + if (character == '<') { + return inclusive ? JSONPath::FilterComparisonOperator::LessEqual + : JSONPath::FilterComparisonOperator::Less; + } + + return inclusive ? JSONPath::FilterComparisonOperator::GreaterEqual + : JSONPath::FilterComparisonOperator::Greater; + } + + this->fail(); + } + + auto parse_comparison_or_test() -> JSONPath::FilterExpression { + const char character{this->peek()}; + if (character == '@' || character == '$') { + bool singular{true}; + auto query{this->parse_filter_query(singular)}; + const auto saved{this->position_}; + this->skip_whitespace(); + if (this->comparison_operator_ahead()) { + // RFC 9535 Section 2.3.5.1: "comparable = literal / singular-query / + // function-expr" + if (!singular) { + this->fail(); + } + + const auto operation{this->parse_comparison_operator()}; + this->skip_whitespace(); + auto right{this->parse_comparable()}; + return JSONPath::FilterExpression{JSONPath::FilterComparison{ + .left = JSONPath::FilterOperand{.value = std::move(query)}, + .operation = operation, + .right = std::move(right)}}; + } + + this->position_ = saved; + return JSONPath::FilterExpression{ + JSONPath::FilterTest{.negated = false, .subject = std::move(query)}}; + } + + if (character == '"' || character == '\'' || character == '-' || + is_digit(character)) { + auto literal{this->parse_filter_literal()}; + this->skip_whitespace(); + if (!this->comparison_operator_ahead()) { + this->fail(); + } + + const auto operation{this->parse_comparison_operator()}; + this->skip_whitespace(); + auto right{this->parse_comparable()}; + return JSONPath::FilterExpression{JSONPath::FilterComparison{ + .left = JSONPath::FilterOperand{.value = std::move(literal)}, + .operation = operation, + .right = std::move(right)}}; + } + + if (is_alpha(character) && is_lowercase(character)) { + auto operand{this->parse_keyword_or_function()}; + const auto saved{this->position_}; + this->skip_whitespace(); + if (this->comparison_operator_ahead()) { + if (std::holds_alternative( + operand.value) && + !is_value_type_function( + std::get(operand.value))) { + this->fail(); + } + + const auto operation{this->parse_comparison_operator()}; + this->skip_whitespace(); + auto right{this->parse_comparable()}; + return JSONPath::FilterExpression{ + JSONPath::FilterComparison{.left = std::move(operand), + .operation = operation, + .right = std::move(right)}}; + } + + this->position_ = saved; + if (!std::holds_alternative( + operand.value)) { + // A literal is not a valid test expression + this->fail(); + } + + auto call{ + std::move(std::get(operand.value))}; + if (call.function != JSONPath::FilterFunctionName::Match && + call.function != JSONPath::FilterFunctionName::Search) { + this->fail(); + } + + return JSONPath::FilterExpression{ + JSONPath::FilterTest{.negated = false, .subject = std::move(call)}}; + } + + this->fail(); + } + + static auto is_value_type_function(const JSONPath::FilterFunctionCall &call) + -> bool { + return call.function == JSONPath::FilterFunctionName::Length || + call.function == JSONPath::FilterFunctionName::Count || + call.function == JSONPath::FilterFunctionName::Value; + } + + // comparable = literal / singular-query / function-expr + auto parse_comparable() -> JSONPath::FilterOperand { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + if (character == '@' || character == '$') { + bool singular{true}; + auto query{this->parse_filter_query(singular)}; + if (!singular) { + this->fail(); + } + + return JSONPath::FilterOperand{.value = std::move(query)}; + } + + if (character == '"' || character == '\'' || character == '-' || + is_digit(character)) { + return JSONPath::FilterOperand{.value = this->parse_filter_literal()}; + } + + if (is_alpha(character) && is_lowercase(character)) { + auto operand{this->parse_keyword_or_function()}; + if (std::holds_alternative(operand.value) && + !is_value_type_function( + std::get(operand.value))) { + this->fail(); + } + + return operand; + } + + this->fail(); + } + + // literal = number / string-literal / true / false / null + auto parse_filter_literal() -> JSON { + const char character{this->peek()}; + if (character == '"' || character == '\'') { + return JSON{this->parse_string_literal()}; + } + + return this->parse_number(); + } + + // number = (int / "-0") [ frac ] [ exp ] + auto parse_number() -> JSON { + const auto begin{this->position_}; + if (this->peek() == '-') { + this->position_ += 1; + } + + if (this->at_end() || !is_digit(this->peek())) { + this->fail(); + } + + if (this->peek() == '0') { + this->position_ += 1; + if (!this->at_end() && is_digit(this->peek())) { + this->fail(); + } + } else { + while (!this->at_end() && is_digit(this->peek())) { + this->position_ += 1; + } + } + + bool real{false}; + // frac = "." 1*DIGIT + if (!this->at_end() && this->peek() == '.') { + real = true; + this->position_ += 1; + if (this->at_end() || !is_digit(this->peek())) { + this->fail(); + } + + while (!this->at_end() && is_digit(this->peek())) { + this->position_ += 1; + } + } + + // exp = "e" [ "-" / "+" ] 1*DIGIT + if (!this->at_end() && (this->peek() == 'e' || this->peek() == 'E')) { + real = true; + this->position_ += 1; + if (!this->at_end() && (this->peek() == '-' || this->peek() == '+')) { + this->position_ += 1; + } + + if (this->at_end() || !is_digit(this->peek())) { + this->fail(); + } + + while (!this->at_end() && is_digit(this->peek())) { + this->position_ += 1; + } + } + + const auto text{this->input_.substr(begin, this->position_ - begin)}; + if (!real) { + const auto value{to_int64_t(text)}; + if (!value.has_value() || value.value() > JSONPATH_MAXIMUM_INTEGER || + value.value() < -JSONPATH_MAXIMUM_INTEGER) { + this->fail(); + } + + return JSON{value.value()}; + } + + const auto value{to_double(text)}; + if (!value.has_value()) { + this->fail(); + } + + return JSON{value.value()}; + } + + // filter-query = rel-query / jsonpath-query + auto parse_filter_query(bool &singular) -> JSONPath::FilterQuery { + JSONPath::FilterQuery query; + query.relative = this->peek() == '@'; + this->position_ += 1; + query.segments = this->parse_segments(singular); + query.singular = singular; + return query; + } + + auto parse_identifier() -> std::string_view { + const auto begin{this->position_}; + while (!this->at_end() && + ((is_alpha(this->peek()) && is_lowercase(this->peek())) || + is_digit(this->peek()) || this->peek() == '_')) { + this->position_ += 1; + } + + return this->input_.substr(begin, this->position_ - begin); + } + + auto parse_keyword_or_function() -> JSONPath::FilterOperand { + const auto saved{this->position_}; + const auto identifier{this->parse_identifier()}; + if (!this->at_end() && this->peek() == '(') { + this->position_ = saved; + return JSONPath::FilterOperand{.value = this->parse_function()}; + } + + if (identifier == "true") { + return JSONPath::FilterOperand{.value = JSON{true}}; + } + + if (identifier == "false") { + return JSONPath::FilterOperand{.value = JSON{false}}; + } + + if (identifier == "null") { + return JSONPath::FilterOperand{.value = JSON{nullptr}}; + } + + this->fail(); + } + + // function-expr = function-name "(" S [function-argument + // *(S "," S function-argument)] S ")" + auto parse_function() -> JSONPath::FilterFunctionCall { + this->depth_ += 1; + if (this->depth_ > JSONPATH_MAXIMUM_NESTING_DEPTH) { + this->fail(); + } + + const auto identifier{this->parse_identifier()}; + JSONPath::FilterFunctionCall call; + if (identifier == "length") { + call.function = JSONPath::FilterFunctionName::Length; + } else if (identifier == "count") { + call.function = JSONPath::FilterFunctionName::Count; + } else if (identifier == "match") { + call.function = JSONPath::FilterFunctionName::Match; + } else if (identifier == "search") { + call.function = JSONPath::FilterFunctionName::Search; + } else if (identifier == "value") { + call.function = JSONPath::FilterFunctionName::Value; + } else { + this->fail(); + } + + if (this->at_end() || this->peek() != '(') { + this->fail(); + } + + this->position_ += 1; + this->skip_whitespace(); + if (this->at_end()) { + this->fail(); + } + + if (this->peek() != ')') { + while (true) { + call.arguments.push_back(this->parse_function_argument()); + this->skip_whitespace(); + if (!this->at_end() && this->peek() == ',') { + this->position_ += 1; + this->skip_whitespace(); + } else { + break; + } + } + } + + if (this->at_end() || this->peek() != ')') { + this->fail(); + } + + this->position_ += 1; + this->check_function(call); + this->depth_ -= 1; + return call; + } + + auto parse_function_argument() -> JSONPath::FilterOperand { + if (this->at_end()) { + this->fail(); + } + + const char character{this->peek()}; + if (character == '@' || character == '$') { + bool singular{true}; + return JSONPath::FilterOperand{.value = + this->parse_filter_query(singular)}; + } + + if (character == '"' || character == '\'' || character == '-' || + is_digit(character)) { + return JSONPath::FilterOperand{.value = this->parse_filter_literal()}; + } + + if (is_alpha(character) && is_lowercase(character)) { + return this->parse_keyword_or_function(); + } + + // The grammar also admits a logical expression argument, yet none of + // the RFC 9535 functions declares a parameter of logical type, so such + // an argument can never be well-typed per Section 2.4.3 + this->fail(); + } + + [[nodiscard]] static auto + is_value_type_operand(const JSONPath::FilterOperand &operand) -> bool { + if (std::holds_alternative(operand.value)) { + return true; + } + + if (std::holds_alternative(operand.value)) { + return std::get(operand.value).singular; + } + + return is_value_type_function( + std::get(operand.value)); + } + + // RFC 9535 Section 2.4.3: "the function's arguments need to be well-typed + // against the declared parameter types" + auto check_function(JSONPath::FilterFunctionCall &call) -> void { + switch (call.function) { + case JSONPath::FilterFunctionName::Length: + if (call.arguments.size() != 1 || + !is_value_type_operand(call.arguments.front())) { + this->fail(); + } + + return; + case JSONPath::FilterFunctionName::Count: + case JSONPath::FilterFunctionName::Value: + if (call.arguments.size() != 1 || + !std::holds_alternative( + call.arguments.front().value)) { + this->fail(); + } + + return; + case JSONPath::FilterFunctionName::Match: + case JSONPath::FilterFunctionName::Search: + if (call.arguments.size() != 2 || + !is_value_type_operand(call.arguments.front()) || + !is_value_type_operand(call.arguments.back())) { + this->fail(); + } + + if (std::holds_alternative(call.arguments.back().value) && + std::get(call.arguments.back().value).is_string()) { + call.compiled = + to_regex(std::get(call.arguments.back().value).to_string(), + call.function == JSONPath::FilterFunctionName::Match + ? RegexDialect::IRegexp + : RegexDialect::IRegexpSearch); + } + + return; + } + } + + std::string_view input_; + std::size_t position_{0}; + std::size_t depth_{0}; +}; + +} // namespace + +inline auto parse_jsonpath(const std::string_view input) -> JSONPath::Query { + JSONPathParser parser{input}; + return parser.parse(); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h index 2a77f719..c616e2e6 100644 --- a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h +++ b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h @@ -8,7 +8,7 @@ #include // std::size_t #include // std::reference_wrapper #include // std::initializer_list -#include // std::advance, std::back_inserter +#include // std::advance, std::back_inserter, std::next #include // std::optional #include // std::ranges::subrange #include // std::is_same_v, std::decay_t @@ -663,6 +663,47 @@ template class GenericPointer { this->data[prefix_size + 1].to_property() == tail_right; } + /// Check whether two JSON Pointers are equal up to a given number of + /// leading tokens. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::Pointer pointer{"foo", "bar", "baz"}; + /// const sourcemeta::core::Pointer other{"foo", "bar", "qux"}; + /// assert(pointer.shares_prefix(other, 2)); + /// assert(!pointer.shares_prefix(other, 3)); + /// ``` + [[nodiscard]] auto shares_prefix(const GenericPointer &other, + const size_type prefix_size) const -> bool { + return this->data.size() >= prefix_size && + other.data.size() >= prefix_size && + std::equal(this->data.cbegin(), + std::next(this->data.cbegin(), + static_cast(prefix_size)), + other.data.cbegin()); + } + + /// Check whether a JSON Pointer starts with another JSON Pointer without + /// the two being equal. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::Pointer pointer{"foo", "bar", "baz"}; + /// const sourcemeta::core::Pointer prefix{"foo", "bar"}; + /// assert(pointer.starts_with_strict(prefix)); + /// assert(!pointer.starts_with_strict(pointer)); + /// ``` + [[nodiscard]] auto + starts_with_strict(const GenericPointer &other) const + -> bool { + return this->data.size() > other.data.size() && + this->shares_prefix(other, other.data.size()); + } + /// Check whether a JSON Pointer starts with the initial part of another JSON /// Pointer. For example: /// 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 6602495e..4ca4b6dc 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 @@ -184,6 +184,39 @@ template class GenericToken { return this->hash; } + /// Check whether a JSON Pointer property token equals the given string, + /// comparing the precomputed hashes first and only falling back to a string + /// comparison when the hash is not perfect. For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// const sourcemeta::core::Pointer::Token token{"foo"}; + /// assert(token.property_equals( + /// "foo", sourcemeta::core::JSON::Object::hash("foo"))); + /// ``` + [[nodiscard]] auto + property_equals(const JSON::StringView value, + const typename Hash::hash_type value_hash) const noexcept + -> bool { + assert(this->is_property()); + assert(hasher(value.data(), value.size()) == value_hash); + if constexpr (requires { hasher.is_perfect(value_hash); }) { + // A perfect hash captures the property bytes but not its length, so + // two properties that differ only by trailing NUL bytes hash equal. + // Comparing sizes disambiguates them without the cost of a full + // string comparison + return this->hash == value_hash && + (hasher.is_perfect(value_hash) + ? this->to_property().size() == value.size() + : this->to_property() == value); + } else { + return this->hash == value_hash && this->to_property() == value; + } + } + /// Get the underlying value of a JSON Pointer object property token /// (non-`const` overload). For example: /// @@ -251,8 +284,13 @@ template class GenericToken { return false; } else if (this->as_property) { if constexpr (requires { hasher.is_perfect(this->hash); }) { + // A perfect hash captures the property bytes but not its length, so + // two properties that differ only by trailing NUL bytes hash equal. + // Comparing sizes disambiguates them without the cost of a full + // string comparison if (hasher.is_perfect(this->hash) && hasher.is_perfect(other.hash)) { - return this->hash == other.hash; + return this->hash == other.hash && + this->to_property().size() == other.to_property().size(); } } diff --git a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h index 7019df93..c2627dfa 100644 --- a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h +++ b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h @@ -42,6 +42,27 @@ namespace sourcemeta::core { SOURCEMETA_CORE_LANGTAG_EXPORT auto is_langtag(const std::string_view value) -> bool; +/// @ingroup langtag +/// Check whether the given string is a well-formed language tag per RFC 5646 +/// (BCP 47) that also follows the recommended casing conventions of RFC 5646 +/// Section 2.1.1: script subtags in titlecase, region subtags in uppercase, +/// and every other subtag, including every subtag after a singleton, in +/// lowercase. The deprecated irregular grandfathered tags are rejected, as +/// the registry maps each of them to a canonical replacement. For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// assert(sourcemeta::core::is_canonical_langtag("en-GB")); +/// assert(sourcemeta::core::is_canonical_langtag("zh-Hant-HK")); +/// assert(!sourcemeta::core::is_canonical_langtag("en-gb")); +/// assert(!sourcemeta::core::is_canonical_langtag("EN")); +/// ``` +SOURCEMETA_CORE_LANGTAG_EXPORT +auto is_canonical_langtag(const std::string_view value) -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/langtag/langtag.cc b/vendor/core/src/core/langtag/langtag.cc index ed00282e..5873a57f 100644 --- a/vendor/core/src/core/langtag/langtag.cc +++ b/vendor/core/src/core/langtag/langtag.cc @@ -249,4 +249,41 @@ auto is_langtag(const std::string_view value) -> bool { return is_irregular_grandfathered(value); } +auto is_canonical_langtag(const std::string_view value) -> bool { + // The registry maps every irregular grandfathered tag to a canonical + // replacement, so none of them is canonical. + if (!is_langtag(value) || is_irregular_grandfathered(value)) { + return false; + } + + bool initial{true}; + bool after_singleton{false}; + std::size_t position{0}; + while (position < value.size()) { + const auto subtag{subtag_at(value, position)}; + + // Per RFC 5646 Section 2.1.1, region subtags are uppercase and script + // subtags are titlecase, while every other subtag, including everything + // that follows a singleton, is lowercase. + const auto letters{is_alpha(subtag)}; + const bool region{!initial && !after_singleton && letters && + subtag.size() == 2}; + const bool script{!initial && !after_singleton && letters && + subtag.size() == 4}; + for (std::size_t index{0}; index < subtag.size(); index += 1) { + const bool expect_uppercase{region || (script && index == 0)}; + const bool uppercase{!is_lowercase(subtag[index])}; + if (uppercase != expect_uppercase) { + return false; + } + } + + initial = false; + after_singleton = after_singleton || subtag.size() == 1; + advance(value, position, subtag.size()); + } + + return true; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/CMakeLists.txt b/vendor/core/src/core/oauth/CMakeLists.txt new file mode 100644 index 00000000..7f5b785f --- /dev/null +++ b/vendor/core/src/core/oauth/CMakeLists.txt @@ -0,0 +1,30 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oauth + PRIVATE_HEADERS error.h profile.h pkce.h bearer.h random.h authorization.h + token.h client_authentication.h transaction.h metadata.h metadata_provider.h + token_exchange.h revocation.h introspection.h device.h dpop.h par.h + assertion.h registration.h + SOURCES oauth_error.cc oauth_pkce.cc oauth_bearer.cc oauth_syntax.h + oauth_random.cc oauth_authorization.cc oauth_authorization_parse.h + oauth_encode.h oauth_decode.h + oauth_json.h oauth_token.cc oauth_client_authentication.cc + oauth_transaction.cc oauth_metadata.cc oauth_metadata_provider.cc + oauth_ttl.h oauth_token_exchange.cc oauth_revocation.cc + oauth_introspection.cc oauth_device.cc oauth_dpop.cc oauth_par.cc + oauth_assertion.cc oauth_registration.cc) + +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::json) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::crypto) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::jose) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::http) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::uri) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::text) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth) +endif() diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h new file mode 100644 index 00000000..4e7f144e --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth.h @@ -0,0 +1,40 @@ +#ifndef SOURCEMETA_CORE_OAUTH_H_ +#define SOURCEMETA_CORE_OAUTH_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// NOLINTEND(misc-include-cleaner) + +/// @defgroup oauth OAuth +/// @brief A standards-driven implementation of the OAuth 2.0 and 2.1 message +/// family. +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h new file mode 100644 index 00000000..1218048f --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_assertion.h @@ -0,0 +1,243 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ASSERTION_H_ +#define SOURCEMETA_CORE_OAUTH_ASSERTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include +#include +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The grant type identifier of a JWT bearer authorization grant (RFC 7523 +/// Section 2.1). +inline constexpr std::string_view OAUTH_GRANT_TYPE_JWT_BEARER{ + "urn:ietf:params:oauth:grant-type:jwt-bearer"}; +/// @ingroup oauth +/// The client assertion type identifier of a JWT bearer client authentication +/// assertion (RFC 7523 Section 2.2). +inline constexpr std::string_view OAUTH_CLIENT_ASSERTION_TYPE_JWT_BEARER{ + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}; + +/// @ingroup oauth +/// Build a JWT bearer assertion (RFC 7523 Section 3) signed with the given key +/// and algorithm, carrying the issuer, subject, and a single audience, an +/// expiration a lifetime past the given time, an issue time, and a random +/// identifier, or no value when the key cannot sign. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// const auto assertion{sourcemeta::core::oauth_build_assertion( +/// "issuer", "subject", "https://server.example/token", +/// std::chrono::seconds{300}, std::chrono::system_clock::now(), +/// key.value(), sourcemeta::core::JWSAlgorithm::ES256)}; +/// assert(assertion.has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_build_assertion( + const std::string_view issuer, const std::string_view subject, + const std::string_view audience, const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional; + +/// @ingroup oauth +/// Build a JWT bearer client authentication assertion (RFC 7523 Section 3), a +/// self-issued assertion whose issuer and subject are both the client +/// identifier (RFC 7521 Section 5.2), or no value when the key cannot sign. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// const auto assertion{sourcemeta::core::oauth_build_client_assertion( +/// "s6BhdRkqt3", "https://server.example/token", std::chrono::seconds{300}, +/// std::chrono::system_clock::now(), key.value(), +/// sourcemeta::core::JWSAlgorithm::ES256)}; +/// assert(assertion.has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_build_client_assertion( + const std::string_view client_id, const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional; + +/// @ingroup oauth +/// Append the client authentication assertion parameters (RFC 7521 Section 4.2) +/// to the sink, the assertion and its JWT bearer type. No `client_id` is +/// emitted, since the client is identified by the assertion subject. The +/// assertion is a credential, so the sink is a wiping string, appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_assertion("eyJ...", body); +/// assert(std::string_view{body}.starts_with("client_assertion_type=")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_assertion(const std::string_view assertion, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append a JWT bearer authorization grant token request body (RFC 7523 +/// Section 2.1) to the sink, the grant type, the assertion, and the scope when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink when the client authenticates. The +/// assertion is a credential, so the sink is a wiping string, appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_jwt_bearer("eyJ...", "read", +/// body); +/// assert(std::string_view{body}.starts_with("grant_type=")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_jwt_bearer(const std::string_view assertion, + const std::string_view scope, + SecureString &sink) -> void; + +/// @ingroup oauth +/// The reason a JWT bearer assertion failed verification (RFC 7523 Section 3). +enum class OAuthAssertionError : std::uint8_t { + /// The assertion was not a well-formed JSON Web Token. + Malformed, + /// The algorithm was absent or outside the accepted set (RFC 7523 Section 5). + UnsupportedAlgorithm, + /// No key verified the signature. + UnknownKey, + /// The signature did not verify. + Signature, + /// The issuer claim was absent or did not match (RFC 7523 Section 3 check 1). + Issuer, + /// The subject claim was absent or did not match (RFC 7523 Section 3 check 2, + /// RFC 7521 Section 4.2). + Subject, + /// The audience claim did not identify this server (RFC 7523 Section 3 + /// check 3). + Audience, + /// The assertion has expired (RFC 7523 Section 3 check 4). + Expired, + /// The assertion is not yet valid (RFC 7523 Section 3 check 5). + NotYetValid, + /// The issue time lies in the future (RFC 7523 Section 3 check 6). + IssuedInFuture, + /// The identifier was already seen within its window, a replay (RFC 7523 + /// Section 3 check 7). + Replay +}; + +/// @ingroup oauth +/// The inputs to JWT bearer assertion verification beyond the request. The +/// accepted algorithms are matched exactly, so an empty set accepts none, and a +/// replay store, when set, rejects a reused identifier. +struct OAuthAssertionVerifyOptions { + /// The algorithms accepted per local policy, a non-owning view whose backing + /// storage must outlive the verification (RFC 7523 Section 5). + std::span allowed_algorithms{}; + /// The tolerance applied to the expiration, not-before, and issue times. + std::chrono::seconds clock_skew{std::chrono::seconds{0}}; + /// The store tracking assertion identifiers to reject a replay, ignored when + /// null or when the assertion carries no identifier (RFC 7523 Section 3 + /// check 7). + OAuthDPoPReplayStore *replay_store{nullptr}; +}; + +/// @ingroup oauth +/// Verify a JWT bearer client authentication assertion (RFC 7523 Section 3, +/// RFC 7521 Section 5.2), returning the first failing check or no value when it +/// verifies. The issuer and subject must both be the client identifier, the +/// subject must match a presented `client_id` when one is given (RFC 7521 +/// Section 4.2), and the audience must match one of the accepted values by +/// Simple String Comparison without normalization (RFC 3986 Section 6.2.1). A +/// failure is reported to the client as `invalid_client` (RFC 7521 +/// Section 4.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array +/// audiences{{"https://server.example/token"}}; const std::array +/// allowed{sourcemeta::core::JWSAlgorithm::ES256}; +/// sourcemeta::core::OAuthAssertionVerifyOptions options; +/// options.allowed_algorithms = allowed; +/// const auto error{sourcemeta::core::oauth_verify_client_assertion( +/// assertion, audiences, "s6BhdRkqt3", keys, +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthAssertionError::Expired); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_verify_client_assertion( + const std::string_view assertion, + const std::span expected_audiences, + const std::string_view request_client_id, const JWKS &keys, + const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional; + +/// @ingroup oauth +/// Verify a JWT bearer authorization grant assertion (RFC 7523 Section 3), +/// returning the first failing check or no value when it verifies. The issuer +/// must match the expected one, the subject identifies the accessor and is only +/// required to be present, and the audience must match one of the accepted +/// values by Simple String Comparison without normalization (RFC 3986 +/// Section 6.2.1). A failure is reported to the client as `invalid_grant` +/// (RFC 6749 Section 5.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array +/// audiences{{"https://server.example/token"}}; +/// sourcemeta::core::OAuthAssertionVerifyOptions options; +/// const auto error{sourcemeta::core::oauth_verify_assertion_grant( +/// assertion, "https://issuer.example", audiences, keys, +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthAssertionError::Issuer); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_verify_assertion_grant( + const std::string_view assertion, const std::string_view expected_issuer, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h new file mode 100644 index 00000000..0a985562 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h @@ -0,0 +1,298 @@ +#ifndef SOURCEMETA_CORE_OAUTH_AUTHORIZATION_H_ +#define SOURCEMETA_CORE_OAUTH_AUTHORIZATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::function +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// A single query or form parameter as a name and value pair. Both members are +/// non-owning views that must outlive any use of this parameter. For example: +/// +/// ```cpp +/// #include +/// +/// const sourcemeta::core::OAuthParameter parameter{"resource", +/// "https://api.example"}; +/// ``` +struct OAuthParameter { + /// The parameter name. + std::string_view name; + /// The parameter value, still in its raw unescaped form. + std::string_view value; +}; + +/// @ingroup oauth +/// A non-owning view of the parameters of an authorization request (RFC 6749 +/// Section 4.1.1). Every field borrows from the caller and must outlive any use +/// of this struct. An empty scalar field is treated as absent and is not +/// emitted, and the spans carry the repeatable and extension parameters. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.client_id = "s6BhdRkqt3"; +/// request.state = "xyz"; +/// std::string url; +/// sourcemeta::core::oauth_build_authorization_url( +/// "https://server.example/authorize", request, url); +/// assert(url == +/// "https://server.example/authorize?response_type=code" +/// "&client_id=s6BhdRkqt3&state=xyz"); +/// ``` +struct OAuthAuthorizationRequest { + /// The client identifier (RFC 6749 Section 2.2). + std::string_view client_id; + /// The redirection endpoint the response is returned to (RFC 6749 + /// Section 3.1.2). + std::string_view redirect_uri; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; + /// The opaque cross-site request forgery token echoed back on the response + /// (RFC 6749 Section 10.12). + std::string_view state; + /// The PKCE code challenge (RFC 7636 Section 4.3). + std::string_view code_challenge; + /// The PKCE code challenge method, emitted only alongside a challenge + /// (RFC 7636 Section 4.3). + std::string_view code_challenge_method; + /// The reference to a pushed authorization request (RFC 9126 Section 4). + std::string_view request_uri; + /// The JWK thumbprint of the DPoP proof public key (RFC 9449 Section 10). + std::string_view dpop_jkt; + /// The response type, a space-delimited set the authorization URL builder + /// forces to `code` while the pushed request builder honors an override, + /// surfaced on parse so a server can tell a missing value from one it does + /// not understand (RFC 6749 Section 3.1.1). It is placed after the older + /// members so an existing positional initializer keeps populating them. + std::string_view response_type; + /// The repeatable resource indicators, each an absolute URI without a + /// fragment (RFC 8707 Section 2). + std::span resources; + /// The extension parameters, such as an OpenID Connect `nonce`, emitted + /// verbatim after the known parameters. + std::span extra; +}; + +/// @ingroup oauth +/// Build an authorization request URL by appending the query to the endpoint +/// (RFC 6749 Section 4.1.1). The `response_type` is always `code`, since the +/// implicit grant is not represented, every value is percent-escaped, an +/// existing query on the endpoint is honored, and the code challenge method is +/// emitted only when a challenge is present. The sink is appended to and never +/// cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.client_id = "s6BhdRkqt3"; +/// request.redirect_uri = "https://client.example/cb"; +/// std::string url; +/// sourcemeta::core::oauth_build_authorization_url( +/// "https://server.example/authorize", request, url); +/// assert(url == +/// "https://server.example/authorize?response_type=code" +/// "&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fclient.example%2Fcb"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_url(const std::string_view endpoint, + const OAuthAuthorizationRequest &request, + std::string &sink) -> void; + +/// @ingroup oauth +/// Parse the query of an authorization request at the authorization server +/// (RFC 6749 Section 4.1.1) into the result, returning whether it is well +/// formed. 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. A duplicated parameter is a failure (RFC 6749 +/// Section 3.1), and `code_challenge_method` defaults to `plain` when a +/// challenge is present without one (RFC 7636 Section 4.3). Every repeatable +/// `resource` and every unrecognized parameter is passed to the callback with +/// its decoded value rather than stored on the result, so the caller collects +/// them. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// assert(sourcemeta::core::oauth_parse_authorization_request( +/// "response_type=code&client_id=s6BhdRkqt3", storage, request, +/// [](std::string_view, std::string_view) {})); +/// assert(request.response_type == "code"); +/// assert(request.client_id == "s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_authorization_request( + const std::string_view query, std::string &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// Whether a redirection URI a client presents matches one it registered +/// (RFC 6749 Section 3.1.2.3), by an exact byte comparison with one exception. +/// When the registered URI is a loopback redirect, an `http` URI whose host is +/// literally `127.0.0.1` or `[::1]`, only the port may differ (RFC 8252 +/// Section 7.3). The strict profile keeps the OAuth 2.1 rule that `localhost` +/// is not a loopback host (RFC 8252 Section 8.3). No URI is constructed, so the +/// inputs are compared as given. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_redirect_uri_matches( +/// "http://127.0.0.1:49152/cb", "http://127.0.0.1:51004/cb", +/// sourcemeta::core::OAuthProfile::Strict)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_redirect_uri_matches(const std::string_view registered, + const std::string_view presented, + const OAuthProfile profile) -> bool; + +/// @ingroup oauth +/// Whether a URI scheme is a private-use scheme suitable for a native app +/// redirect, a reverse-domain name that must contain at least one period +/// (RFC 8252 Section 7.1 and Section 8.4). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_private_use_scheme("com.example.app")); +/// assert(!sourcemeta::core::oauth_is_private_use_scheme("myapp")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_private_use_scheme(const std::string_view scheme) noexcept + -> bool; + +/// @ingroup oauth +/// A non-owning view of an authorization response returned on the redirection +/// endpoint (RFC 6749 Section 4.1.2). Each field borrows from the parsed query +/// or the decode arena, so both must outlive the view, and an absent parameter +/// is an empty view. A present `error` marks a failure response (RFC 6749 +/// Section 4.1.2.1). +struct OAuthAuthorizationResponse { + /// The authorization code (RFC 6749 Section 4.1.2). + std::string_view code; + /// The state value echoed from the request (RFC 6749 Section 4.1.2). + std::string_view state; + /// The issuer identifier of the authorization server (RFC 9207 Section 2). + std::string_view iss; + /// The error code of a failure response (RFC 6749 Section 4.1.2.1). + std::string_view error; + /// The human-readable error description of a failure response (RFC 6749 + /// Section 4.1.2.1). + std::string_view error_description; + /// The URI of a human-readable error page for a failure response (RFC 6749 + /// Section 4.1.2.1). + std::string_view error_uri; +}; + +/// @ingroup oauth +/// Parse the query of an authorization response (RFC 6749 Section 4.1.2) into +/// the result, returning whether the query is well formed. Each recognized +/// value is form-decoded (RFC 6749 Appendix B), borrowing from the input when +/// it carries no escape and otherwise from the storage arena, which the caller +/// owns and which is reused across parses. A duplicated recognized parameter is +/// a failure, and an unrecognized parameter is ignored. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// assert(sourcemeta::core::oauth_parse_authorization_response( +/// "code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", storage, response)); +/// assert(response.code == "SplxlOBeZQQYbYS6WxSbIA"); +/// assert(response.state == "xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_authorization_response(const std::string_view query, + std::string &storage, + OAuthAuthorizationResponse &result) + -> bool; + +/// @ingroup oauth +/// Build a successful authorization redirect at the authorization server by +/// appending the query to the client's redirection endpoint (RFC 6749 +/// Section 4.1.2), returning whether it was produced. The `code` is required, +/// `state` is echoed when present, and an `iss` is emitted when present and +/// must be a valid issuer identifier (RFC 9207 Section 2). No value is produced +/// when the redirect URI contains a fragment, which a redirection endpoint must +/// not (RFC 6749 Section 3.1.2). Every value is percent-escaped, an existing +/// query on the endpoint is honored, and the sink is appended to and never +/// cleared. The redirect URI and the response fields must not alias the sink. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.code = "SplxlOBeZQQYbYS6WxSbIA"; +/// response.state = "xyz"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_redirect( +/// "https://client.example/cb", response, url)); +/// assert(url == "https://client.example/cb?code=SplxlOBeZQQYbYS6WxSbIA" +/// "&state=xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool; + +/// @ingroup oauth +/// Build a failed authorization redirect at the authorization server (RFC 6749 +/// Section 4.1.2.1), returning whether it was produced. The `error` is +/// required, `error_description`, `error_uri`, and `state` are emitted when +/// present, and an `iss` is emitted when present and must be a valid issuer +/// identifier (RFC 9207 Section 2). This must not be called when the redirect +/// URI or client identifier failed validation, since the error is then shown to +/// the resource owner rather than redirected. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.state = "xyz"; +/// response.error = "access_denied"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_error_redirect( +/// "https://client.example/cb", response, url)); +/// assert(url == +/// "https://client.example/cb?error=access_denied&state=xyz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h new file mode 100644 index 00000000..a5db27ec --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -0,0 +1,163 @@ +#ifndef SOURCEMETA_CORE_OAUTH_BEARER_H_ +#define SOURCEMETA_CORE_OAUTH_BEARER_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append a `Bearer` credential (RFC 6750 Section 2.1) for an access token to +/// the sink, returning whether the token is a well-formed `b64token`. Nothing +/// is appended when it is not. The sink then holds the access token, which is +/// secret, so a caller that keeps it should use wiping storage. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string header; +/// assert(sourcemeta::core::oauth_bearer_header("mF_9.B5f-4.1JqM", header)); +/// assert(header == "Bearer mF_9.B5f-4.1JqM"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool; + +/// @ingroup oauth +/// Find the value of one authentication parameter within the challenge for a +/// scheme in a `WWW-Authenticate` header value (RFC 7235 Section 4.1), +/// returning no value when the scheme or parameter is absent, or the header is +/// malformed before the parameter is reached. The scheme and parameter name are +/// matched case insensitively, and a quoted-string value is unescaped (RFC 9110 +/// Section 5.6.4). The full grammar is parsed so that adjacent challenges and +/// parameters are told apart correctly. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto realm{sourcemeta::core::oauth_challenge_parameter( +/// R"(Bearer realm="example", error="invalid_token")", "Bearer", "realm")}; +/// assert(realm.has_value()); +/// assert(realm.value() == "example"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name) + -> std::optional; + +/// @ingroup oauth +/// The parameters of a `WWW-Authenticate` challenge a protected resource +/// returns (RFC 6750 Section 3, RFC 9728 Section 5.1, RFC 9449 Section 7.1). +/// Every field is a non-owning view that must outlive any use of this struct, +/// and an empty field is omitted from the challenge. +struct OAuthChallenge { + /// The protection space the credentials apply to (RFC 6750 Section 3). + std::string_view realm; + /// The space-delimited scope the token must carry (RFC 6750 Section 3). + std::string_view scope; + /// The error code (RFC 6750 Section 3.1). + std::string_view error; + /// The human-readable error description (RFC 6750 Section 3). + std::string_view error_description; + /// The URI of a human-readable error page (RFC 6750 Section 3). + std::string_view error_uri; + /// The URL of the protected resource metadata document (RFC 9728 + /// Section 5.1). + std::string_view resource_metadata; + /// The space-delimited JWS algorithms the DPoP scheme accepts (RFC 9449 + /// Section 7.1). + std::string_view algs; +}; + +/// @ingroup oauth +/// Build a `WWW-Authenticate` challenge for a scheme and append it to the sink, +/// returning whether a challenge was produced (RFC 7235 Section 4.1). Each +/// present parameter is emitted as a quoted-string with the double quote and +/// backslash escaped (RFC 9110 Section 5.6.4). A challenge with no parameter +/// yields a bare scheme, which RFC 7235 Section 2.1 permits and a DPoP +/// challenge may use (RFC 9449 Section 7.1), so the `Bearer` scheme's own rule +/// that it carry at least one parameter (RFC 6750 Section 3) is the caller's +/// responsibility. Nothing is appended and false is returned only when the +/// scheme is not a token or a value carries a control character that would +/// allow header injection. Only header safety is enforced, so keeping each +/// value within the tighter character set its own attribute defines (RFC 6750 +/// Section 3) is likewise the caller's responsibility. It is a pure function, +/// so a resource server builds its fixed challenge once and caches it. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthChallenge challenge; +/// challenge.realm = "example"; +/// challenge.error = "invalid_token"; +/// std::string header; +/// sourcemeta::core::oauth_build_challenge("Bearer", challenge, header); +/// assert(header == R"(Bearer realm="example", error="invalid_token")"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_challenge(const std::string_view scheme, + const OAuthChallenge &challenge, std::string &sink) + -> bool; + +/// @ingroup oauth +/// Whether a set of access token claims names an audience, so that a resource +/// server accepts only a token minted for it (RFC 9068 Section 4, RFC 7662 +/// Section 2.2). The claims are the payload of a JWT access token or an +/// introspection response, and the `aud` claim is honored whether it is a +/// single string or an array of strings. Each value is treated as an opaque +/// case-sensitive string (RFC 7519 Section 4.1.3) and compared by code points +/// (RFC 3986 Section 6.2.1), with no normalization. An empty audience never +/// matches. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto claims{ +/// sourcemeta::core::parse_json(R"JSON({"aud":"https://api.example"})JSON")}; +/// assert(sourcemeta::core::oauth_has_audience(claims, "https://api.example")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_has_audience(const JSON &claims, const std::string_view audience) + -> bool; + +/// @ingroup oauth +/// Whether a set of access token claims carries the DPoP confirmation that +/// binds the token to a proof-of-possession key, namely a `jkt` JWK thumbprint +/// (RFC 9449 Section 6.1). A resource server that receives such a token under +/// the `Bearer` scheme must reject it, since a key-bound token stripped of its +/// proof is being replayed (RFC 9449 Section 7.2). Only the DPoP confirmation +/// is detected, not other sender-constraining methods such as mutual-TLS. The +/// claims are the payload of a JWT access token or an introspection response. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"cnf":{"jkt":"0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}})JSON")}; +/// assert(sourcemeta::core::oauth_is_dpop_bound(claims)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_dpop_bound(const JSON &claims) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h new file mode 100644 index 00000000..1f4a6c3d --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_client_authentication.h @@ -0,0 +1,151 @@ +#ifndef SOURCEMETA_CORE_OAUTH_CLIENT_AUTHENTICATION_H_ +#define SOURCEMETA_CORE_OAUTH_CLIENT_AUTHENTICATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append an HTTP `Basic` authentication credential (RFC 6749 Section 2.3.1) to +/// the sink, for use as an `Authorization` header value. The client identifier +/// and secret are each percent-encoded, joined with a colon, and Base64 +/// encoded. The credential is secret, so the sink is a wiping string, and it is +/// appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString header; +/// sourcemeta::core::oauth_client_secret_basic("s6BhdRkqt3", "gX1fBat3bV", +/// header); +/// assert(header == "Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_secret_basic(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append the `client_secret_post` client authentication parameters (RFC 6749 +/// Section 2.3.1) to a token request body. Both the identifier and the secret +/// are emitted, so this composes into the same sink as a grant builder. The +/// body carries a secret, so the sink is a wiping string, and it is appended to +/// and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_secret_post("id", "secret", body); +/// assert(body == "client_id=id&client_secret=secret"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_secret_post(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append a public client identification parameter (RFC 6749 Section 3.2.1) to +/// a token request body, emitting the identifier alone with no secret. This +/// composes into the same sink as a grant builder. The sink is appended to and +/// never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_client_id_only("s6BhdRkqt3", body); +/// assert(body == "client_id=s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_client_id_only(const std::string_view client_id, SecureString &sink) + -> void; + +/// @ingroup oauth +/// The client authentication mechanism a token request presented (RFC 6749 +/// Section 2.3). `Public` is a bare client identifier with no secret, which is +/// identification rather than an authentication mechanism (RFC 6749 +/// Section 3.2.1). +enum class OAuthClientAuthenticationMethod : std::uint8_t { + /// No client authentication and no identifier were presented. + None, + /// An HTTP `Basic` credential (RFC 6749 Section 2.3.1). + Basic, + /// A `client_secret` in the request body (RFC 6749 Section 2.3.1). + Post, + /// A bare `client_id` in the request body, identifying a public client + /// (RFC 6749 Section 3.2.1). + Public, + /// A `client_assertion` and its type (RFC 7521 Section 4.2). + Assertion +}; + +/// @ingroup oauth +/// The client credentials a token request presented, each a non-owning view +/// into the request or the decode arena (RFC 6749 Section 2.3). An absent field +/// is an empty view. +struct OAuthClientCredentials { + /// The mechanism the request presented. + OAuthClientAuthenticationMethod method; + /// The client identifier. + std::string_view client_id; + /// The client secret, present only for `Basic` and `Post`. + std::string_view client_secret; + /// The assertion type, present only for `Assertion` (RFC 7521 Section 4.2). + std::string_view assertion_type; + /// The assertion, present only for `Assertion` (RFC 7521 Section 4.2). + std::string_view assertion; +}; + +/// @ingroup oauth +/// Parse the client authentication a token request presented, from the +/// `Authorization` header value and the request body, into the credentials +/// (RFC 6749 Section 2.3). Returns whether the presentation is well formed. It +/// is malformed when more than one mechanism is presented (RFC 6749 +/// Section 2.3, RFC 7521 Section 4.2.1), when a `Basic` username conflicts with +/// a body `client_id` (RFC 6749 Section 5.2), when the `Basic` credential is +/// not a canonical Base64 of a colon-separated pair, or when only one of the +/// assertion parameters is present. The caller chooses the error code for a +/// rejection, which is `invalid_client` when a collision involves the assertion +/// mechanism (RFC 7521 Section 4.2.1) and `invalid_request` otherwise (RFC 6749 +/// Section 5.2). The decoded `Basic` credential is secret, so the arena is a +/// wiping string, which the caller owns, should clear between independent +/// parses, and must not alias the header or body inputs. This detects the +/// mechanism and extracts the credentials, but does +/// not verify the secret, which the caller does against its stored value in +/// constant time, nor that an `Assertion` `client_id` identifies the same +/// client as the assertion (RFC 7521 Section 4.2), which the caller checks when +/// it verifies the assertion. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthClientCredentials credentials; +/// assert(sourcemeta::core::oauth_parse_client_authentication( +/// "Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW", "", storage, credentials)); +/// assert(credentials.method == +/// sourcemeta::core::OAuthClientAuthenticationMethod::Basic); +/// assert(credentials.client_id == "s6BhdRkqt3"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_client_authentication(const std::string_view authorization, + const std::string_view body, + SecureString &storage, + OAuthClientCredentials &credentials) + -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h new file mode 100644 index 00000000..c1786814 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_device.h @@ -0,0 +1,254 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DEVICE_H_ +#define SOURCEMETA_CORE_OAUTH_DEVICE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include +#include + +#include // std::array +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// Append a device authorization request body (RFC 8628 Section 3.1) to the +/// sink. The `client_id` is required for a public client, the `scope` is +/// emitted when present, and the repeatable resources follow. This request has +/// no secret, so the sink is an ordinary string, appended to and never cleared. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string body; +/// sourcemeta::core::oauth_build_device_authorization_request( +/// "1406020730", "read", {}, body); +/// assert(body == "client_id=1406020730&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_device_authorization_request( + const std::string_view client_id, const std::string_view scope, + const std::span resources, std::string &sink) -> void; + +/// @ingroup oauth +/// Append a device grant token request body (RFC 8628 Section 3.4) to the sink. +/// The `device_code` is required, and the repeatable resources follow. No +/// `client_id` is emitted, so the caller composes a client authentication +/// builder into the same sink. The device code is a credential, so the sink is +/// a wiping string, appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_device( +/// "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", {}, body); +/// assert(body == "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3A" +/// "device_code" +/// "&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_device( + const std::string_view device_code, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// A non-owning view over a device authorization response (RFC 8628 +/// Section 3.2) held in a caller-owned JSON value, which must outlive the view. +/// A client keeps displaying the user code even when it uses the complete +/// verification URI (RFC 8628 Section 3.3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "device_code": "GmRh", "user_code": "WDJB-MJHT", +/// "verification_uri": "https://example.com/device", "expires_in": 1800 +/// })JSON")}; +/// const sourcemeta::core::OAuthDeviceAuthorizationResponse response{document}; +/// assert(response.user_code().value() == "WDJB-MJHT"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDeviceAuthorizationResponse { +public: + /// Construct a view over a device authorization response, which is borrowed. + explicit OAuthDeviceAuthorizationResponse(const JSON &data); + + /// The device verification code (RFC 8628 Section 3.2). + [[nodiscard]] auto device_code() const -> std::optional; + /// The end-user verification code (RFC 8628 Section 3.2). + [[nodiscard]] auto user_code() const -> std::optional; + /// The end-user verification URI (RFC 8628 Section 3.2). + [[nodiscard]] auto verification_uri() const + -> std::optional; + /// The verification URI with the user code included (RFC 8628 Section 3.2). + [[nodiscard]] auto verification_uri_complete() const + -> std::optional; + /// The lifetime of the device and user codes, no value when non-positive + /// (RFC 8628 Section 3.2). + [[nodiscard]] auto expires_in() const -> std::optional; + /// The minimum polling interval, defaulting to five seconds when absent + /// (RFC 8628 Section 3.2). + [[nodiscard]] auto interval() const -> std::chrono::seconds; + + /// The underlying document. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// What a device flow client does after a token endpoint error (RFC 8628 +/// Section 3.5). +enum class OAuthDevicePollDecision : std::uint8_t { + /// Keep polling at the current interval. + Continue, + /// The request needs a fresh DPoP nonce, so the client re-issues the proof + /// with the server-supplied nonce and polls again without growing the + /// interval (RFC 9449 Section 8). + RetryWithNonce, + /// The end user denied the request. + Denied, + /// The codes expired before approval. + Expired, + /// A terminal error other than pending, slow down, denial, expiry, or a + /// nonce requirement. + Error +}; + +/// @ingroup oauth +/// A pure state machine driving the device flow polling (RFC 8628 Section 3.5). +/// It tracks the polling interval and the code lifetime, and interprets token +/// endpoint errors, without performing any I/O. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDevicePoller poller{ +/// std::chrono::seconds{5}, std::chrono::seconds{1800}, +/// std::chrono::steady_clock::now()}; +/// assert(poller.observe(sourcemeta::core::OAuthTokenError::SlowDown) == +/// sourcemeta::core::OAuthDevicePollDecision::Continue); +/// assert(poller.interval() == std::chrono::seconds{10}); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDevicePoller { +public: + /// Construct a poller with the interval and lifetime the device authorization + /// response advertised, from a starting time. A steady clock is used so wall + /// clock adjustments cannot shorten or extend the lifetime. An interval of + /// zero or less defaults to five seconds (RFC 8628 Section 3.5). + OAuthDevicePoller(const std::chrono::seconds interval, + const std::chrono::seconds lifetime, + const std::chrono::steady_clock::time_point start) noexcept; + + /// The current polling interval, which a `slow_down` error grows. + [[nodiscard]] auto interval() const noexcept -> std::chrono::seconds; + + /// Whether the codes have expired locally by the given time. + [[nodiscard]] auto + expired(const std::chrono::steady_clock::time_point now) const noexcept + -> bool; + + /// Interpret a token endpoint error, permanently adding five seconds to the + /// interval on `slow_down` and continuing, continuing on + /// `authorization_pending`, retrying with a nonce on a DPoP nonce requirement + /// (RFC 9449 Section 8), and reporting a terminal decision otherwise (RFC + /// 8628 Section 3.5). + auto observe(const OAuthTokenError error) noexcept -> OAuthDevicePollDecision; + +private: + std::chrono::seconds interval_; + std::chrono::seconds lifetime_; + std::chrono::steady_clock::time_point start_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// Build a device authorization response document (RFC 8628 Section 3.2), +/// returning no value when a required part is missing: an empty device code, +/// user code, or verification URI, or a non-positive `expires_in`, which is a +/// REQUIRED positive lifetime. The verification URI complete is emitted when +/// present, and the interval only when it is positive and differs from the +/// default, so a client never sees a zero or negative polling delay. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto +/// response{sourcemeta::core::oauth_make_device_authorization_response( +/// "GmRh", "WDJB-MJHT", "https://example.com/device", "", +/// std::chrono::seconds{1800}, std::chrono::seconds{5})}; +/// assert(response.value().at("user_code").to_string() == "WDJB-MJHT"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_device_authorization_response( + const std::string_view device_code, const std::string_view user_code, + const std::string_view verification_uri, + const std::string_view verification_uri_complete, + const std::chrono::seconds expires_in, const std::chrono::seconds interval) + -> std::optional; + +/// @ingroup oauth +/// Mint a device flow user code, eight characters from the RFC 8628 Section 6.1 +/// recommended twenty-character alphabet, drawn with rejection sampling for a +/// uniform distribution. The code is shown to the end user, not kept secret. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto code{sourcemeta::core::oauth_device_user_code()}; +/// assert(code.size() == 8); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_device_user_code() -> std::array; + +/// @ingroup oauth +/// Whether a user code the end user typed matches a stored one, comparing after +/// discarding every non-alphanumeric character and folding to uppercase, so the +/// separators and case a user adds do not matter (RFC 8628 Section 6.1). The +/// final comparison is constant time. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_device_user_code_matches("wdjb-mjht", +/// "WDJBMJHT")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_device_user_code_matches(const std::string_view presented, + const std::string_view stored) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h new file mode 100644 index 00000000..e288850b --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_dpop.h @@ -0,0 +1,336 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DPOP_H_ +#define SOURCEMETA_CORE_OAUTH_DPOP_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include + +#include // std::array +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::size_t +#include // std::uint8_t +#include // std::map +#include // std::mutex +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view +#include // std::vector + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The token type of a DPoP-bound access token (RFC 9449 Section 5), the value +/// a bound token response carries and a client confirms before use. +inline constexpr std::string_view OAUTH_TOKEN_TYPE_DPOP{"DPoP"}; + +/// @ingroup oauth +/// A client-side minter of DPoP proof JSON Web Tokens (RFC 9449 Section 4.2) +/// that holds one proof-of-possession key and the most recent nonce each server +/// has issued. Nonces are tracked per server because a nonce is only accepted +/// by the server that issued it (RFC 9449 Section 9), so a caller keys the +/// authorization server by its issuer and each resource server by its origin. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// #include +/// +/// auto key{sourcemeta::core::JWKPrivate::from_pem(pem)}; +/// assert(key.has_value()); +/// sourcemeta::core::OAuthDPoPProofer proofer{ +/// std::move(key.value()), sourcemeta::core::JWSAlgorithm::ES256}; +/// std::string header; +/// assert(proofer.proof("https://server.example.com", "POST", +/// "https://server.example.com/token", "", +/// std::chrono::system_clock::now(), header)); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDPoPProofer { +public: + /// Construct a proofer bound to a proof-of-possession key and the asymmetric + /// signature algorithm to sign proofs with (RFC 9449 Section 4.2). + OAuthDPoPProofer(JWKPrivate key, const JWSAlgorithm algorithm); + + /// A proofer owns a private key and a mutex, so it is neither copied nor + /// moved. + OAuthDPoPProofer(const OAuthDPoPProofer &) = delete; + auto operator=(const OAuthDPoPProofer &) -> OAuthDPoPProofer & = delete; + OAuthDPoPProofer(OAuthDPoPProofer &&) = delete; + auto operator=(OAuthDPoPProofer &&) -> OAuthDPoPProofer & = delete; + ~OAuthDPoPProofer() = default; + + /// Append a DPoP proof for a request to the sink, returning whether it could + /// be built and signed. The method and target URI are covered by the proof, + /// the access token binds the proof through its hash when it is presented to + /// a protected resource and is omitted otherwise (RFC 9449 Section 7), and + /// the most recent nonce the server issued is included when one is known. The + /// creation time is taken from the given clock reading (RFC 9449 + /// Section 4.2). + [[nodiscard]] auto + proof(const std::string_view server, const std::string_view method, + const std::string_view url, const std::string_view access_token, + const std::chrono::system_clock::time_point now, std::string &sink) + -> bool; + + /// Record the most recent nonce a server issued through its response header, + /// to be included in later proofs to that server, ignoring a malformed nonce + /// rather than echoing it (RFC 9449 Section 8). + auto observe(const std::string_view server, const std::string_view nonce) + -> void; + + /// The JSON Web Key thumbprint of the proof-of-possession key (RFC 9449 + /// Section 6.1), the value a client sends to bind an authorization code to + /// the key (RFC 9449 Section 10), or no value when the public part cannot be + /// recovered. + [[nodiscard]] auto thumbprint() const -> std::optional; + +private: + JWKPrivate key_; + JWSAlgorithm algorithm_; +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + mutable std::mutex mutex_; + std::map> nonces_; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +/// @ingroup oauth +/// Build the confirmation value that binds an access token to a DPoP key +/// (RFC 9449 Section 6.1), for a server to assign under the `cnf` claim of a +/// JSON Web Token access token or a top-level `cnf` of a token introspection +/// response (RFC 9449 Section 6.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto confirmation{sourcemeta::core::oauth_dpop_confirmation( +/// "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I")}; +/// assert(confirmation.at("jkt").to_string() == +/// "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_dpop_confirmation(const std::string_view thumbprint) -> JSON; + +/// @ingroup oauth +/// The JSON Web Key thumbprint of the key a DPoP proof is signed with (RFC 9449 +/// Section 6.1), the value a server matches against an authorization code +/// binding (RFC 9449 Section 10) or binds an issued token to, or no value when +/// the proof or its embedded key cannot be read. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto thumbprint{sourcemeta::core::oauth_dpop_proof_thumbprint(proof)}; +/// assert(!thumbprint.has_value() || !thumbprint.value().empty()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_dpop_proof_thumbprint(const std::string_view proof) + -> std::optional; + +/// @ingroup oauth +/// The reason a DPoP proof failed verification (RFC 9449 Section 4.3), one per +/// check performed, with a missing nonce kept distinct from a mismatched one +/// because a server resupplies a nonce for the former (RFC 9449 Section 9). +enum class OAuthDPoPError : std::uint8_t { + /// More or fewer than one DPoP header field was present (check 1). + ProofCount, + /// The header value was not a single well-formed JSON Web Token (check 2). + Malformed, + /// A required header parameter or claim was absent (check 3). + MissingClaim, + /// The token type header parameter was not `dpop+jwt` (check 4). + UnexpectedType, + /// The algorithm was absent, symmetric, or outside the accepted set + /// (check 5). + UnsupportedAlgorithm, + /// The embedded key carried private material (check 7). + PrivateKey, + /// The signature did not verify with the embedded key (check 6). + Signature, + /// The method claim did not match the request method (check 8). + MethodMismatch, + /// The target claim did not match the request target (check 9). + TargetMismatch, + /// A nonce was required but absent, the downgrade the server must reject + /// (check 10, RFC 9449 Section 11.3). + MissingNonce, + /// The nonce claim did not match the nonce the server issued (check 10). + NonceMismatch, + /// The creation time was outside the acceptable window (check 11). + Expired, + /// The access token hash was absent or did not match the presented token + /// (check 12). + AccessTokenMismatch, + /// The proof key did not match the key the access token is bound to, or a + /// token was presented with no binding to confirm it against (check 12). + KeyMismatch +}; + +/// @ingroup oauth +/// The inputs to DPoP proof verification a caller supplies beyond the request +/// (RFC 9449 Section 4.3). Every field has a safe default, so a token endpoint +/// verifying a proof with no bound token leaves the token and thumbprint unset, +/// and a protected resource sets both. +struct OAuthDPoPVerifyOptions { + /// The number of DPoP header fields the request carried, checked to be + /// exactly one (RFC 9449 Section 4.3 check 1). + std::size_t proof_count{1}; + /// The asymmetric algorithms accepted per local policy, every asymmetric + /// algorithm when empty (RFC 9449 Section 4.3 check 5). + std::span allowed_algorithms{}; + /// How far in the past a creation time may be (RFC 9449 Section 11.1). + std::chrono::seconds past_window{std::chrono::seconds{300}}; + /// How far in the future a creation time may be, to tolerate clock offset + /// (RFC 9449 Section 11.1). + std::chrono::seconds future_window{std::chrono::seconds{5}}; + /// The nonce the server issued to the client, requiring a matching nonce + /// claim when set (RFC 9449 Section 9). + std::optional expected_nonce{std::nullopt}; + /// The access token presented alongside the proof, requiring a matching hash + /// claim when set, and requiring the bound thumbprint to also be set so the + /// key binding is confirmed rather than skipped (RFC 9449 Section 7). + std::optional access_token{std::nullopt}; + /// The thumbprint the access token is bound to, requiring a matching proof + /// key when set (RFC 9449 Section 4.3 check 12). + std::optional bound_thumbprint{std::nullopt}; +}; + +/// @ingroup oauth +/// Verify a DPoP proof against the request it accompanies (RFC 9449 +/// Section 4.3), returning the first failing check or no value when every check +/// passes. The checks are run in a fixed order though the specification permits +/// any order, and replay is a separate concern the caller enforces after this +/// passes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDPoPVerifyOptions options; +/// const auto error{sourcemeta::core::oauth_dpop_verify( +/// proof, "POST", "https://server.example.com/token", +/// std::chrono::system_clock::now(), options)}; +/// assert(!error.has_value() || +/// error.value() == sourcemeta::core::OAuthDPoPError::Expired); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_dpop_verify( + const std::string_view proof, const std::string_view method, + const std::string_view url, const std::chrono::system_clock::time_point now, + const OAuthDPoPVerifyOptions &options) -> std::optional; + +/// @ingroup oauth +/// An in-memory store of the proof identifiers seen within their acceptance +/// window, to reject a replayed DPoP proof at one target (RFC 9449 +/// Section 11.1). A store instance is safe to share across threads, it holds a +/// hash of each identifier rather than the identifier itself, and it is bounded +/// to a fixed capacity so an attacker minting distinct proofs cannot exhaust +/// memory. When full it fails closed, rejecting a new identifier rather than +/// evicting a live entry whose replay guard is still needed, so a flood costs +/// availability rather than replay protection. Give each entry a window that +/// covers the whole acceptance window a proof enjoys, the sum of the past and +/// future tolerances, so an entry never expires while its proof is still +/// accepted. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthDPoPReplayStore store; +/// const auto now{std::chrono::system_clock::now()}; +/// assert(store.check_and_insert("id", "https://server.example.com/token", now, +/// std::chrono::seconds{300})); +/// assert(!store.check_and_insert("id", "https://server.example.com/token", +/// now, +/// std::chrono::seconds{300})); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthDPoPReplayStore { +public: + /// The default maximum number of live entries a store retains. + static constexpr std::size_t DEFAULT_CAPACITY{131072}; + + /// Construct an empty store with the given maximum number of live entries, + /// beyond which a new identifier is rejected until a slot frees on expiry. + explicit OAuthDPoPReplayStore( + const std::size_t capacity = DEFAULT_CAPACITY) noexcept + : capacity_{capacity == 0 ? DEFAULT_CAPACITY : capacity} {} + + /// A store owns a mutex, so it is neither copied nor moved. + OAuthDPoPReplayStore(const OAuthDPoPReplayStore &) = delete; + auto operator=(const OAuthDPoPReplayStore &) + -> OAuthDPoPReplayStore & = delete; + OAuthDPoPReplayStore(OAuthDPoPReplayStore &&) = delete; + auto operator=(OAuthDPoPReplayStore &&) -> OAuthDPoPReplayStore & = delete; + ~OAuthDPoPReplayStore() = default; + + /// Record a proof identifier at a target and report whether it is new, + /// returning false for one already seen within its window, which is a replay + /// (RFC 9449 Section 11.1). Entries whose window has elapsed by the given + /// time are pruned, and a new identifier is refused once the store is full of + /// live entries, so the return also stands for a store that cannot admit it. + /// The target is canonicalized as an HTTP target URI when `normalize_target` + /// is set, so an equivalent but differently spelled DPoP target still + /// collides (RFC 9449 Section 4.3 check 9), and keyed verbatim otherwise, for + /// a target compared without normalization such as an assertion audience. + [[nodiscard]] auto + check_and_insert(const std::string_view identifier, + const std::string_view target, + const std::chrono::system_clock::time_point now, + const std::chrono::seconds window, + const bool normalize_target = true) -> bool; + + /// The number of entries whose window has not elapsed by the given time, + /// counted without modifying the store, since expired entries are pruned when + /// the next one is recorded. + [[nodiscard]] auto size(const std::chrono::system_clock::time_point now) const + -> std::size_t; + +private: + struct Entry { + std::array digest; + std::chrono::system_clock::time_point expiry; + }; + + std::size_t capacity_; +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + mutable std::mutex mutex_; + std::vector entries_; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +/// @ingroup oauth +/// Whether a value is a well-formed DPoP nonce, a non-empty string of the nonce +/// character set (RFC 9449 Section 8.1, RFC 6749 Appendix A), so a client +/// validates a received nonce before echoing it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_valid_dpop_nonce("eyJ7S_zG.eyJH0-Z.HX4w")); +/// assert(!sourcemeta::core::oauth_is_valid_dpop_nonce("")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_is_valid_dpop_nonce(const std::string_view value) noexcept -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h new file mode 100644 index 00000000..b0e3c8a2 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -0,0 +1,315 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ERROR_H_ +#define SOURCEMETA_CORE_OAUTH_ERROR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::exception +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The error codes an authorization endpoint returns in its redirect +/// (RFC 6749 Section 4.1.2.1). +enum class OAuthAuthorizationError : std::uint8_t { + /// The request is missing a parameter, includes an invalid value, or is + /// otherwise malformed. + InvalidRequest, + /// The client is not authorized to request a code using this method. + UnauthorizedClient, + /// The resource owner or authorization server denied the request. + AccessDenied, + /// The authorization server does not support this response type. + UnsupportedResponseType, + /// The requested scope is invalid, unknown, or malformed. + InvalidScope, + /// The authorization server encountered an unexpected condition. + ServerError, + /// The authorization server is temporarily unable to handle the request. + TemporarilyUnavailable +}; + +/// @ingroup oauth +/// The error codes a token endpoint returns (RFC 6749 Section 5.2), extended +/// with the codes the device grant, resource indicators, token exchange, DPoP, +/// and token revocation add to the same endpoint family. +enum class OAuthTokenError : std::uint8_t { + /// The request is missing a parameter, includes an unsupported value, or is + /// otherwise malformed (RFC 6749 Section 5.2). + InvalidRequest, + /// Client authentication failed (RFC 6749 Section 5.2). + InvalidClient, + /// The grant or refresh token is invalid, expired, revoked, or mismatched + /// (RFC 6749 Section 5.2). + InvalidGrant, + /// The authenticated client is not authorized to use this grant type + /// (RFC 6749 Section 5.2). + UnauthorizedClient, + /// The grant type is not supported by the authorization server (RFC 6749 + /// Section 5.2). + UnsupportedGrantType, + /// The requested scope is invalid, unknown, or exceeds the grant (RFC 6749 + /// Section 5.2). + InvalidScope, + /// The device authorization is still pending user approval (RFC 8628 + /// Section 3.5). + AuthorizationPending, + /// The client is polling too frequently and must slow down (RFC 8628 + /// Section 3.5). + SlowDown, + /// The end user denied the device authorization request (RFC 8628 + /// Section 3.5). + AccessDenied, + /// The device code expired before the user approved it (RFC 8628 + /// Section 3.5). + ExpiredToken, + /// The requested resource or audience is invalid or unknown (RFC 8707 + /// Section 2, RFC 8693 Section 2.2.2). + InvalidTarget, + /// The DPoP proof is missing or invalid (RFC 9449 Section 5). + InvalidDPoPProof, + /// The authorization server requires the client to use a DPoP nonce + /// (RFC 9449 Section 8). + UseDPoPNonce, + /// The token type is not supported by the revocation endpoint (RFC 7009 + /// Section 2.2.1). + UnsupportedTokenType +}; + +/// @ingroup oauth +/// The error codes a protected resource returns in its `WWW-Authenticate` +/// challenge (RFC 6750 Section 3.1), shared by the `Bearer` and `DPoP` +/// schemes. +enum class OAuthBearerError : std::uint8_t { + /// The request is malformed (RFC 6750 Section 3.1). + InvalidRequest, + /// The access token is expired, revoked, malformed, or otherwise invalid + /// (RFC 6750 Section 3.1). + InvalidToken, + /// The token does not carry the scope the request requires (RFC 6750 + /// Section 3.1). + InsufficientScope, + /// The DPoP proof accompanying the request is missing or invalid, returned + /// only under the `DPoP` scheme (RFC 9449 Section 7.1). + InvalidDPoPProof, + /// The request must be retried with a DPoP proof that carries a nonce the + /// resource server supplies, returned only under the `DPoP` scheme (RFC 9449 + /// Section 9). + UseDPoPNonce +}; + +/// @ingroup oauth +/// The error codes a dynamic client registration endpoint returns (RFC 7591 +/// Section 3.2.2). +enum class OAuthRegistrationError : std::uint8_t { + /// A redirect URI in the request is invalid. + InvalidRedirectURI, + /// A field in the client metadata is invalid. + InvalidClientMetadata, + /// The software statement is invalid. + InvalidSoftwareStatement, + /// The software statement is not approved by the authorization server. + UnapprovedSoftwareStatement +}; + +/// @ingroup oauth +/// The wire code for an authorization endpoint error (RFC 6749 +/// Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthAuthorizationError::AccessDenied) == +/// "access_denied"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a token endpoint error (RFC 6749 Section 5.2 and its +/// extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthTokenError::InvalidGrant) == +/// "invalid_grant"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthTokenError error) noexcept -> std::string_view; + +/// @ingroup oauth +/// The wire code for a protected resource challenge error (RFC 6750 +/// Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthBearerError::InvalidToken) == +/// "invalid_token"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a dynamic client registration error (RFC 7591 +/// Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthRegistrationError::InvalidRedirectURI) == +/// "invalid_redirect_uri"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map an authorization endpoint error code to its value, returning no value +/// for an unrecognized code (RFC 6749 Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_authorization_error("invalid_scope") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a token endpoint error code to its value, returning no value for an +/// unrecognized code (RFC 6749 Section 5.2 and its extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_token_error("slow_down").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a protected resource challenge error code to its value, returning no +/// value for an unrecognized code (RFC 6750 Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_bearer_error("invalid_token").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a dynamic client registration error code to its value, returning no +/// value for an unrecognized code (RFC 7591 Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_registration_error("invalid_redirect_uri") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// The HTTP status for a token endpoint error response. It is 400 Bad Request +/// in general, but 401 Unauthorized for a client authentication failure when +/// the client authenticated through the `Authorization` header, since that +/// response must then carry a `WWW-Authenticate` challenge (RFC 6749 +/// Section 5.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidClient, true).code == +/// 401); +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidGrant, true).code == +/// 400); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> HTTPStatus; + +/// @ingroup oauth +/// The HTTP status a protected resource returns for a bearer error, as the +/// SHOULD-level recommendation of RFC 6750 Section 3.1: 400 for a malformed +/// request, 401 for an invalid token, and 403 for insufficient scope. The DPoP +/// resource codes accompany a 401 (RFC 9449 Section 7). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_bearer_error_status( +/// sourcemeta::core::OAuthBearerError::InsufficientScope).code == +/// 403); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_bearer_error_status(const OAuthBearerError error) noexcept + -> HTTPStatus; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup oauth +/// An error that occurs when parsing an invalid authorization server or +/// protected resource metadata document. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthMetadataParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth metadata document"; + } +}; + +/// @ingroup oauth +/// An error that occurs when parsing an invalid dynamic client registration +/// request or response. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthRegistrationParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth client registration document"; + } +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h new file mode 100644 index 00000000..cabb7505 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_introspection.h @@ -0,0 +1,123 @@ +#ifndef SOURCEMETA_CORE_OAUTH_INTROSPECTION_H_ +#define SOURCEMETA_CORE_OAUTH_INTROSPECTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::chrono::seconds +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// Append a token introspection request body (RFC 7662 Section 2.1) to the +/// sink. The `token` is required, and the `token_type_hint` is emitted only +/// when present. The authorization the endpoint requires is supplied +/// separately, since RFC 7662 Section 2.1 mandates it but does not fix the +/// method. The token is secret, so the sink is a wiping string, and it is +/// appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_introspection_request( +/// "mF_9.B5f-4.1JqM", "", body); +/// assert(body == "token=mF_9.B5f-4.1JqM"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_introspection_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void; + +/// @ingroup oauth +/// A non-owning view over a token introspection response (RFC 7662 Section 2.2) +/// held in a caller-owned JSON value, which must outlive the view. The `active` +/// state is computed once at construction, since it is the one per-request hot +/// accessor. A response must not be cached past its `exp` (RFC 7662 Section 4). +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json( +/// R"JSON({"active":true,"scope":"read","client_id":"abc"})JSON")}; +/// const sourcemeta::core::OAuthIntrospectionResponse response{document}; +/// assert(response.active()); +/// assert(response.scope().value() == "read"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthIntrospectionResponse { +public: + /// Construct a view over an introspection response document, which is + /// borrowed. + explicit OAuthIntrospectionResponse(const JSON &data); + + /// Whether the token is active (RFC 7662 Section 2.2), computed at + /// construction. A missing or non-boolean `active` is treated as inactive. + [[nodiscard]] auto active() const noexcept -> bool; + + /// The space-delimited scope (RFC 7662 Section 2.2), or no value when absent. + [[nodiscard]] auto scope() const -> std::optional; + /// The client identifier the token was issued to (RFC 7662 Section 2.2). + [[nodiscard]] auto client_id() const -> std::optional; + /// The resource owner username (RFC 7662 Section 2.2). + [[nodiscard]] auto username() const -> std::optional; + /// The token type, which is `DPoP` for a DPoP-bound token (RFC 7662 + /// Section 2.2, RFC 9449 Section 6.2). + [[nodiscard]] auto token_type() const -> std::optional; + /// The subject of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto subject() const -> std::optional; + /// The issuer of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto issuer() const -> std::optional; + /// The identifier of the token (RFC 7662 Section 2.2). + [[nodiscard]] auto jti() const -> std::optional; + /// The expiration time (RFC 7662 Section 2.2), or no value when absent. + [[nodiscard]] auto expiration() const -> std::optional; + /// The issuance time (RFC 7662 Section 2.2). + [[nodiscard]] auto issued_at() const -> std::optional; + /// The not-before time (RFC 7662 Section 2.2). + [[nodiscard]] auto not_before() const -> std::optional; + + /// The underlying document, for reaching members without a typed accessor + /// such as `aud`, `may_act`, or `cnf`. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; + bool active_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// Build the minimal inactive introspection response `{ "active": false }` +/// (RFC 7662 Section 2.2). The minimality is a recommendation, while the field +/// value is the requirement, so a caller may add members. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_introspection_inactive()}; +/// assert(!response.at("active").to_boolean()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_introspection_inactive() -> JSON; + +} // namespace sourcemeta::core + +#endif 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 new file mode 100644 index 00000000..69c85770 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata.h @@ -0,0 +1,329 @@ +#ifndef SOURCEMETA_CORE_OAUTH_METADATA_H_ +#define SOURCEMETA_CORE_OAUTH_METADATA_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_t +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The kind of metadata document a well-known URL points at, which selects the +/// suffix and whether it is inserted before the path or appended after it. +enum class OAuthWellKnownKind : std::uint8_t { + /// The `oauth-authorization-server` document, inserted before the path + /// (RFC 8414 Section 3). + AuthorizationServer, + /// The `oauth-protected-resource` document, inserted before the path + /// (RFC 9728 Section 3). + ProtectedResource, + /// The `openid-configuration` document, inserted before the path (RFC 8414 + /// Section 5). + OpenIDConfigurationInserted, + /// The `openid-configuration` document, appended after the path, the legacy + /// OpenID Connect Discovery form (RFC 8414 Section 5). + OpenIDConfigurationAppended +}; + +/// @ingroup oauth +/// Derive a metadata well-known URL from an identifier and append it to the +/// sink, returning whether the identifier is well formed (RFC 8414 Section 3, +/// RFC 9728 Section 3). The identifier must use the `https` scheme and carry no +/// fragment, and no query unless it is a protected resource. A terminating +/// slash on the path is removed, and for the inserted kinds the well-known +/// string is placed between the host and the path, preserving a protected +/// resource query after it. The sink is appended to and never cleared. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string url; +/// assert(sourcemeta::core::oauth_well_known_url( +/// "https://example.com/issuer1", +/// sourcemeta::core::OAuthWellKnownKind::AuthorizationServer, url)); +/// assert(url == +/// "https://example.com/.well-known/oauth-authorization-server/issuer1"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_well_known_url(const std::string_view identifier, + const OAuthWellKnownKind kind, std::string &sink) + -> bool; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// An authorization server metadata document (RFC 8414), owning its JSON. The +/// document is validated on construction against the issuer it was retrieved +/// for: the issuer must match by exact code points and be a valid issuer +/// identifier, `response_types_supported` must be present and non-empty, and an +/// authentication method that needs a signing algorithm list must carry a +/// non-empty one without `none`. Accessors apply the specification defaults, +/// and any member without a typed accessor is reachable through the underlying +/// document. A string accessor returns a view into the owned document, valid +/// for the lifetime of this object, so a view taken before the object is moved +/// from must not be used afterward. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json( +/// R"JSON({"issuer":"https://example.com", +/// "response_types_supported":["code"], +/// "authorization_endpoint":"https://example.com/authorize", +/// "token_endpoint":"https://example.com/token"})JSON")}; +/// const auto metadata{sourcemeta::core::OAuthServerMetadata::from( +/// std::move(document), "https://example.com")}; +/// assert(metadata.has_value()); +/// assert(metadata.value().token_endpoint().value() == +/// "https://example.com/token"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthServerMetadata { +public: + /// Construct and validate a metadata document for an expected issuer, + /// throwing when it is invalid. The document is moved in. + OAuthServerMetadata(JSON &&data, const std::string_view issuer); + + /// 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; + + /// The issuer identifier (RFC 8414 Section 2). + [[nodiscard]] auto issuer() const -> std::string_view; + + /// The authorization endpoint (RFC 8414 Section 2). + [[nodiscard]] auto authorization_endpoint() const + -> std::optional; + + /// The token endpoint (RFC 8414 Section 2). + [[nodiscard]] auto token_endpoint() const -> std::optional; + + /// The dynamic client registration endpoint (RFC 8414 Section 2). + [[nodiscard]] auto registration_endpoint() const + -> std::optional; + + /// The token revocation endpoint (RFC 8414 Section 2). + [[nodiscard]] auto revocation_endpoint() const + -> std::optional; + + /// The token introspection endpoint (RFC 8414 Section 2). + [[nodiscard]] auto introspection_endpoint() const + -> std::optional; + + /// The JWK Set document location (RFC 8414 Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// The pushed authorization request endpoint (RFC 9126 Section 5). + [[nodiscard]] auto pushed_authorization_request_endpoint() const + -> std::optional; + + /// Whether the server accepts authorization request data only through the + /// pushed authorization request endpoint, defaulting to false when absent + /// (RFC 9126 Section 5). + [[nodiscard]] auto require_pushed_authorization_requests() const -> bool; + + /// Whether the server signs authorization responses with an `iss` parameter, + /// defaulting to false when absent (RFC 9207 Section 3). + [[nodiscard]] auto authorization_response_iss_parameter_supported() const + -> bool; + + /// Whether a response type is supported (RFC 8414 Section 2). + [[nodiscard]] auto supports_response_type(const std::string_view value) const + -> bool; + + /// Whether a grant type is supported, defaulting to the authorization code + /// and implicit grants when absent (RFC 8414 Section 2). + [[nodiscard]] auto supports_grant_type(const std::string_view value) const + -> bool; + + /// Whether a PKCE code challenge method is supported, defaulting to none when + /// absent since an omitted list means PKCE is unsupported (RFC 8414 + /// Section 2). + [[nodiscard]] auto + supports_code_challenge_method(const std::string_view value) const -> bool; + + /// Whether a token endpoint authentication method is supported, defaulting to + /// `client_secret_basic` when absent (RFC 8414 Section 2). + [[nodiscard]] auto + supports_token_endpoint_auth_method(const std::string_view value) const + -> bool; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +/// @ingroup oauth +/// A protected resource metadata document (RFC 9728), owning its JSON. The +/// document is validated on construction against the resource it was retrieved +/// for: `resource` must be present, a valid resource identifier (an https URL +/// with no fragment, a query tolerated), and identical by code points to the +/// expected resource (RFC 9728 Section 3.3). Pass the resource identifier the +/// 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: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json(R"JSON({ +/// "resource": "https://api.example", +/// "authorization_servers": [ "https://auth.example" ] +/// })JSON")}; +/// const auto metadata{sourcemeta::core::OAuthResourceMetadata::from( +/// std::move(document), "https://api.example")}; +/// assert(metadata.has_value()); +/// assert(metadata.value().first_authorization_server().value() == +/// "https://auth.example"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthResourceMetadata { +public: + /// Construct and validate a metadata document for an expected resource, + /// throwing when it is invalid. The document is moved in. + OAuthResourceMetadata(JSON &&data, const std::string_view resource); + + /// Construct and validate a metadata document for an expected resource, + /// returning no value when it is invalid. The document is moved in. + [[nodiscard]] static auto from(JSON &&data, const std::string_view resource) + -> std::optional; + + /// The resource identifier (RFC 9728 Section 2). + [[nodiscard]] auto resource() const -> std::string_view; + + /// The first authorization server that can issue tokens for the resource, + /// which a client resolves to its metadata (RFC 9728 Section 5), or no value + /// when none is listed. + [[nodiscard]] auto first_authorization_server() const + -> std::optional; + + /// Whether an authorization server issuer is listed (RFC 9728 Section 2). + [[nodiscard]] auto + supports_authorization_server(const std::string_view value) const -> bool; + + /// The JWK Set document location for the resource's own keys (RFC 9728 + /// Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// Whether a bearer token presentation method is listed (RFC 9728 Section 2), + /// where an explicit empty list means none is supported. Absence is + /// unspecified rather than unsupported, so this returns false then. + [[nodiscard]] auto supports_bearer_method(const std::string_view value) const + -> bool; + + /// Whether a scope is listed for the resource (RFC 9728 Section 2). + [[nodiscard]] auto supports_scope(const std::string_view value) const -> bool; + + /// Whether the resource requires DPoP-bound access tokens, defaulting to + /// false when absent (RFC 9728 Section 2). + [[nodiscard]] auto dpop_bound_access_tokens_required() const -> bool; + + /// The underlying document, for reaching members without a typed accessor + /// such as the internationalized names. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// The configuration an authorization server publishes as its metadata +/// (RFC 8414 Section 2), each field a non-owning view. An empty scalar and a +/// zero-element array are omitted, since RFC 8414 Section 3.2 forbids a +/// zero-element array in the response. +struct OAuthServerMetadataConfig { + /// The issuer identifier (RFC 8414 Section 2), REQUIRED. + std::string_view issuer; + /// The authorization endpoint (RFC 8414 Section 2). + std::string_view authorization_endpoint; + /// The token endpoint (RFC 8414 Section 2). + std::string_view token_endpoint; + /// The dynamic client registration endpoint (RFC 8414 Section 2). + std::string_view registration_endpoint; + /// The JWK Set document location (RFC 8414 Section 2). + std::string_view jwks_uri; + /// The supported response types (RFC 8414 Section 2), REQUIRED and non-empty. + std::span response_types_supported; + /// The supported grant types (RFC 8414 Section 2). + std::span grant_types_supported; + /// The supported PKCE code challenge methods (RFC 8414 Section 2, RFC 7636). + std::span code_challenge_methods_supported; + /// The supported token endpoint authentication methods (RFC 8414 Section 2). + std::span token_endpoint_auth_methods_supported; + /// The supported JWS algorithms for the `private_key_jwt` and + /// `client_secret_jwt` token endpoint authentication methods (RFC 8414 + /// Section 2). REQUIRED and must exclude `none` when either of those methods + /// is advertised. + std::span + token_endpoint_auth_signing_alg_values_supported; + /// The supported scopes (RFC 8414 Section 2). + std::span scopes_supported; + /// The pushed authorization request endpoint (RFC 9126 Section 5). New + /// members are kept at the end so an existing positional initializer keeps + /// mapping to the older fields. + std::string_view pushed_authorization_request_endpoint; + /// Whether authorization request data is accepted only through the pushed + /// authorization request endpoint, emitted only when true (RFC 9126 + /// Section 5). + bool require_pushed_authorization_requests{false}; +}; + +/// @ingroup oauth +/// Build an authorization server metadata document for the well-known endpoint +/// (RFC 8414 Section 2), returning no value when the document would be +/// unusable: the issuer is not a valid issuer identifier, the required response +/// types are empty, the required authorization endpoint or (unless only the +/// implicit grant is offered) token endpoint is missing, any advertised +/// endpoint or JWK Set location is not a valid https URL, the signing algorithm +/// list contains `none`, or a JWT token endpoint authentication method is +/// advertised without a non-empty signing algorithm list. Each present scalar +/// and each non-empty array is emitted, and a zero-element array is omitted +/// (RFC 8414 Section 3.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array response_types{{"code"}}; +/// sourcemeta::core::OAuthServerMetadataConfig config; +/// config.issuer = "https://server.example"; +/// config.response_types_supported = response_types; +/// const auto document{sourcemeta::core::oauth_make_server_metadata(config)}; +/// assert(document.has_value()); +/// assert(document.value().at("issuer").to_string() == +/// "https://server.example"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h new file mode 100644 index 00000000..e8effd76 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_metadata_provider.h @@ -0,0 +1,235 @@ +#ifndef SOURCEMETA_CORE_OAUTH_METADATA_PROVIDER_H_ +#define SOURCEMETA_CORE_OAUTH_METADATA_PROVIDER_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::function +#include // std::shared_ptr +#include // std::mutex +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// A long-lived, cached resolver of authorization server metadata (RFC 8414). +/// It derives the well-known URL from an issuer, retrieves and validates the +/// document through an injected transport, and caches it with a freshness-aware +/// refresh. It is meant to be constructed once per issuer at startup, since a +/// per-request instance defeats the caching. Reads take a snapshot, so a +/// returned document is immune to a concurrent refresh. The kind must be an +/// authorization server kind, either `AuthorizationServer` or an OpenID Connect +/// configuration form, since a protected resource document is not an +/// authorization server metadata document and would never validate. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthMetadataProvider provider{ +/// "https://example.com", +/// sourcemeta::core::OAuthWellKnownKind::AuthorizationServer, +/// [](std::string_view) -> std::optional< +/// sourcemeta::core::OAuthMetadataProvider::FetchResult> { +/// return sourcemeta::core::OAuthMetadataProvider::FetchResult{ +/// .body = +/// R"JSON({"issuer":"https://example.com", +/// "response_types_supported":["code"]})JSON", +/// .max_age = std::nullopt}; +/// }}; +/// const auto metadata{provider.metadata()}; +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthMetadataProvider { +public: + /// The outcome of one metadata retrieval. The freshness hint is kept separate + /// from the body because only the caller that made the request can observe + /// the caching response header. An absent hint means no lifetime was + /// advertised, which is distinct from an advertised lifetime of zero. + struct FetchResult { + /// The raw metadata bytes. + std::string body; + /// The advertised freshness lifetime, absent when none was given. + std::optional max_age; + }; + + /// A pluggable transport that turns a URL into raw metadata bytes plus an + /// optional freshness hint. Returns no value on a failed retrieval, such as a + /// transport error, an unsuccessful response, or an oversized body. Injecting + /// the transport keeps this module free of any networking dependency and + /// makes the provider substitutable for testing. A refresh runs the transport + /// while holding the cache lock, which serializes concurrent refreshes and + /// blocks other readers for its duration, though a forced refresh still + /// retrieves once per call, so the transport must bound its own wait with a + /// timeout. + using Fetcher = + std::function(std::string_view url)>; + + /// A source of the current time, defaulting to the system clock and existing + /// as an injection point so the refresh can be driven deterministically under + /// test. + using Clock = std::function; + + /// Tunables for the caching policy. The `minimum_ttl` must not exceed the + /// `maximum_ttl`, since an advertised lifetime is clamped low first then + /// high. + struct Options { + /// The lifetime to assume when the transport advertises none. + std::chrono::seconds fallback_ttl{std::chrono::hours{1}}; + /// The shortest lifetime honored, clamping smaller advertised values up. + std::chrono::seconds minimum_ttl{std::chrono::minutes{5}}; + /// The longest lifetime honored, clamping larger advertised values down. + std::chrono::seconds maximum_ttl{std::chrono::hours{24}}; + }; + + /// Construct a provider for an issuer with an injected transport, using the + /// default policy and the system clock. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher); + /// Construct a provider overriding the caching policy. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher, Options options); + /// Construct a provider overriding the policy and the clock. + OAuthMetadataProvider(std::string issuer, const OAuthWellKnownKind kind, + Fetcher fetcher, Options options, Clock clock); + + /// The provider owns a lock guarding its cache, so it is neither copyable nor + /// movable. Store it behind a pointer or construct it in place. + OAuthMetadataProvider(const OAuthMetadataProvider &) = delete; + auto operator=(const OAuthMetadataProvider &) + -> OAuthMetadataProvider & = delete; + OAuthMetadataProvider(OAuthMetadataProvider &&) = delete; + auto operator=(OAuthMetadataProvider &&) -> OAuthMetadataProvider & = delete; + + /// The cached metadata, retrieving it on the first call and refreshing it + /// once its freshness lifetime has elapsed. A failed refresh keeps serving + /// the last good document, and no value is returned only when the document + /// has never been retrieved successfully. The result is a snapshot immune to + /// a concurrent refresh. + [[nodiscard]] auto metadata() -> std::shared_ptr; + + /// Force an immediate retrieval regardless of freshness, serving the RFC 9728 + /// Section 5.2 recommendation to re-retrieve on a challenge. A failed + /// retrieval keeps the last good document. + [[nodiscard]] auto refresh() -> std::shared_ptr; + +private: + auto install_locked(const OAuthWellKnownKind kind, + const std::chrono::system_clock::time_point now) -> bool; + auto fetch_and_install_locked(const std::chrono::system_clock::time_point now) + -> bool; + + const std::string issuer_; + const OAuthWellKnownKind kind_; + const Fetcher fetcher_; + const Options options_; + const Clock clock_; + std::mutex mutex_; + std::shared_ptr metadata_; + std::chrono::system_clock::time_point next_refresh_{}; +}; + +/// @ingroup oauth +/// A long-lived, cached resolver of protected resource metadata (RFC 9728), +/// the resource-side counterpart of the authorization server provider. It +/// derives the well-known URL from a resource identifier, retrieves and +/// validates the document through an injected transport, and caches it with a +/// freshness-aware refresh. It is meant to be constructed once per resource at +/// startup, since a per-request instance defeats the caching. Reads take a +/// snapshot, so a returned document is immune to a concurrent refresh. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthResourceMetadataProvider provider{ +/// "https://api.example", +/// [](std::string_view) -> std::optional< +/// sourcemeta::core::OAuthResourceMetadataProvider::FetchResult> { +/// return sourcemeta::core::OAuthResourceMetadataProvider::FetchResult{ +/// .body = R"JSON({"resource":"https://api.example", +/// "authorization_servers":["https://auth.example"]})JSON", +/// .max_age = std::nullopt}; +/// }}; +/// const auto metadata{provider.metadata()}; +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthResourceMetadataProvider { +public: + /// The outcome of one metadata retrieval, shared with the authorization + /// server provider. + using FetchResult = OAuthMetadataProvider::FetchResult; + + /// A pluggable transport that turns a URL into raw metadata bytes plus an + /// optional freshness hint, shared with the authorization server provider. + using Fetcher = OAuthMetadataProvider::Fetcher; + + /// A source of the current time, shared with the authorization server + /// provider. + using Clock = OAuthMetadataProvider::Clock; + + /// Tunables for the caching policy, shared with the authorization server + /// provider. + using Options = OAuthMetadataProvider::Options; + + /// Construct a provider for a resource with an injected transport, using the + /// default policy and the system clock. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher); + /// Construct a provider overriding the caching policy. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher, + Options options); + /// Construct a provider overriding the policy and the clock. + OAuthResourceMetadataProvider(std::string resource, Fetcher fetcher, + Options options, Clock clock); + + /// The provider owns a lock guarding its cache, so it is neither copyable nor + /// movable. Store it behind a pointer or construct it in place. + OAuthResourceMetadataProvider(const OAuthResourceMetadataProvider &) = delete; + auto operator=(const OAuthResourceMetadataProvider &) + -> OAuthResourceMetadataProvider & = delete; + OAuthResourceMetadataProvider(OAuthResourceMetadataProvider &&) = delete; + auto operator=(OAuthResourceMetadataProvider &&) + -> OAuthResourceMetadataProvider & = delete; + + /// The cached metadata, retrieving it on the first call and refreshing it + /// once its freshness lifetime has elapsed. A failed refresh keeps serving + /// the last good document, and no value is returned only when the document + /// has never been retrieved successfully. The result is a snapshot immune to + /// a concurrent refresh. + [[nodiscard]] auto metadata() -> std::shared_ptr; + + /// Force an immediate retrieval regardless of freshness, serving the RFC 9728 + /// Section 5.2 recommendation to re-retrieve on a challenge. A failed + /// retrieval keeps the last good document. + [[nodiscard]] auto refresh() -> std::shared_ptr; + +private: + auto fetch_and_install_locked(const std::chrono::system_clock::time_point now) + -> bool; + + const std::string resource_; + const Fetcher fetcher_; + const Options options_; + const Clock clock_; + std::mutex mutex_; + std::shared_ptr metadata_; + std::chrono::system_clock::time_point next_refresh_{}; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h new file mode 100644 index 00000000..1e2f9b11 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_par.h @@ -0,0 +1,195 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PAR_H_ +#define SOURCEMETA_CORE_OAUTH_PAR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include + +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::function +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append a pushed authorization request body (RFC 9126 Section 2.1) to the +/// sink. Every authorization request parameter is emitted except the +/// `request_uri`, which a pushed request MUST NOT carry, and the `client_id`, +/// which client authentication supplies, in the body for a public client or the +/// `client_secret_post` method and in the `Authorization` header for the +/// `client_secret_basic` method. The `response_type` defaults to the +/// authorization code flow. The body may carry a client secret, so the sink is +/// a wiping string, appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// request.redirect_uri = "https://client.example/cb"; +/// request.scope = "read"; +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_par_request(request, body); +/// sourcemeta::core::oauth_client_id_only("s6BhdRkqt3", body); +/// assert(std::string_view{body}.starts_with("response_type=code")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_par_request(const OAuthAuthorizationRequest &request, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Append the front-channel authorization request URL (RFC 9126 Section 4) to +/// the sink, the reference form carrying only the `client_id` and the +/// `request_uri` obtained from the pushed authorization request endpoint. +/// Unlike a full authorization request it emits no `response_type`, since the +/// pushed request holds it. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string url; +/// sourcemeta::core::oauth_build_par_authorization_url( +/// "https://server.example/authorize", "s6BhdRkqt3", +/// "urn:ietf:params:oauth:request_uri:6esc", url); +/// assert(url == +/// "https://server.example/authorize?client_id=s6BhdRkqt3&request_uri=" +/// "urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A6esc"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_par_authorization_url(const std::string_view endpoint, + const std::string_view client_id, + const std::string_view request_uri, + std::string &sink) -> void; + +/// @ingroup oauth +/// A non-owning view over a pushed authorization request response (RFC 9126 +/// Section 2.2) held in a caller-owned JSON value, which must outlive the view. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "request_uri": "urn:ietf:params:oauth:request_uri:6esc", +/// "expires_in": 60 +/// })JSON")}; +/// const sourcemeta::core::OAuthPARResponse response{document}; +/// assert(response.request_uri().value() == +/// "urn:ietf:params:oauth:request_uri:6esc"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthPARResponse { +public: + /// Construct a view over a pushed authorization request response, which is + /// borrowed. + explicit OAuthPARResponse(const JSON &data); + + /// The request URI to use at the authorization endpoint (RFC 9126 + /// Section 2.2). + [[nodiscard]] auto request_uri() const -> std::optional; + /// The lifetime of the request URI, no value when non-positive (RFC 9126 + /// Section 2.2). + [[nodiscard]] auto expires_in() const -> std::optional; + + /// The underlying document. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// Parse a pushed authorization request body (RFC 9126 Section 2.1) into the +/// result, returning whether it is well formed. The parse follows the +/// authorization request rules, and additionally rejects a `request_uri` +/// parameter, which a pushed request MUST NOT provide, and every other +/// parameter, such as the client authentication ones, is passed to the +/// callback. The body may carry a client secret, so it is decoded into a wiping +/// arena the caller owns and reuses. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthAuthorizationRequest request; +/// assert(sourcemeta::core::oauth_parse_par_request( +/// "response_type=code&client_id=s6BhdRkqt3", storage, request, +/// [](std::string_view, std::string_view) {})); +/// assert(request.response_type == "code"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_parse_par_request( + const std::string_view body, SecureString &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// Mint a request URI reference for a pushed authorization request response +/// (RFC 9126 Section 2.2), the `urn:ietf:params:oauth:request_uri` form with a +/// cryptographically strong random reference so a value cannot be guessed +/// (RFC 9126 Section 7.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto request_uri{sourcemeta::core::oauth_par_request_uri()}; +/// assert(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_par_request_uri() + -> std::string; + +/// @ingroup oauth +/// Build a pushed authorization request response document (RFC 9126 +/// Section 2.2), returning no value when the request URI is empty or the +/// lifetime is non-positive, which is a REQUIRED positive value. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_par_response( +/// "urn:ietf:params:oauth:request_uri:6esc", std::chrono::seconds{60})}; +/// assert(response.value().at("expires_in").to_integer() == 60); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_par_response(const std::string_view request_uri, + const std::chrono::seconds expires_in) + -> std::optional; + +/// @ingroup oauth +/// Reconcile the two ways a DPoP key is communicated at the pushed +/// authorization request endpoint (RFC 9449 Section 10.1): the `dpop_jkt` +/// parameter and the thumbprint of a verified DPoP header proof. Returns the +/// effective thumbprint to bind the authorization code to, an empty view when +/// neither is present, or no value when both are present and disagree, which +/// the server MUST reject. The returned view borrows from the arguments. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_par_dpop_binding("abc", "abc").value() == +/// "abc"); +/// assert(!sourcemeta::core::oauth_par_dpop_binding("abc", "xyz").has_value()); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_par_dpop_binding(const std::string_view dpop_jkt, + const std::optional proof_thumbprint) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h new file mode 100644 index 00000000..91081f30 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -0,0 +1,155 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PKCE_H_ +#define SOURCEMETA_CORE_OAUTH_PKCE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The PKCE code challenge transformation method (RFC 7636 Section 4.2). +enum class OAuthPKCEMethod : std::uint8_t { + /// The SHA-256 transformation, the only method the strict profile permits + /// (RFC 7636 Section 4.2). + S256, + /// The identity transformation, permitted only under the compatible profile + /// (RFC 7636 Section 4.2). + Plain +}; + +/// @ingroup oauth +/// The result of verifying a PKCE code verifier against a stored code +/// challenge. Only `Match` and `NotUsed` let the exchange proceed. The +/// remaining outcomes each name a distinct pairing failure the token endpoint +/// rejects (RFC 7636 Section 4.6, RFC 9700 Section 2.1.1, OAuth 2.1 +/// Section 4.1.3). +enum class OAuthPKCEOutcome : std::uint8_t { + /// The verifier corresponds to the challenge. + Match, + /// Neither a challenge nor a verifier is present, so PKCE was not used. The + /// caller decides separately whether its profile required it. + NotUsed, + /// A challenge is stored but no verifier was presented (OAuth 2.1 + /// Section 4.1.3). + MissingVerifier, + /// A verifier was presented but no challenge is stored, which signals a + /// possible authorization code injection (RFC 9700 Section 2.1.1). + MissingChallenge, + /// Both are present, well formed, and permitted, but the verifier does not + /// correspond to the challenge (RFC 7636 Section 4.6). + Mismatch, + /// The stored method is `plain` under the strict profile (RFC 9700 + /// Section 2.1.1). + MethodNotAllowed, + /// The presented verifier is not a valid code verifier (RFC 7636 + /// Section 4.1). + MalformedVerifier, + /// The stored challenge violates the code challenge syntax, either the wrong + /// length for its method or a character outside the allowed set (RFC 7636 + /// Section 4.2). A syntactically valid challenge that simply does not + /// correspond to the verifier is a `Mismatch`, not this. + MalformedChallenge +}; + +/// @ingroup oauth +/// The wire value for a PKCE challenge method (RFC 7636 Section 4.3). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_pkce_method_code( +/// sourcemeta::core::OAuthPKCEMethod::S256) == "S256"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map a PKCE challenge method wire value to its type, returning no value for +/// an unrecognized method (RFC 7636 Section 4.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_pkce_method("S256").has_value()); +/// assert(!sourcemeta::core::to_oauth_pkce_method("plaintext").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional; + +/// @ingroup oauth +/// Mint a new PKCE code verifier, the base64url encoding of 32 +/// cryptographically random octets (RFC 7636 Section 4.1). The 43 bytes are not +/// null terminated, so pass them onward as a view of `data()` and `size()` +/// rather than as a C string. The result is secret material, so wipe it once +/// the exchange completes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto verifier{sourcemeta::core::oauth_pkce_verifier()}; +/// const std::string_view view{verifier.data(), verifier.size()}; +/// assert(view.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verifier() -> std::array; + +/// @ingroup oauth +/// Derive the S256 code challenge for a code verifier, the base64url encoding +/// of its SHA-256 digest (RFC 7636 Section 4.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto challenge{sourcemeta::core::oauth_pkce_challenge( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")}; +/// assert((std::string_view{challenge.data(), challenge.size()} == +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array; + +/// @ingroup oauth +/// Verify a presented code verifier against a stored code challenge and its +/// method, under the given profile, returning the pairing outcome. An empty +/// verifier or challenge means the value is absent. The final verifier against +/// challenge comparison is constant time over their bytes once their lengths +/// match. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto outcome{sourcemeta::core::oauth_pkce_verify( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", +/// sourcemeta::core::OAuthPKCEMethod::S256, +/// sourcemeta::core::OAuthProfile::Strict)}; +/// assert(outcome == sourcemeta::core::OAuthPKCEOutcome::Match); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h new file mode 100644 index 00000000..8538920b --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_profile.h @@ -0,0 +1,35 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PROFILE_H_ +#define SOURCEMETA_CORE_OAUTH_PROFILE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::uint8_t + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The behavioural profile a parser or validator runs under. `Strict` applies +/// the OAuth 2.1 and RFC 9700 hardening, and `Compatible` relaxes it for +/// RFC 6749 interoperability where a specification still permits the older +/// behaviour. For example: +/// +/// ```cpp +/// #include +/// +/// const auto profile{sourcemeta::core::OAuthProfile::Strict}; +/// ``` +enum class OAuthProfile : std::uint8_t { + /// The default, selecting the OAuth 2.1 and RFC 9700 hardening. Each function + /// that takes a profile applies the part of that hardening it governs. Today + /// that is the PKCE method check, where the strict profile rejects `plain`. + Strict, + /// Selects the RFC 6749 behaviours the strict profile refuses, for + /// interoperability with deployments that predate the hardening. + Compatible +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h new file mode 100644 index 00000000..c09430db --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_random.h @@ -0,0 +1,33 @@ +#ifndef SOURCEMETA_CORE_OAUTH_RANDOM_H_ +#define SOURCEMETA_CORE_OAUTH_RANDOM_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::array + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Mint a random token, the base64url encoding of 32 cryptographically random +/// octets, for an unguessable but non-confidential value such as a `state` or +/// `nonce` (RFC 6749 Section 10.10). The 43 bytes are not null terminated, so +/// pass them onward as a view of `data()` and `size()` rather than as a C +/// string. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::oauth_random_token()}; +/// const std::string_view view{token.data(), token.size()}; +/// assert(view.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_random_token() -> std::array; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h new file mode 100644 index 00000000..7ba075ee --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_registration.h @@ -0,0 +1,400 @@ +#ifndef SOURCEMETA_CORE_OAUTH_REGISTRATION_H_ +#define SOURCEMETA_CORE_OAUTH_REGISTRATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::chrono::seconds +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oauth +/// A dynamic client registration metadata document (RFC 7591 Section 2), owning +/// its JSON. The same document is exchanged in both roles: a client's +/// registration request (RFC 7591 Section 3.1) and the server's registration +/// response (RFC 7591 Section 3.2.1), so the response-only members carry a +/// value only when read over a response. The document is validated on +/// construction to reject a `jwks` and `jwks_uri` present together, and a +/// `grant_types`, `response_types`, or `token_endpoint_auth_method` present +/// with the wrong type, since each of those carries a default an accessor would +/// otherwise substitute (RFC 7591 Section 2). Accessors apply the specification +/// defaults, and any member +/// without a typed accessor, such as an internationalized name (RFC 7591 +/// Section 2.2), is reachable through the underlying document. A string +/// accessor returns a view into the owned document, valid for the lifetime of +/// this object, so a view taken before the object is moved from must not be +/// used afterward. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json(R"JSON({ +/// "client_id": "s6BhdRkqt3", +/// "redirect_uris": [ "https://client.example.org/callback" ], +/// "token_endpoint_auth_method": "client_secret_basic" +/// })JSON")}; +/// const auto metadata{ +/// sourcemeta::core::OAuthClientMetadata::from(std::move(document))}; +/// assert(metadata.has_value()); +/// assert(metadata.value().client_id().value() == "s6BhdRkqt3"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthClientMetadata { +public: + /// Construct and validate a client registration document, throwing when it is + /// invalid. The document is moved in. + explicit OAuthClientMetadata(JSON &&data); + + /// Construct and validate a client registration document, returning no value + /// when it is invalid. The document is moved in. + [[nodiscard]] static auto from(JSON &&data) + -> std::optional; + + /// Whether a redirection URI is registered (RFC 7591 Section 2). + [[nodiscard]] auto has_redirect_uri(const std::string_view value) const + -> bool; + + /// The requested token endpoint authentication method, defaulting to + /// `client_secret_basic` when absent (RFC 7591 Section 2). + [[nodiscard]] auto token_endpoint_auth_method() const -> std::string_view; + + /// Whether a grant type is registered, defaulting to the authorization code + /// grant when absent (RFC 7591 Section 2). + [[nodiscard]] auto supports_grant_type(const std::string_view value) const + -> bool; + + /// Whether a response type is registered, defaulting to the code response + /// type when absent (RFC 7591 Section 2). + [[nodiscard]] auto supports_response_type(const std::string_view value) const + -> bool; + + /// The human-readable client name (RFC 7591 Section 2). + [[nodiscard]] auto client_name() const -> std::optional; + + /// The client information page (RFC 7591 Section 2). + [[nodiscard]] auto client_uri() const -> std::optional; + + /// The client logo location (RFC 7591 Section 2). + [[nodiscard]] auto logo_uri() const -> std::optional; + + /// The space-separated scopes the client may request (RFC 7591 Section 2). + [[nodiscard]] auto scope() const -> std::optional; + + /// Whether a contact is listed (RFC 7591 Section 2). + [[nodiscard]] auto has_contact(const std::string_view value) const -> bool; + + /// The terms of service page (RFC 7591 Section 2). + [[nodiscard]] auto tos_uri() const -> std::optional; + + /// The privacy policy page (RFC 7591 Section 2). + [[nodiscard]] auto policy_uri() const -> std::optional; + + /// The JWK Set document location for the client's keys (RFC 7591 Section 2). + [[nodiscard]] auto jwks_uri() const -> std::optional; + + /// The client software identifier, stable across instances (RFC 7591 + /// Section 2). + [[nodiscard]] auto software_id() const -> std::optional; + + /// The client software version (RFC 7591 Section 2). + [[nodiscard]] auto software_version() const + -> std::optional; + + /// The software statement asserting the metadata, echoed unmodified in a + /// response (RFC 7591 Section 2.3). + [[nodiscard]] auto software_statement() const + -> std::optional; + + /// The issued client identifier, present only in a registration response + /// (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_id() const -> std::optional; + + /// The issued client secret, present only in a response to a confidential + /// client (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_secret() const -> std::optional; + + /// The time the client identifier was issued, present only in a response + /// (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_id_issued_at() const + -> std::optional; + + /// The time the client secret expires, zero meaning it never expires, present + /// only in a response that issued a secret (RFC 7591 Section 3.2.1). + [[nodiscard]] auto client_secret_expires_at() const + -> std::optional; + + /// The registration access token for managing the registration, present only + /// in a response when management is offered (RFC 7592 Section 3). + [[nodiscard]] auto registration_access_token() const + -> std::optional; + + /// The registration management location, present only in a response when + /// management is offered (RFC 7592 Section 3). + [[nodiscard]] auto registration_client_uri() const + -> std::optional; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + JSON data_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oauth +/// The client metadata a registration request carries (RFC 7591 Section 2), +/// each field a non-owning view. An empty scalar and a zero-element array are +/// omitted. +struct OAuthClientRegistrationConfig { + /// The redirection URIs the client registers for redirect-based flows + /// (RFC 7591 Section 2). + std::span redirect_uris; + /// The requested token endpoint authentication method, omitted to take the + /// `client_secret_basic` default (RFC 7591 Section 2). + std::string_view token_endpoint_auth_method; + /// The grant types the client will use (RFC 7591 Section 2). + std::span grant_types; + /// The response types the client will use (RFC 7591 Section 2). + std::span response_types; + /// The human-readable client name (RFC 7591 Section 2). + std::string_view client_name; + /// The client information page (RFC 7591 Section 2). + std::string_view client_uri; + /// The client logo location (RFC 7591 Section 2). + std::string_view logo_uri; + /// The space-separated scopes the client may request (RFC 7591 Section 2). + std::string_view scope; + /// The ways to contact those responsible for the client (RFC 7591 Section 2). + std::span contacts; + /// The terms of service page (RFC 7591 Section 2). + std::string_view tos_uri; + /// The privacy policy page (RFC 7591 Section 2). + std::string_view policy_uri; + /// The JWK Set document location for the client's keys, mutually exclusive + /// with an inline key set (RFC 7591 Section 2). + std::string_view jwks_uri; + /// The client's JWK Set carried inline for a client that cannot host one, + /// mutually exclusive with its location, a non-owning pointer left null when + /// absent (RFC 7591 Section 2). + const JSON *jwks{nullptr}; + /// The client software identifier (RFC 7591 Section 2). + std::string_view software_id; + /// The client software version (RFC 7591 Section 2). + std::string_view software_version; + /// The software statement asserting the metadata (RFC 7591 Section 2.3). + std::string_view software_statement; +}; + +/// @ingroup oauth +/// Build a dynamic client registration request body (RFC 7591 Section 3.1), +/// returning no value when a present `client_uri`, `logo_uri`, `tos_uri`, +/// `policy_uri`, `jwks_uri`, or redirection URI is not a valid URI, or an +/// inline key set is not a JSON object or is given together with its location. +/// Each present scalar and each non-empty array is emitted. The caller +/// serializes the object as the `application/json` request body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array redirect_uris{ +/// {"https://client.example.org/callback"}}; +/// sourcemeta::core::OAuthClientRegistrationConfig config; +/// config.redirect_uris = redirect_uris; +/// config.client_name = "My Example Client"; +/// const auto body{ +/// sourcemeta::core::oauth_make_registration_request(config)}; +/// assert(body.has_value()); +/// assert(body.value().at("client_name").to_string() == "My Example Client"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_request(const OAuthClientRegistrationConfig &config) + -> std::optional; + +/// @ingroup oauth +/// Build a dynamic client registration error response body (RFC 7591 +/// Section 3.2.2). The error is the wire error code, and the description is +/// emitted only when present. The caller serializes the object as the +/// `application/json` response body. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto body{sourcemeta::core::oauth_make_registration_error_response( +/// sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthRegistrationError::InvalidClientMetadata), +/// "The grant types are inconsistent with the response types")}; +/// assert(body.at("error").to_string() == "invalid_client_metadata"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_error_response(const std::string_view error, + const std::string_view error_description) + -> JSON; + +/// @ingroup oauth +/// Whether the registered grant types and response types are mutually +/// consistent (RFC 7591 Section 2.1): the `authorization_code` grant pairs with +/// the `code` response type and the `implicit` grant with the `token` response +/// type. Only the explicitly registered lists are compared, so a document that +/// omits either list registers no value that could contradict the other, and +/// the server surfaces an inconsistency as `invalid_client_metadata` (RFC 7591 +/// Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json(R"JSON({ +/// "grant_types": [ "authorization_code" ], +/// "response_types": [ "code" ] +/// })JSON")}; +/// const auto metadata{ +/// sourcemeta::core::OAuthClientMetadata::from(std::move(document))}; +/// assert(metadata.has_value()); +/// assert(sourcemeta::core::oauth_registration_grant_response_consistent( +/// metadata.value())); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_registration_grant_response_consistent( + const OAuthClientMetadata &metadata) -> bool; + +/// @ingroup oauth +/// Flatten the claims of a trusted software statement onto a registration +/// record (RFC 7591 Section 3.1.1), returning false when the record or the +/// claims are not a JSON object. A statement claim takes precedence over a +/// directly supplied value of the same name, so it overwrites it, while the +/// JSON Web Token structural claims (RFC 7519 Section 4.1) and the statement +/// member itself are left out since they describe the statement rather than the +/// client. Pass the claims a trusted statement was verified to carry. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto record{sourcemeta::core::parse_json(R"JSON({ +/// "client_name": "Requested Name" +/// })JSON")}; +/// const auto claims{sourcemeta::core::parse_json(R"JSON({ +/// "iss": "https://statement-issuer.example", +/// "client_name": "Attested Name" +/// })JSON")}; +/// sourcemeta::core::oauth_apply_software_statement_claims(record, claims); +/// assert(record.at("client_name").to_string() == "Attested Name"); +/// assert(!record.defines("iss")); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_apply_software_statement_claims(JSON &metadata, const JSON &claims) + -> bool; + +/// @ingroup oauth +/// The values an authorization server assigns to a client at registration +/// (RFC 7591 Section 3.2.1) and for its management (RFC 7592 Section 3), each a +/// non-owning view. The client identifier is REQUIRED, a secret and its +/// expiry are emitted together, and the management pair is emitted when +/// registration management is offered. +struct OAuthClientRegistrationResult { + /// The issued client identifier (RFC 7591 Section 3.2.1), REQUIRED. + std::string_view client_id; + /// The issued client secret, present for a confidential client (RFC 7591 + /// Section 3.2.1). + std::string_view client_secret; + /// The time the client identifier was issued (RFC 7591 Section 3.2.1). + std::optional client_id_issued_at{}; + /// The time the client secret expires, zero meaning it never expires, + /// REQUIRED alongside a secret (RFC 7591 Section 3.2.1). + std::optional client_secret_expires_at{}; + /// The registration access token for managing the registration, REQUIRED + /// together with the management location when management is offered (RFC 7592 + /// Section 3). + std::string_view registration_access_token; + /// The registration management location, REQUIRED together with the access + /// token when management is offered (RFC 7592 Section 3). + std::string_view registration_client_uri; +}; + +/// @ingroup oauth +/// Build a dynamic client registration response body (RFC 7591 Section 3.2.1 +/// and RFC 7592 Section 3) by returning the registered metadata with the +/// server-assigned values overlaid, or no value when the client identifier is +/// empty, only one of the secret and its expiry is given, a time is negative, +/// only one of the management access token and location is given, or the +/// management location is not a valid URI. The server-assigned members come +/// only from the assigned values, never from the accepted record. The caller +/// serializes the object as the `application/json` response body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto metadata{sourcemeta::core::parse_json(R"JSON({ +/// "redirect_uris": [ "https://client.example.org/callback" ] +/// })JSON")}; +/// sourcemeta::core::OAuthClientRegistrationResult result; +/// result.client_id = "s6BhdRkqt3"; +/// const auto body{ +/// sourcemeta::core::oauth_make_registration_response(metadata, result)}; +/// assert(body.has_value()); +/// assert(body.value().at("client_id").to_string() == "s6BhdRkqt3"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_response(const JSON &metadata, + const OAuthClientRegistrationResult &result) + -> std::optional; + +/// @ingroup oauth +/// Build a dynamic client registration update request body (RFC 7592 +/// Section 2.2), a full replacement of the client's metadata that MUST carry +/// the current client identifier and MAY carry the current secret to match +/// against, returning no value when the client identifier is empty or a URL +/// field is not a valid URI. Every metadata field is emitted as in a +/// registration request, and the four server-assigned members are excluded +/// since the request MUST NOT carry them. An omitted field is a request to +/// delete it, since the update replaces rather than augments. The caller +/// serializes the object as the `application/json` request body. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array redirect_uris{ +/// {"https://client.example.org/callback"}}; +/// sourcemeta::core::OAuthClientRegistrationConfig config; +/// config.redirect_uris = redirect_uris; +/// config.client_name = "My New Example"; +/// const auto body{sourcemeta::core::oauth_make_registration_update_request( +/// config, "s6BhdRkqt3", "")}; +/// assert(body.has_value()); +/// assert(body.value().at("client_id").to_string() == "s6BhdRkqt3"); +/// ``` +[[nodiscard]] SOURCEMETA_CORE_OAUTH_EXPORT auto +oauth_make_registration_update_request( + const OAuthClientRegistrationConfig &config, + const std::string_view client_id, const std::string_view client_secret) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h new file mode 100644 index 00000000..48d3f0c8 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_revocation.h @@ -0,0 +1,120 @@ +#ifndef SOURCEMETA_CORE_OAUTH_REVOCATION_H_ +#define SOURCEMETA_CORE_OAUTH_REVOCATION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::uint8_t +#include // std::function +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The `access_token` token type hint (RFC 7009 Section 2.1), reused by token +/// introspection (RFC 7662 Section 2.1). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_HINT_ACCESS_TOKEN{ + "access_token"}; +/// @ingroup oauth +/// The `refresh_token` token type hint (RFC 7009 Section 2.1), reused by token +/// introspection (RFC 7662 Section 2.1). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_HINT_REFRESH_TOKEN{ + "refresh_token"}; + +/// @ingroup oauth +/// A non-owning view of a token revocation or introspection request (RFC 7009 +/// Section 2.1, RFC 7662 Section 2.1). The token borrows from the input or the +/// decode arena, and an absent hint is an empty view. +struct OAuthTokenLookupRequest { + /// The token to act on (RFC 7009 Section 2.1). + std::string_view token; + /// The hint about the token type, one of the hint vocabulary values, empty + /// when absent (RFC 7009 Section 2.1). + std::string_view token_type_hint; +}; + +/// @ingroup oauth +/// Append a token revocation request body (RFC 7009 Section 2.1) to the sink. +/// The `token` is required, and the `token_type_hint` is emitted only when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The token is secret, so the sink +/// is a wiping string, and it is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_revocation_request( +/// "45ghiukldjahdnhzdauz", "", body); +/// assert(body == "token=45ghiukldjahdnhzdauz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_revocation_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void; + +/// @ingroup oauth +/// Parse a token revocation request body (RFC 7009 Section 2.1) into the +/// result, returning whether it is well formed. The token is required, a +/// duplicated parameter fails (RFC 6749 Section 3.2), and every other +/// parameter, such as the client authentication ones, is passed to the +/// callback. The token is secret, so it is decoded into a wiping arena the +/// caller owns and reuses, and which must not alias the body. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthTokenLookupRequest request; +/// assert(sourcemeta::core::oauth_parse_revocation_request( +/// "token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token", storage, +/// request, [](std::string_view, std::string_view) {})); +/// assert(request.token == "45ghiukldjahdnhzdauz"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_revocation_request( + const std::string_view body, SecureString &storage, + OAuthTokenLookupRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// The outcome a client draws from a token revocation response (RFC 7009 +/// Section 2.2). +enum class OAuthRevocationOutcome : std::uint8_t { + /// The revocation succeeded, which an unknown token also produces (RFC 7009 + /// Section 2.2). + Success, + /// The server is temporarily unable to respond and the request may be + /// retried, honoring any `Retry-After` (RFC 7009 Section 2.2.1). + Retry, + /// The request failed, and the body carries a token endpoint error code + /// (RFC 7009 Section 2.2.1). + Error +}; + +/// @ingroup oauth +/// Map a revocation response status code to its outcome: a 200 is a success, +/// including for an unknown token (RFC 7009 Section 2.2), a 503 is retryable +/// (RFC 7009 Section 2.2.1), and any other status is an error. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_revocation_outcome(HTTP_STATUS_OK) == +/// sourcemeta::core::OAuthRevocationOutcome::Success); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_revocation_outcome(const HTTPStatus status) noexcept + -> OAuthRevocationOutcome; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h new file mode 100644 index 00000000..bf55a7f8 --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token.h @@ -0,0 +1,266 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TOKEN_H_ +#define SOURCEMETA_CORE_OAUTH_TOKEN_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include +#include + +#include // std::chrono::seconds +#include // std::function +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append an authorization code grant token request body (RFC 6749 +/// Section 4.1.3) to the sink. The `code` is required, the `redirect_uri` is +/// emitted only when the authorization request carried one, and the +/// `code_verifier` is emitted only when PKCE is in use (RFC 7636 Section 4.5). +/// No `client_id` is emitted, so the caller composes a client authentication +/// builder into the same sink. The body carries secrets, so the sink is a +/// wiping string, and it is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_code( +/// "SplxlOBeZQQYbYS6WxSbIA", "https://client.example/cb", "", {}, body); +/// assert(body == "grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA" +/// "&redirect_uri=https%3A%2F%2Fclient.example%2Fcb"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_code( + const std::string_view code, const std::string_view redirect_uri, + const std::string_view code_verifier, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// Append a refresh token grant token request body (RFC 6749 Section 6) to the +/// sink. The `refresh_token` is required, and the requested `scope` is emitted +/// only when present and must not exceed the original grant. No `client_id` is +/// emitted, so the caller composes a client authentication builder into the +/// same sink. The body carries secrets, so the sink is a wiping string, and it +/// is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_refresh("tGzv3JOkF0XG5Qx2TlKWIA", +/// "read", {}, body); +/// assert(body == "grant_type=refresh_token" +/// "&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_refresh( + const std::string_view refresh_token, const std::string_view scope, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// Append a client credentials grant token request body (RFC 6749 +/// Section 4.4.2) to the sink. The requested `scope` is emitted only when +/// present. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The sink is appended to and never +/// cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString body; +/// sourcemeta::core::oauth_build_token_request_client_credentials("read", {}, +/// body); +/// assert(body == "grant_type=client_credentials&scope=read"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_client_credentials( + const std::string_view scope, + const std::span resources, SecureString &sink) + -> void; + +/// @ingroup oauth +/// A non-owning view of a token request at the authorization server (RFC 6749 +/// Section 4.1.3, Section 4.4.2, Section 6). Every field borrows from the input +/// or the decode arena, and an absent parameter is an empty view. The grant +/// type is surfaced as its raw value so a server can tell a missing grant from +/// an unsupported one. +struct OAuthTokenRequest { + /// The grant type (RFC 6749 Section 4.1.3). + std::string_view grant_type; + /// The authorization code, for the authorization code grant (RFC 6749 + /// Section 4.1.3). + std::string_view code; + /// The redirection endpoint echoed for the authorization code grant (RFC 6749 + /// Section 4.1.3), surfaced unconditionally. + std::string_view redirect_uri; + /// The PKCE code verifier (RFC 7636 Section 4.5). + std::string_view code_verifier; + /// The refresh token, for the refresh grant (RFC 6749 Section 6). + std::string_view refresh_token; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; +}; + +/// @ingroup oauth +/// Parse the body of a token request at the authorization server (RFC 6749 +/// Section 4.1.3) into the result, returning whether it is well formed. Each +/// recognized value is form-decoded into the storage arena and viewed there. +/// The body carries the request's secrets, the authorization code, the code +/// verifier, the refresh token, and the client authentication parameters, so +/// the arena is a wiping string, which the caller owns, should clear between +/// independent parses, and must outlive the result. A duplicated recognized +/// parameter is a failure (RFC 6749 Section 3.2). Every repeatable `resource` +/// and `audience` and every unrecognized parameter, including the client +/// authentication parameters, is passed to the callback with its decoded value +/// rather than stored on the result. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::SecureString storage; +/// sourcemeta::core::OAuthTokenRequest request; +/// assert(sourcemeta::core::oauth_parse_token_request( +/// "grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA", storage, +/// request, [](std::string_view, std::string_view) {})); +/// assert(request.grant_type == "authorization_code"); +/// assert(request.code == "SplxlOBeZQQYbYS6WxSbIA"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_parse_token_request( + const std::string_view body, SecureString &storage, + OAuthTokenRequest &result, + const std::function &on_other) + -> bool; + +/// @ingroup oauth +/// A non-owning view over a token endpoint success response (RFC 6749 +/// Section 5.1) held in a caller-owned JSON value. The value must outlive the +/// view, and the token accessors return views into it, which are secret and +/// must not be logged. Extension members such as an OpenID Connect `id_token` +/// are reached through the underlying document. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json( +/// R"JSON({"access_token":"2YotnFZFEjr1zCsicMWpAA","token_type":"Bearer", +/// "expires_in":3600})JSON")}; +/// const sourcemeta::core::OAuthTokenResponse response{document}; +/// assert(response.is_bearer_token_type()); +/// assert(response.access_token().value() == "2YotnFZFEjr1zCsicMWpAA"); +/// ``` +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthTokenResponse { +public: + /// Construct a view over a token response document, which is borrowed. + explicit OAuthTokenResponse(const JSON &data); + + /// The issued access token (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto access_token() const -> std::optional; + + /// The access token type (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto token_type() const -> std::optional; + + /// Whether the token type is `Bearer`, matched case insensitively (RFC 6749 + /// Section 5.1). + [[nodiscard]] auto is_bearer_token_type() const -> bool; + + /// The access token lifetime in seconds (RFC 6749 Section 5.1), or no value + /// when absent or not a non-negative integer. + [[nodiscard]] auto expires_in() const -> std::optional; + + /// The issued refresh token (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto refresh_token() const -> std::optional; + + /// The granted scope (RFC 6749 Section 5.1), or no value when absent. + [[nodiscard]] auto scope() const -> std::optional; + + /// Whether the granted scope contains a value, comparing the space-delimited + /// unordered set (RFC 6749 Section 3.3). + [[nodiscard]] auto has_scope(const std::string_view value) const -> bool; + + /// The underlying document, for reaching extension members such as an OpenID + /// Connect `id_token`. + [[nodiscard]] auto data() const -> const JSON &; + +private: + const JSON *data_; +}; + +/// @ingroup oauth +/// The token a server grants, for building a token endpoint success response +/// (RFC 6749 Section 5.1). Every field borrows from the caller, an empty scalar +/// is omitted, and the two scope fields decide whether a scope is emitted. +struct OAuthTokenGrant { + /// The issued access token (RFC 6749 Section 5.1). + std::string_view access_token; + /// The token type, such as `Bearer` (RFC 6749 Section 7.1). + std::string_view token_type; + /// The lifetime of the access token, omitted when absent (RFC 6749 + /// Section 5.1). + std::optional expires_in; + /// The issued refresh token, omitted when absent (RFC 6749 Section 5.1). + std::string_view refresh_token; + /// The granted scope (RFC 6749 Section 3.3). A scope has at least one token, + /// so an empty granted scope is not representable and is not emitted. A + /// server that would grant no scopes denies the request instead of issuing a + /// token with an empty scope. + std::string_view scope; + /// The scope the client requested, used to decide whether the granted scope + /// must be emitted (RFC 6749 Section 5.1). + std::string_view requested_scope; +}; + +/// @ingroup oauth +/// Build a token endpoint success response document (RFC 6749 Section 5.1). The +/// access token and token type are always emitted, the lifetime and refresh +/// token when present, and the scope only when it differs from the requested +/// scope, which is when it is REQUIRED (RFC 6749 Section 5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenGrant grant; +/// grant.access_token = "2YotnFZFEjr1zCsicMWpAA"; +/// grant.token_type = "Bearer"; +/// const auto response{sourcemeta::core::oauth_make_token_response(grant)}; +/// assert(response.at("token_type").to_string() == "Bearer"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_token_response(const OAuthTokenGrant &grant) -> JSON; + +/// @ingroup oauth +/// Build a token endpoint error response document (RFC 6749 Section 5.2). The +/// error code is always emitted, and the description and URI when present. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::oauth_make_token_error_response( +/// "invalid_grant", "", "")}; +/// assert(response.at("error").to_string() == "invalid_grant"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_make_token_error_response(const std::string_view error, + const std::string_view error_description, + const std::string_view error_uri) -> JSON; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h new file mode 100644 index 00000000..fb90552e --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_token_exchange.h @@ -0,0 +1,140 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TOKEN_EXCHANGE_H_ +#define SOURCEMETA_CORE_OAUTH_TOKEN_EXCHANGE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The `access_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_ACCESS_TOKEN{ + "urn:ietf:params:oauth:token-type:access_token"}; +/// @ingroup oauth +/// The `refresh_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_REFRESH_TOKEN{ + "urn:ietf:params:oauth:token-type:refresh_token"}; +/// @ingroup oauth +/// The `id_token` token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_ID_TOKEN{ + "urn:ietf:params:oauth:token-type:id_token"}; +/// @ingroup oauth +/// The SAML 1.1 token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_SAML1{ + "urn:ietf:params:oauth:token-type:saml1"}; +/// @ingroup oauth +/// The SAML 2.0 token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_SAML2{ + "urn:ietf:params:oauth:token-type:saml2"}; +/// @ingroup oauth +/// The JWT token type identifier (RFC 8693 Section 3). +inline constexpr std::string_view OAUTH_TOKEN_TYPE_JWT{ + "urn:ietf:params:oauth:token-type:jwt"}; + +/// @ingroup oauth +/// The parameters of a token exchange request (RFC 8693 Section 2.1). Every +/// field is a non-owning view, an empty scalar is omitted, and the spans carry +/// the repeatable audience and resource indicator values, each emitted under +/// its fixed parameter name. +struct OAuthTokenExchangeRequest { + /// The security token that represents the subject, REQUIRED (RFC 8693 + /// Section 2.1). + std::string_view subject_token; + /// The type of the subject token, REQUIRED (RFC 8693 Section 2.1). + std::string_view subject_token_type; + /// The security token that represents the acting party, emitted only + /// together with its type (RFC 8693 Section 2.1). + std::string_view actor_token; + /// The type of the actor token, emitted only together with the actor token. + std::string_view actor_token_type; + /// The requested type for the issued token (RFC 8693 Section 2.1). + std::string_view requested_token_type; + /// The space-delimited requested scope (RFC 6749 Section 3.3). + std::string_view scope; + /// The repeatable logical audience names of the target service, each emitted + /// as an `audience` parameter (RFC 8693 Section 2.1). + std::span audiences; + /// The repeatable resource indicators, each an absolute URI without a + /// fragment emitted as a `resource` parameter (RFC 8707 Section 2). + std::span resources; +}; + +/// @ingroup oauth +/// Whether a token exchange request is well formed (RFC 8693 Section 2.1): the +/// subject token and its type are present, and the actor token and its type are +/// either both present or both absent. A malformed request is rejected as +/// `invalid_request` (RFC 8693 Section 2.2.2), so a server applies this after +/// collecting the parameters. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenExchangeRequest request; +/// request.subject_token = "eyJ..."; +/// request.subject_token_type = +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN; +/// assert(sourcemeta::core::oauth_token_exchange_valid(request)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_token_exchange_valid(const OAuthTokenExchangeRequest &request) + -> bool; + +/// @ingroup oauth +/// Append a token exchange request body (RFC 8693 Section 2.1) to the sink, +/// returning whether it is well formed. The `grant_type` is the token exchange +/// URN, the subject token and its type are required, the actor token and its +/// type are emitted only together, and the repeatable audiences and resources +/// follow. No `client_id` is emitted, so the caller composes a client +/// authentication builder into the same sink. The body carries a secret token, +/// so the sink is a wiping string, and it is appended to and never cleared. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// sourcemeta::core::OAuthTokenExchangeRequest request; +/// request.subject_token = "accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC"; +/// request.subject_token_type = +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN; +/// sourcemeta::core::SecureString body; +/// assert(sourcemeta::core::oauth_build_token_request_exchange(request, body)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_token_request_exchange( + const OAuthTokenExchangeRequest &request, SecureString &sink) -> bool; + +/// @ingroup oauth +/// The type of the token a token exchange response issued (RFC 8693 +/// Section 2.2.1), or no value when absent. The result borrows from the +/// response, which must outlive it. It is REQUIRED on a successful response, so +/// its absence marks a malformed one. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"JSON({ +/// "access_token": "eyJ...", "token_type": "Bearer", +/// "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" +/// })JSON")}; +/// assert(sourcemeta::core::oauth_issued_token_type(document).value() == +/// sourcemeta::core::OAUTH_TOKEN_TYPE_ACCESS_TOKEN); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_issued_token_type(const JSON &response) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h new file mode 100644 index 00000000..13dc7efb --- /dev/null +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_transaction.h @@ -0,0 +1,137 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TRANSACTION_H_ +#define SOURCEMETA_CORE_OAUTH_TRANSACTION_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The secrets minted for one authorization code flow, each the base64url +/// encoding of 32 random octets. Both are secret and are serialized by the +/// caller, such as +/// into a sealed cookie, and wiped once the flow completes. The 43 bytes of +/// each are not null terminated. +struct OAuthTransactionSecrets { + /// The cross-site request forgery token (RFC 6749 Section 10.12). + std::array state; + /// The PKCE code verifier (RFC 7636 Section 4.1). + std::array code_verifier; +}; + +/// @ingroup oauth +/// A non-owning view of the state a client retains between an authorization +/// request and its callback. Every field borrows from the caller and must +/// outlive any use of this struct. +struct OAuthTransaction { + /// The state sent with the request, compared against the callback. + std::string_view state; + /// The PKCE code verifier, carried through to the token request. + std::string_view code_verifier; + /// The issuer identifier the request was sent to (RFC 9207 Section 2). + std::string_view issuer; + /// The redirection URI the request was sent with (RFC 8252 Section 8.10). + std::string_view redirect_uri; +}; + +/// @ingroup oauth +/// Whether the authorization server is known to support the `iss` response +/// parameter (RFC 9207 Section 2.4). This governs how a callback missing `iss` +/// is treated. +enum class OAuthIssuerSupport : std::uint8_t { + /// The server supports `iss`, so a callback missing it is rejected. + Supported, + /// The server is known not to support `iss`, so a present `iss` is ignored. + NotSupported, + /// Support is unknown, so `iss` is validated only when present. + Unknown +}; + +/// @ingroup oauth +/// The reason a callback was rejected, in the order the checks run. The first +/// failing check decides the outcome (RFC 9700 Section 4). +enum class OAuthCallbackError : std::uint8_t { + /// The state is missing or does not match, a possible cross-site request + /// forgery (RFC 6749 Section 10.12). + State, + /// The callback arrived on a URI other than the one the request was sent with + /// (RFC 8252 Section 8.10). + ReceivedURI, + /// The `iss` is missing when required or does not match the expected issuer, + /// a possible mix-up attack (RFC 9207 Section 2.4). + Issuer, + /// The authorization server returned an error rather than a code (RFC 6749 + /// Section 4.1.2.1). + Declined, + /// The response carries neither an error nor a code. + MissingCode +}; + +/// @ingroup oauth +/// Mint the secrets for a new authorization code flow, a random `state` and a +/// PKCE code verifier (RFC 7636 Section 4.1, RFC 6749 Section 10.12). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto secrets{sourcemeta::core::oauth_transaction_mint()}; +/// assert(secrets.state.size() == 43); +/// assert(secrets.code_verifier.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_transaction_mint() -> OAuthTransactionSecrets; + +/// @ingroup oauth +/// Validate an authorization response against the retained transaction, +/// returning the authorization code through `code` on success or the first +/// failing check otherwise (RFC 9700 Section 4). The checks run in order: the +/// state in constant time, the received URI when one is supplied, the issuer, +/// whether the server declined, and finally the presence of a code. Pass an +/// empty `received_uri` to skip that check. The state check always defends +/// against cross-site request forgery. Guaranteed mix-up defense needs a +/// non-empty `received_uri` (RFC 8252 Section 8.10) or an `issuer_support` of +/// `Supported`, which makes a missing `iss` fatal (RFC 9207 Section 2.4). With +/// `Unknown` the `iss` is validated only when present, so a server that omits +/// it is not caught, and with `NotSupported` it is ignored entirely. A caller +/// talking to more than one authorization server must therefore supply one of +/// those two. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// sourcemeta::core::oauth_parse_authorization_response( +/// "code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", storage, response); +/// const sourcemeta::core::OAuthTransaction transaction{ +/// .state = "xyz", .code_verifier = "", .issuer = "", .redirect_uri = ""}; +/// std::string_view code; +/// const auto error{sourcemeta::core::oauth_transaction_check( +/// transaction, response, sourcemeta::core::OAuthIssuerSupport::Unknown, +/// "", code)}; +/// assert(!error.has_value()); +/// assert(code == "SplxlOBeZQQYbYS6WxSbIA"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_transaction_check(const OAuthTransaction &transaction, + const OAuthAuthorizationResponse &response, + const OAuthIssuerSupport issuer_support, + const std::string_view received_uri, + std::string_view &code) + -> 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 new file mode 100644 index 00000000..c61d2b71 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_assertion.cc @@ -0,0 +1,273 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "oauth_encode.h" + +#include // std::ranges::find_if, std::clamp +#include // std::chrono::seconds, std::chrono::duration_cast +#include // std::int64_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ALG{JSON::Object::hash("alg"sv)}; +const auto HASH_KID{JSON::Object::hash("kid"sv)}; +const auto HASH_ISS{JSON::Object::hash("iss"sv)}; +const auto HASH_SUB{JSON::Object::hash("sub"sv)}; +const auto HASH_AUD{JSON::Object::hash("aud"sv)}; +const auto HASH_EXP{JSON::Object::hash("exp"sv)}; +const auto HASH_IAT{JSON::Object::hash("iat"sv)}; +const auto HASH_JTI{JSON::Object::hash("jti"sv)}; + +auto to_epoch_seconds(const std::chrono::system_clock::time_point point) + -> std::int64_t { + return static_cast( + std::chrono::duration_cast(point.time_since_epoch()) + .count()); +} + +auto map_verification_error(const JWTVerificationError error) + -> OAuthAssertionError { + switch (error) { + case JWTVerificationError::AlgorithmNotAllowed: + return OAuthAssertionError::UnsupportedAlgorithm; + case JWTVerificationError::UnknownKey: + return OAuthAssertionError::UnknownKey; + case JWTVerificationError::Signature: + return OAuthAssertionError::Signature; + case JWTVerificationError::Type: + return OAuthAssertionError::Malformed; + case JWTVerificationError::Issuer: + return OAuthAssertionError::Issuer; + case JWTVerificationError::Subject: + return OAuthAssertionError::Subject; + case JWTVerificationError::Audience: + return OAuthAssertionError::Audience; + case JWTVerificationError::Expiration: + return OAuthAssertionError::Expired; + case JWTVerificationError::NotBefore: + return OAuthAssertionError::NotYetValid; + case JWTVerificationError::IssuedAt: + return OAuthAssertionError::IssuedInFuture; + } + + std::unreachable(); +} + +// RFC 7519 Section 4.1.3: the audience is a string or an array of strings, so +// an array carrying a non-string element is a malformed claim the assertion is +// rejected for (RFC 7523 Section 3 check 10) rather than accepted because +// another element happens to match +auto audience_is_well_formed(const JWT &token) -> bool { + if (!token.payload().is_object()) { + return false; + } + + const auto *audience{token.payload().try_at("aud"sv, HASH_AUD)}; + if (audience == nullptr || audience->is_string()) { + return true; + } + + if (!audience->is_array()) { + return false; + } + + for (const auto &element : audience->as_array()) { + if (!element.is_string()) { + return false; + } + } + + return true; +} + +// The shared verification of a parsed JWT bearer assertion (RFC 7523 +// Section 3): find the audience the assertion targets, delegate the signature +// and time checks to the JSON Web Token verifier, and reject a replayed +// identifier +auto verify_assertion( + const JWT &token, const std::string_view expected_issuer, + const std::optional expected_subject, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + if (!audience_is_well_formed(token)) { + return OAuthAssertionError::Audience; + } + + // RFC 7523 Section 3 check 3 and RFC 3986 Section 6.2.1: the audience is + // matched by Simple String Comparison, so an accepted value is compared to + // the string or array claim without any normalization + const auto match{std::ranges::find_if( + expected_audiences, [&token](const std::string_view audience) -> bool { + return token.has_audience(audience); + })}; + if (match == expected_audiences.end()) { + return OAuthAssertionError::Audience; + } + + const auto error{jwt_verify(token, keys, options.allowed_algorithms, + expected_issuer, *match, now, options.clock_skew, + expected_subject, std::nullopt)}; + if (error.has_value()) { + return map_verification_error(error.value()); + } + + // RFC 7523 Section 3 check 7: an identifier is tracked for as long as the + // assertion would be accepted, scoped to the audience it was issued for. The + // clock skew is added to the remaining lifetime so the entry outlives the + // grace window in which the assertion is still accepted past its expiration + if (options.replay_store != nullptr) { + const auto identifier{token.token_id()}; + const auto expiration{token.expires_at()}; + 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 + const auto skew{std::clamp(options.clock_skew, + std::chrono::seconds::zero(), + std::chrono::seconds{31556952})}; + const auto remaining{std::chrono::duration_cast( + expiration.value() - now) + + skew}; + const auto window{remaining > std::chrono::seconds{0} + ? remaining + : std::chrono::seconds{0}}; + // The audience is keyed verbatim, since assertion audiences are compared + // by Simple String Comparison and must not be URL-normalized (RFC 7523 + // Section 3, RFC 3986 Section 6.2.1) + if (!options.replay_store->check_and_insert(identifier.value(), *match, + now, window, false)) { + return OAuthAssertionError::Replay; + } + } + } + + return std::nullopt; +} + +} // namespace + +auto oauth_build_assertion(const std::string_view issuer, + const std::string_view subject, + const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, + const JWKPrivate &key, const JWSAlgorithm algorithm) + -> std::optional { + auto header{JSON::make_object()}; + header.assign_assume_new("alg", JSON{jws_algorithm_name(algorithm)}, + HASH_ALG); + // The key identifier lets the server select the verifying key (RFC 7515 + // Section 4.1.4) + const auto key_id{key.key_id()}; + if (key_id.has_value()) { + header.assign_assume_new("kid", JSON{key_id.value()}, HASH_KID); + } + + auto payload{JSON::make_object()}; + payload.assign_assume_new("iss", JSON{issuer}, HASH_ISS); + payload.assign_assume_new("sub", JSON{subject}, HASH_SUB); + payload.assign_assume_new("aud", JSON{audience}, HASH_AUD); + payload.assign_assume_new("exp", JSON{to_epoch_seconds(now + lifetime)}, + HASH_EXP); + payload.assign_assume_new("iat", JSON{to_epoch_seconds(now)}, HASH_IAT); + const auto identifier{oauth_random_token()}; + payload.assign_assume_new( + "jti", JSON{std::string_view{identifier.data(), identifier.size()}}, + HASH_JTI); + + return jwt_sign(header, payload, key); +} + +auto oauth_build_client_assertion( + const std::string_view client_id, const std::string_view audience, + const std::chrono::seconds lifetime, + const std::chrono::system_clock::time_point now, const JWKPrivate &key, + const JWSAlgorithm algorithm) -> std::optional { + // RFC 7521 Section 5.2: a self-issued client authentication assertion has the + // client identifier as both its issuer and its subject + return oauth_build_assertion(client_id, client_id, audience, lifetime, now, + key, algorithm); +} + +auto oauth_client_assertion(const std::string_view assertion, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "client_assertion_type", + OAUTH_CLIENT_ASSERTION_TYPE_JWT_BEARER); + oauth_append_form_parameter(sink, "client_assertion", assertion); +} + +auto oauth_build_token_request_jwt_bearer(const std::string_view assertion, + const std::string_view scope, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "grant_type", OAUTH_GRANT_TYPE_JWT_BEARER); + oauth_append_form_parameter(sink, "assertion", assertion); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } +} + +auto oauth_verify_client_assertion( + const std::string_view assertion, + const std::span expected_audiences, + const std::string_view request_client_id, const JWKS &keys, + const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + const auto token{JWT::from(assertion)}; + if (!token.has_value()) { + return OAuthAssertionError::Malformed; + } + + const auto subject{token.value().subject()}; + if (!subject.has_value()) { + return OAuthAssertionError::Subject; + } + + // RFC 7521 Section 5.2: the issuer and subject are both the client + // identifier, and RFC 7521 Section 4.2: a presented client_id must identify + // the same client as the assertion subject + const auto expected_subject{request_client_id.empty() ? subject.value() + : request_client_id}; + return verify_assertion(token.value(), subject.value(), expected_subject, + expected_audiences, keys, now, options); +} + +auto oauth_verify_assertion_grant( + const std::string_view assertion, const std::string_view expected_issuer, + const std::span expected_audiences, + const JWKS &keys, const std::chrono::system_clock::time_point now, + const OAuthAssertionVerifyOptions &options) + -> std::optional { + const auto token{JWT::from(assertion)}; + if (!token.has_value()) { + return OAuthAssertionError::Malformed; + } + + // RFC 7523 Section 3 check 2: a subject is REQUIRED, though for a grant it + // identifies the accessor rather than being constrained to a known value + if (!token.value().subject().has_value()) { + return OAuthAssertionError::Subject; + } + + return verify_assertion(token.value(), expected_issuer, std::nullopt, + expected_audiences, keys, now, options); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_authorization.cc b/vendor/core/src/core/oauth/oauth_authorization.cc new file mode 100644 index 00000000..695fe8e4 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_authorization.cc @@ -0,0 +1,398 @@ +#include + +#include +#include + +#include "oauth_authorization_parse.h" +#include "oauth_decode.h" +#include "oauth_syntax.h" + +#include // std::function +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto oauth_append_parameter(std::string &sink, char &separator, + const std::string_view name, + const std::string_view value) -> void { + if (separator != '\0') { + sink.push_back(separator); + } + + separator = '&'; + // RFC 6749 Section 4.1.1: the query is application/x-www-form-urlencoded, so + // the name is escaped too, which is a no-op for the fixed keys but keeps a + // caller-supplied resource or extra name from corrupting the query + URI::escape(name, sink); + sink.push_back('='); + URI::escape(value, sink); +} + +struct HttpAuthority { + std::string_view host; + std::string_view rest; +}; + +// Split an "http://" URI into its host and the remainder after the authority, +// discarding the port, without constructing a URI (RFC 3986 Section 3.2) +auto split_http_authority(const std::string_view value) + -> std::optional { + static constexpr std::string_view prefix{"http://"}; + if (!value.starts_with(prefix)) { + return std::nullopt; + } + + const auto after{value.substr(prefix.size())}; + // RFC 3986 Section 3.2: the authority ends at the next "/", "?", or "#", or + // the end of the URI, so the query and fragment are part of the compared + // remainder rather than folded into the authority + const auto path_position{after.find_first_of("/?#")}; + auto authority{after.substr(0, path_position)}; + const auto rest{path_position == std::string_view::npos + ? std::string_view{} + : after.substr(path_position)}; + + // RFC 3986 Section 3.2.1: userinfo is delimited from the host by an "@". A + // loopback redirect never carries userinfo, and allowing it invites host + // confusion, since a redirect like "http://127.0.0.1:2@evil/cb" has its real + // host after the "@" yet its authority begins with the loopback literal, so + // an authority with userinfo does not qualify for the loopback exception + if (authority.find('@') != std::string_view::npos) { + return std::nullopt; + } + + std::string_view host; + if (authority.starts_with('[')) { + const auto close{authority.find(']')}; + if (close == std::string_view::npos) { + return std::nullopt; + } + + host = authority.substr(0, close + 1); + } else { + host = authority.substr(0, authority.find(':')); + } + + return HttpAuthority{.host = host, .rest = rest}; +} + +} // namespace + +auto oauth_build_authorization_url(const std::string_view endpoint, + const OAuthAuthorizationRequest &request, + std::string &sink) -> void { + // A lower bound covering the endpoint, the known keys and separators, and the + // raw value lengths, over which percent-escaping only ever grows + sink.reserve(sink.size() + endpoint.size() + request.client_id.size() + + request.redirect_uri.size() + request.scope.size() + + request.state.size() + request.code_challenge.size() + + request.code_challenge_method.size() + + request.request_uri.size() + request.dpop_jkt.size() + 128); + sink.append(endpoint); + // The separator before the first parameter: '?' opens a query the endpoint + // lacks, nothing when the endpoint already ends its query with '?' or '&', + // and '&' continues an existing query (RFC 6749 Section 3.1) + char separator{'?'}; + if (endpoint.find('?') != std::string_view::npos) { + separator = + (endpoint.empty() || endpoint.back() == '?' || endpoint.back() == '&') + ? '\0' + : '&'; + } + + // RFC 6749 Section 4.1.1: the authorization code flow is the only response + // type this builder emits, since the implicit grant is not represented + oauth_append_parameter(sink, separator, "response_type", "code"); + + if (!request.client_id.empty()) { + oauth_append_parameter(sink, separator, "client_id", request.client_id); + } + + if (!request.redirect_uri.empty()) { + oauth_append_parameter(sink, separator, "redirect_uri", + request.redirect_uri); + } + + if (!request.scope.empty()) { + oauth_append_parameter(sink, separator, "scope", request.scope); + } + + if (!request.state.empty()) { + oauth_append_parameter(sink, separator, "state", request.state); + } + + if (!request.code_challenge.empty()) { + oauth_append_parameter(sink, separator, "code_challenge", + request.code_challenge); + // RFC 7636 Section 4.3: the method qualifies a challenge, so it is + // meaningless and omitted when no challenge is present + if (!request.code_challenge_method.empty()) { + oauth_append_parameter(sink, separator, "code_challenge_method", + request.code_challenge_method); + } + } + + if (!request.request_uri.empty()) { + oauth_append_parameter(sink, separator, "request_uri", request.request_uri); + } + + if (!request.dpop_jkt.empty()) { + oauth_append_parameter(sink, separator, "dpop_jkt", request.dpop_jkt); + } + + for (const auto &resource : request.resources) { + oauth_append_parameter(sink, separator, resource.name, resource.value); + } + + for (const auto ¶meter : request.extra) { + oauth_append_parameter(sink, separator, parameter.name, parameter.value); + } +} + +auto oauth_parse_authorization_request( + const std::string_view query, std::string &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool { + return oauth_parse_authorization_into(query, storage, result, on_other, + false); +} + +auto oauth_redirect_uri_matches(const std::string_view registered, + const std::string_view presented, + const OAuthProfile profile) -> bool { + // RFC 6749 Section 3.1.2.3: the default is an exact match of the two URIs + if (registered == presented) { + return true; + } + + const auto registered_parts{split_http_authority(registered)}; + const auto presented_parts{split_http_authority(presented)}; + if (!registered_parts.has_value() || !presented_parts.has_value()) { + return false; + } + + // RFC 8252 Section 7.3: for a loopback http redirect the client may vary the + // port, so the two match when only the port differs. RFC 8252 Section 8.3 and + // OAuth 2.1 Section 8.4.2 keep "localhost" out of the loopback set under the + // strict profile + const auto host{registered_parts.value().host}; + const bool loopback{ + host == "127.0.0.1" || host == "[::1]" || + (profile == OAuthProfile::Compatible && host == "localhost")}; + return loopback && host == presented_parts.value().host && + registered_parts.value().rest == presented_parts.value().rest; +} + +auto oauth_is_private_use_scheme(const std::string_view scheme) noexcept + -> bool { + // RFC 3986 Section 3.1: "scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." + // )". A reverse domain name has no empty label, so a leading or trailing + // period is rejected here, and an interior empty label below + if (scheme.empty() || !is_alpha(scheme.front()) || scheme.back() == '.') { + return false; + } + + // RFC 8252 Section 7.1 and Section 8.4: a private-use scheme is a reverse + // domain name, so it must contain at least one period and no empty label + bool has_period{false}; + char previous{'\0'}; + for (const auto character : scheme) { + if (!is_alphanum(character) && character != '+' && character != '-' && + character != '.') { + return false; + } + + if (character == '.') { + if (previous == '.') { + return false; + } + + has_period = true; + } + + previous = character; + } + + return has_period; +} + +auto oauth_build_authorization_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool { + // A success response carries the code, and RFC 9207 Section 2 constrains the + // issuer syntax when one is echoed + if (response.code.empty()) { + return false; + } + + // RFC 6749 Section 3.1.2: a redirection endpoint "MUST NOT include a fragment + // component", and appending the query after one would place the parameters + // inside the fragment rather than the query + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + sink.reserve(sink.size() + redirect_uri.size() + response.code.size() + + response.state.size() + response.iss.size() + 32); + sink.append(redirect_uri); + char separator{redirect_uri.find('?') == std::string_view::npos ? '?' : '&'}; + oauth_append_parameter(sink, separator, "code", response.code); + // RFC 6749 Section 4.1.2: state is returned when the request carried one, + // which the caller reflects by setting it + if (!response.state.empty()) { + oauth_append_parameter(sink, separator, "state", response.state); + } + + if (!response.iss.empty()) { + oauth_append_parameter(sink, separator, "iss", response.iss); + } + + return true; +} + +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool { + // RFC 6749 Section 4.1.2.1: this builder must not be called when the redirect + // URI or client identifier failed validation, since the error is then shown + // to the resource owner rather than redirected. An error response carries the + // error code + if (response.error.empty()) { + return false; + } + + // RFC 6749 Section 3.1.2: a redirection endpoint "MUST NOT include a fragment + // component", and appending the query after one would place the parameters + // inside the fragment rather than the query + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + sink.reserve(sink.size() + redirect_uri.size() + response.error.size() + + response.error_description.size() + response.error_uri.size() + + response.state.size() + response.iss.size() + 64); + sink.append(redirect_uri); + char separator{redirect_uri.find('?') == std::string_view::npos ? '?' : '&'}; + oauth_append_parameter(sink, separator, "error", response.error); + if (!response.error_description.empty()) { + oauth_append_parameter(sink, separator, "error_description", + response.error_description); + } + + if (!response.error_uri.empty()) { + oauth_append_parameter(sink, separator, "error_uri", response.error_uri); + } + + if (!response.state.empty()) { + oauth_append_parameter(sink, separator, "state", response.state); + } + + if (!response.iss.empty()) { + oauth_append_parameter(sink, separator, "iss", response.iss); + } + + return true; +} + +auto oauth_parse_authorization_response(const std::string_view query, + std::string &storage, + OAuthAuthorizationResponse &result) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + query.size()); + const URI::Query parsed{query}; + bool has_code{false}; + bool has_state{false}; + bool has_iss{false}; + bool has_error{false}; + bool has_error_description{false}; + bool has_error_uri{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, the same rule + // the request parsers apply, and a malformed escape fails the parse + std::string_view name; + if (!oauth_form_decode_into(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + // RFC 6749 Section 3.1: "Request and response parameters MUST NOT be + // included more than once", so a duplicate is malformed, while an + // unrecognized parameter is ignored (RFC 6749 Section 4.1.2) + if (name == "code") { + if (has_code) { + return false; + } + + has_code = true; + if (!oauth_form_decode_into(value, storage, result.code)) { + return false; + } + } else if (name == "state") { + if (has_state) { + return false; + } + + has_state = true; + if (!oauth_form_decode_into(value, storage, result.state)) { + return false; + } + } else if (name == "iss") { + if (has_iss) { + return false; + } + + has_iss = true; + if (!oauth_form_decode_into(value, storage, result.iss)) { + return false; + } + } else if (name == "error") { + if (has_error) { + return false; + } + + has_error = true; + if (!oauth_form_decode_into(value, storage, result.error)) { + return false; + } + } else if (name == "error_description") { + if (has_error_description) { + return false; + } + + has_error_description = true; + if (!oauth_form_decode_into(value, storage, result.error_description)) { + return false; + } + } else if (name == "error_uri") { + if (has_error_uri) { + return false; + } + + has_error_uri = true; + if (!oauth_form_decode_into(value, storage, result.error_uri)) { + return false; + } + } + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_authorization_parse.h b/vendor/core/src/core/oauth/oauth_authorization_parse.h new file mode 100644 index 00000000..ded05774 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_authorization_parse.h @@ -0,0 +1,147 @@ +#ifndef SOURCEMETA_CORE_OAUTH_AUTHORIZATION_PARSE_H_ +#define SOURCEMETA_CORE_OAUTH_AUTHORIZATION_PARSE_H_ + +#include +#include +#include + +#include "oauth_decode.h" + +#include // std::function +#include // std::string_view +#include // std::is_same_v + +namespace sourcemeta::core { + +// Decode one value into the arena, borrowing when the arena is an ordinary +// string and always appending when it is a wiping one, so a body that carries +// secrets keeps its decoded values in wiping storage +template +inline auto oauth_authorization_decode(const std::string_view value, + Storage &storage, + std::string_view &result) -> bool { + if constexpr (std::is_same_v) { + return oauth_form_decode_into_secure(value, storage, result); + } else { + return oauth_form_decode_into(value, storage, result); + } +} + +template +inline auto oauth_authorization_assign_scalar(const std::string_view value, + Storage &storage, bool &seen, + std::string_view &field) -> bool { + // RFC 6749 Section 3.1: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.1: "Request and response parameters MUST NOT be included + // more than once", so a second occurrence of a recognized parameter fails + if (seen) { + return false; + } + + seen = true; + return oauth_authorization_decode(value, storage, field); +} + +// The shared authorization request parse (RFC 6749 Section 4.1.1). The storage +// type selects the arena posture, and reject_request_uri turns the presence of +// a request_uri parameter into a failure, which a pushed authorization request +// requires (RFC 9126 Section 2.1) +template +inline auto oauth_parse_authorization_into( + const std::string_view query, Storage &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other, + const bool reject_request_uri) -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + query.size()); + const URI::Query parsed{query}; + bool has_response_type{false}; + bool has_client_id{false}; + bool has_redirect_uri{false}; + bool has_scope{false}; + bool has_state{false}; + bool has_code_challenge{false}; + bool has_code_challenge_method{false}; + bool has_request_uri{false}; + bool has_dpop_jkt{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_authorization_decode(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "response_type") { + valid = oauth_authorization_assign_scalar( + value, storage, has_response_type, result.response_type); + } else if (name == "client_id") { + valid = oauth_authorization_assign_scalar(value, storage, has_client_id, + result.client_id); + } else if (name == "redirect_uri") { + valid = oauth_authorization_assign_scalar( + value, storage, has_redirect_uri, result.redirect_uri); + } else if (name == "scope") { + valid = oauth_authorization_assign_scalar(value, storage, has_scope, + result.scope); + } else if (name == "state") { + valid = oauth_authorization_assign_scalar(value, storage, has_state, + result.state); + } else if (name == "code_challenge") { + valid = oauth_authorization_assign_scalar( + value, storage, has_code_challenge, result.code_challenge); + } else if (name == "code_challenge_method") { + valid = oauth_authorization_assign_scalar(value, storage, + has_code_challenge_method, + result.code_challenge_method); + } else if (name == "request_uri") { + // RFC 9126 Section 2.1: a pushed request MUST NOT provide request_uri, so + // its mere presence, whatever its value, fails the parse + if (reject_request_uri) { + return false; + } + + valid = oauth_authorization_assign_scalar(value, storage, has_request_uri, + result.request_uri); + } else if (name == "dpop_jkt") { + valid = oauth_authorization_assign_scalar(value, storage, has_dpop_jkt, + result.dpop_jkt); + } else { + // Repeatable resource indicators (RFC 8707) and extension parameters are + // surfaced with their decoded value rather than stored on the result + std::string_view decoded; + valid = oauth_authorization_decode(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + // RFC 7636 Section 4.3: "If the client does not send the + // code_challenge_method parameter ... the default code_challenge_method value + // is plain", which the strict profile must still see in order to reject + if (has_code_challenge && !has_code_challenge_method) { + result.code_challenge_method = "plain"; + } + + return true; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_bearer.cc b/vendor/core/src/core/oauth/oauth_bearer.cc new file mode 100644 index 00000000..09fd1c73 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_bearer.cc @@ -0,0 +1,261 @@ +#include + +#include +#include +#include + +#include // std::array +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::pair + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_AUD{JSON::Object::hash("aud"sv)}; +const auto HASH_CNF{JSON::Object::hash("cnf"sv)}; +const auto HASH_JKT{JSON::Object::hash("jkt"sv)}; + +auto oauth_skip_ows(const std::string_view value, std::size_t position) noexcept + -> std::size_t { + while (position < value.size() && http_is_ows(value[position])) { + position += 1; + } + + return position; +} + +} // namespace + +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool { + if (!http_is_b64token(token)) { + return false; + } + + static constexpr std::string_view prefix{"Bearer "}; + sink.reserve(sink.size() + prefix.size() + token.size()); + sink.append(prefix); + sink.append(token); + return true; +} + +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name) + -> std::optional { + // A local arena holds the unescaped bytes of a quoted value. The header is + // only ever read, never grown, so it can never alias the arena, and the + // matched value is returned by copy before the arena goes out of scope + std::string arena; + std::size_t position{0}; + // The scheme of the challenge currently being read. RFC 7235 shares the comma + // between the challenge list and each challenge's parameter list, so a token + // is a new scheme unless it is immediately followed by "=", in which case it + // is a parameter of the current challenge + std::string_view current_scheme{}; + // Whether the current challenge has already taken a parameter, and whether + // the next element must be preceded by a comma. RFC 9110 Section 5.6.1 + // separates list elements with commas, so a parameter or challenge that + // follows another without one is malformed rather than silently reassociated + bool scheme_has_parameter{false}; + bool separator_required{false}; + while (position < header.size()) { + position = oauth_skip_ows(header, position); + if (position >= header.size()) { + break; + } + + if (header[position] == ',') { + position += 1; + separator_required = false; + // A comma before any parameter ends an empty challenge, so a parameter + // that follows cannot attach to that scheme + if (!scheme_has_parameter) { + current_scheme = {}; + } + + continue; + } + + // The element is not a comma, so if one was required the header is + // malformed + if (separator_required) { + return std::nullopt; + } + + const std::size_t token_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == token_start) { + return std::nullopt; + } + + const auto token{header.substr(token_start, position - token_start)}; + const auto after_token{position}; + const auto equals{oauth_skip_ows(header, position)}; + if (equals < header.size() && header[equals] == '=') { + if (current_scheme.empty()) { + return std::nullopt; + } + + position = oauth_skip_ows(header, equals + 1); + const bool match{equals_ignore_case(current_scheme, scheme) && + equals_ignore_case(token, name)}; + std::string_view value{}; + if (position < header.size() && header[position] == '"') { + arena.clear(); + const auto next{ + http_scan_quoted_string(header, position, arena, value)}; + if (!next.has_value()) { + return std::nullopt; + } + + position = next.value(); + } else { + const std::size_t value_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == value_start) { + return std::nullopt; + } + + value = header.substr(value_start, position - value_start); + } + + scheme_has_parameter = true; + separator_required = true; + if (match) { + return std::string{value}; + } + + continue; + } + + // The token is a new scheme. It may be followed by a token68 credential, + // which is skipped whole so the scan reaches the next challenge + current_scheme = token; + scheme_has_parameter = false; + const auto separator{oauth_skip_ows(header, after_token)}; + if (separator > after_token && separator < header.size()) { + std::size_t blob{separator}; + while (blob < header.size() && http_is_b64token_char(header[blob])) { + blob += 1; + } + + while (blob < header.size() && header[blob] == '=') { + blob += 1; + } + + // A token68 must be a valid token68 (RFC 7235 Section 2.1) and must end + // the challenge, so a bare padding run is not one and a parameter cannot + // attach to the token68 form of a challenge + const auto terminator{oauth_skip_ows(header, blob)}; + if ((terminator >= header.size() || header[terminator] == ',') && + http_is_b64token(header.substr(separator, blob - separator))) { + position = blob; + current_scheme = {}; + separator_required = true; + } + } + } + + return std::nullopt; +} + +auto oauth_build_challenge(const std::string_view scheme, + const OAuthChallenge &challenge, std::string &sink) + -> bool { + // RFC 7235 Section 2.1: the scheme is a token + if (!http_is_token(scheme)) { + return false; + } + + const std::array, 7> parameters{ + {{"realm", challenge.realm}, + {"scope", challenge.scope}, + {"error", challenge.error}, + {"error_description", challenge.error_description}, + {"error_uri", challenge.error_uri}, + {"resource_metadata", challenge.resource_metadata}, + {"algs", challenge.algs}}}; + + // The challenge is rendered into a scratch buffer so that a later unencodable + // value leaves the sink untouched. A challenge with no parameter is a bare + // scheme, which RFC 7235 Section 2.1 permits and RFC 9449 Section 7.1 uses + // for a DPoP challenge with no parameters + std::string rendered; + bool first{true}; + for (const auto &[name, value] : parameters) { + if (value.empty()) { + continue; + } + + // RFC 7235 Section 4.1: a space separates the scheme from the first + // parameter, and a comma the parameters from each other + rendered.append(first ? " " : ", "); + rendered.append(name); + rendered.push_back('='); + if (!http_encode_quoted_string(value, rendered)) { + return false; + } + + first = false; + } + + sink.reserve(sink.size() + scheme.size() + rendered.size()); + sink.append(scheme); + sink.append(rendered); + return true; +} + +auto oauth_has_audience(const JSON &claims, const std::string_view audience) + -> bool { + // A resource identifier is an absolute URI and never empty (RFC 8707 + // Section 2), so an empty query cannot be a legitimate audience even if a + // token were to carry an empty one + if (audience.empty() || !claims.is_object()) { + return false; + } + + const auto *member{claims.try_at("aud"sv, HASH_AUD)}; + if (member == nullptr) { + return false; + } + + // RFC 9068 Section 4 and RFC 7662 Section 2.2: the audience is a single + // string or an array of strings, compared by code points (RFC 3986 + // Section 6.2.1) + if (member->is_string()) { + return member->to_string() == audience; + } + + return member->is_array() && member->contains(audience); +} + +auto oauth_is_dpop_bound(const JSON &claims) -> bool { + if (!claims.is_object()) { + return false; + } + + const auto *confirmation{claims.try_at("cnf"sv, HASH_CNF)}; + if (confirmation == nullptr || !confirmation->is_object()) { + return false; + } + + // RFC 9449 Section 6.1: a DPoP-bound token carries the JWK thumbprint of its + // proof-of-possession key as the "jkt" confirmation member + const auto *thumbprint{confirmation->try_at("jkt"sv, HASH_JKT)}; + return thumbprint != nullptr && thumbprint->is_string(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_client_authentication.cc b/vendor/core/src/core/oauth/oauth_client_authentication.cc new file mode 100644 index 00000000..b2f27275 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_client_authentication.cc @@ -0,0 +1,206 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::size_t +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto assign_secure_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.2: a recognized parameter must not appear twice + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +auto oauth_client_secret_basic(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void { + // RFC 6749 Section 2.3.1: each half is percent-encoded before it is joined + // with a colon, so a colon inside the identifier or secret cannot be mistaken + // for the delimiter, and only then is the pair Base64 encoded + SecureString credential; + credential.reserve(client_id.size() + client_secret.size() + 1); + URI::escape(client_id, credential); + credential.push_back(':'); + URI::escape(client_secret, credential); + + sink.append("Basic "); + base64_encode(std::string_view{credential}, sink); +} + +auto oauth_client_secret_post(const std::string_view client_id, + const std::string_view client_secret, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "client_id", client_id); + oauth_append_form_parameter(sink, "client_secret", client_secret); +} + +auto oauth_client_id_only(const std::string_view client_id, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "client_id", client_id); +} + +auto oauth_parse_client_authentication(const std::string_view authorization, + const std::string_view body, + SecureString &storage, + OAuthClientCredentials &credentials) + -> bool { + credentials = {}; + credentials.method = OAuthClientAuthenticationMethod::None; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view. Every decoded value is at most + // its raw length, so the two inputs bound the total + storage.reserve(storage.size() + authorization.size() + body.size()); + + bool basic_present{false}; + std::string_view basic_id; + std::string_view basic_secret; + const auto scheme_end{authorization.find(' ')}; + if (scheme_end != std::string_view::npos && + equals_ignore_case(authorization.substr(0, scheme_end), "Basic")) { + basic_present = true; + // RFC 7235 Section 2.1: the scheme is followed by one or more spaces before + // the credential + auto token_start{scheme_end}; + while (token_start < authorization.size() && + authorization[token_start] == ' ') { + token_start += 1; + } + + // RFC 6749 Section 2.3.1: the credential is the Base64 of the percent + // encoded identifier and secret joined by a colon, so a decode failure or a + // missing colon is a malformed request. The decoded credential is secret, + // so it lands in a local wiping string + SecureString credential; + if (!base64_decode(authorization.substr(token_start), credential)) { + return false; + } + + const std::string_view decoded{credential}; + const auto colon{decoded.find(':')}; + if (colon == std::string_view::npos || + !oauth_form_decode_into_secure(decoded.substr(0, colon), storage, + basic_id) || + !oauth_form_decode_into_secure(decoded.substr(colon + 1), storage, + basic_secret)) { + return false; + } + } + + bool has_client_id{false}; + bool has_client_secret{false}; + bool has_assertion{false}; + bool has_assertion_type{false}; + std::string_view body_client_id; + std::string_view body_client_secret; + std::string_view body_assertion; + std::string_view body_assertion_type; + const URI::Query parsed{body}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "client_id") { + valid = + assign_secure_scalar(value, storage, has_client_id, body_client_id); + } else if (name == "client_secret") { + valid = assign_secure_scalar(value, storage, has_client_secret, + body_client_secret); + } else if (name == "client_assertion") { + valid = + assign_secure_scalar(value, storage, has_assertion, body_assertion); + } else if (name == "client_assertion_type") { + valid = assign_secure_scalar(value, storage, has_assertion_type, + body_assertion_type); + } + + if (!valid) { + return false; + } + } + + // RFC 6749 Section 2.3 and RFC 7521 Section 4.2.1: a request must use exactly + // one authentication mechanism, and the assertion parameters count as one, so + // presenting more than one is rejected. The caller chooses the error code, + // which RFC 7521 Section 4.2.1 makes invalid_client when the collision + // involves the assertion mechanism and RFC 6749 Section 5.2 makes + // invalid_request otherwise. A bare body client_id is identification, not a + // mechanism (RFC 6749 Section 3.2.1) + const bool assertion_present{has_assertion || has_assertion_type}; + const auto mechanisms{static_cast(basic_present) + + static_cast(has_client_secret) + + static_cast(assertion_present)}; + if (mechanisms > 1) { + return false; + } + + // RFC 6749 Section 5.2: a Basic username that conflicts with a body client_id + // is a malformed request, so the two must identify the same client + if (basic_present && has_client_id && basic_id != body_client_id) { + return false; + } + + // RFC 7521 Section 4.2: the assertion mechanism needs both parameters + if (assertion_present && !(has_assertion && has_assertion_type)) { + return false; + } + + // RFC 6749 Section 2.3.1: client_secret_post carries both the identifier and + // the secret, so a body secret with no client_id is an incomplete mechanism. + // A Basic secret has its own identifier, and a Basic and body secret together + // are already rejected as two mechanisms above + if (has_client_secret && !has_client_id) { + return false; + } + + if (basic_present) { + credentials.method = OAuthClientAuthenticationMethod::Basic; + credentials.client_id = basic_id; + credentials.client_secret = basic_secret; + } else if (has_client_secret) { + credentials.method = OAuthClientAuthenticationMethod::Post; + credentials.client_id = body_client_id; + credentials.client_secret = body_client_secret; + } else if (assertion_present) { + credentials.method = OAuthClientAuthenticationMethod::Assertion; + credentials.client_id = body_client_id; + credentials.assertion = body_assertion; + credentials.assertion_type = body_assertion_type; + } else if (has_client_id) { + credentials.method = OAuthClientAuthenticationMethod::Public; + credentials.client_id = body_client_id; + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_decode.h b/vendor/core/src/core/oauth/oauth_decode.h new file mode 100644 index 00000000..9765f877 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_decode.h @@ -0,0 +1,67 @@ +#ifndef SOURCEMETA_CORE_OAUTH_DECODE_H_ +#define SOURCEMETA_CORE_OAUTH_DECODE_H_ + +#include +#include + +#include // assert +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +// Decode one "application/x-www-form-urlencoded" value against the caller's +// arena following design convention 1: borrow from the input when it carries no +// escape, otherwise append the decoded bytes to the arena and view them there. +// The arena MUST be reserved up front to at least the total raw length of every +// value that will be decoded into it, since decoding only shrinks, so that no +// append reallocates and a prior borrowed view into it stays valid. Reserving +// the whole input length once satisfies this +inline auto oauth_form_decode_into(const std::string_view value, + std::string &arena, std::string_view &result) + -> bool { + if (value.find_first_of("+%") == std::string_view::npos) { + result = value; + return true; + } + + // The caller must have reserved enough headroom for the raw value, since + // decoding only shrinks, so this append never reallocates and a prior + // borrowed view into the arena stays valid. The assert catches a violation + // loudly under test, and the guard fails closed rather than dangle a view 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; + } + + result = std::string_view{arena}.substr(base); + return true; +} + +// Decode one "application/x-www-form-urlencoded" value into a wiping arena, for +// a request whose values are secret such as a token. Unlike the borrowing +// variant it always appends, so the decoded view lands in the wiping arena +// rather than the caller's buffer, and the value never aliases the arena. The +// arena MUST be reserved up front to at least the total raw length so no append +// reallocates and a prior view into it stays valid +inline auto oauth_form_decode_into_secure(const std::string_view value, + SecureString &arena, + std::string_view &result) -> bool { + const auto base{arena.size()}; + if (!URI::unescape_form(value, arena)) { + return false; + } + + result = std::string_view{arena}.substr(base); + return true; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_device.cc b/vendor/core/src/core/oauth/oauth_device.cc new file mode 100644 index 00000000..a6f1d65f --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_device.cc @@ -0,0 +1,285 @@ +#include + +#include +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::array +#include // std::chrono::seconds, std::chrono::steady_clock +#include // std::size_t +#include // std::int64_t, std::uint8_t +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +// RFC 8628 Section 6.1: the recommended base-20 user code alphabet, the +// uppercase letters A to Z with the vowels and Y removed so a code cannot form +// a word +constexpr std::string_view USER_CODE_ALPHABET{"BCDFGHJKLMNPQRSTVWXZ"}; +constexpr std::chrono::seconds DEFAULT_INTERVAL{5}; + +const auto HASH_DEVICE_CODE{JSON::Object::hash("device_code"sv)}; +const auto HASH_USER_CODE{JSON::Object::hash("user_code"sv)}; +const auto HASH_VERIFICATION_URI{JSON::Object::hash("verification_uri"sv)}; +const auto HASH_VERIFICATION_URI_COMPLETE{ + JSON::Object::hash("verification_uri_complete"sv)}; +const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; +const auto HASH_INTERVAL{JSON::Object::hash("interval"sv)}; + +auto normalize_user_code(const std::string_view value) -> SecureString { + SecureString result; + result.reserve(value.size()); + for (const auto character : value) { + if (!is_alphanum(character)) { + continue; + } + + // The alphabet is uppercase, so a lowercase ASCII letter is folded up + if (character >= 'a' && character <= 'z') { + result.push_back(static_cast(character - ('a' - 'A'))); + } else { + result.push_back(character); + } + } + + return result; +} + +} // namespace + +auto oauth_build_device_authorization_request( + const std::string_view client_id, const std::string_view scope, + const std::span resources, std::string &sink) + -> void { + if (!client_id.empty()) { + oauth_append_form_parameter(sink, "client_id", client_id); + } + + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + for (const auto &resource : resources) { + oauth_append_form_parameter(sink, resource.name, resource.value); + } +} + +auto oauth_build_token_request_device( + const std::string_view device_code, + const std::span resources, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "grant_type", + "urn:ietf:params:oauth:grant-type:device_code"); + oauth_append_form_parameter(sink, "device_code", device_code); + for (const auto &resource : resources) { + oauth_append_form_parameter(sink, resource.name, resource.value); + } +} + +OAuthDeviceAuthorizationResponse::OAuthDeviceAuthorizationResponse( + const JSON &data) + : data_{&data} {} + +auto OAuthDeviceAuthorizationResponse::device_code() const + -> std::optional { + return oauth_json_string_member(*this->data_, "device_code"sv, + HASH_DEVICE_CODE); +} + +auto OAuthDeviceAuthorizationResponse::user_code() const + -> std::optional { + return oauth_json_string_member(*this->data_, "user_code"sv, HASH_USER_CODE); +} + +auto OAuthDeviceAuthorizationResponse::verification_uri() const + -> std::optional { + return oauth_json_string_member(*this->data_, "verification_uri"sv, + HASH_VERIFICATION_URI); +} + +auto OAuthDeviceAuthorizationResponse::verification_uri_complete() const + -> std::optional { + return oauth_json_string_member(*this->data_, "verification_uri_complete"sv, + HASH_VERIFICATION_URI_COMPLETE); +} + +auto OAuthDeviceAuthorizationResponse::expires_in() const + -> std::optional { + // RFC 8628 Section 3.2: expires_in is a REQUIRED positive lifetime, so a zero + // or negative value is malformed and reported as absent rather than surfacing + // as an immediate expiry to a poller + const auto value{ + oauth_json_seconds_member(*this->data_, "expires_in"sv, HASH_EXPIRES_IN)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value + : std::nullopt; +} + +auto OAuthDeviceAuthorizationResponse::interval() const + -> std::chrono::seconds { + // RFC 8628 Section 3.2: absent interval means the default, and a non-positive + // interval is malformed, so it also falls back to the default rather than + // driving overly aggressive polling + const auto value{ + oauth_json_seconds_member(*this->data_, "interval"sv, HASH_INTERVAL)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value.value() + : DEFAULT_INTERVAL; +} + +auto OAuthDeviceAuthorizationResponse::data() const -> const JSON & { + return *this->data_; +} + +OAuthDevicePoller::OAuthDevicePoller( + const std::chrono::seconds interval, const std::chrono::seconds lifetime, + const std::chrono::steady_clock::time_point start) noexcept + : interval_{interval > std::chrono::seconds{0} ? interval + : DEFAULT_INTERVAL}, + lifetime_{lifetime}, start_{start} {} + +auto OAuthDevicePoller::interval() const noexcept -> std::chrono::seconds { + return this->interval_; +} + +auto OAuthDevicePoller::expired( + const std::chrono::steady_clock::time_point now) const noexcept -> bool { + // A time before the start has not elapsed anything, and truncating its + // negative sub-second difference to zero would otherwise read as expired for + // a zero lifetime + if (now < this->start_) { + return false; + } + + // Comparing the elapsed duration against the lifetime, rather than a + // start-plus-lifetime deadline, keeps a large lifetime from overflowing + return std::chrono::duration_cast(now - this->start_) >= + this->lifetime_; +} + +auto OAuthDevicePoller::observe(const OAuthTokenError error) noexcept + -> OAuthDevicePollDecision { + switch (error) { + case OAuthTokenError::SlowDown: + // RFC 8628 Section 3.5: slow_down permanently increases the interval by + // five seconds, saturating rather than overflowing a maximal interval + if (this->interval_ < + std::chrono::seconds{ + std::numeric_limits::max() - 5}) { + this->interval_ += std::chrono::seconds{5}; + } else { + this->interval_ = std::chrono::seconds{ + std::numeric_limits::max()}; + } + + return OAuthDevicePollDecision::Continue; + case OAuthTokenError::AuthorizationPending: + return OAuthDevicePollDecision::Continue; + case OAuthTokenError::UseDPoPNonce: + // RFC 9449 Section 8: the server requires a nonce, because the proof + // carried none or a stale one, and now supplies it, so the proof is + // re-minted with the nonce and the poll retried without growing the + // interval + return OAuthDevicePollDecision::RetryWithNonce; + case OAuthTokenError::AccessDenied: + return OAuthDevicePollDecision::Denied; + case OAuthTokenError::ExpiredToken: + return OAuthDevicePollDecision::Expired; + default: + return OAuthDevicePollDecision::Error; + } +} + +auto oauth_make_device_authorization_response( + const std::string_view device_code, const std::string_view user_code, + const std::string_view verification_uri, + const std::string_view verification_uri_complete, + const std::chrono::seconds expires_in, const std::chrono::seconds interval) + -> std::optional { + // RFC 8628 Section 3.2: device_code, user_code, verification_uri, and a + // positive expires_in lifetime are REQUIRED, so a missing one yields no + // document rather than an unusable response + if (device_code.empty() || user_code.empty() || verification_uri.empty() || + expires_in <= std::chrono::seconds{0}) { + return std::nullopt; + } + + auto response{JSON::make_object()}; + response.assign_assume_new("device_code", JSON{device_code}, + HASH_DEVICE_CODE); + response.assign_assume_new("user_code", JSON{user_code}, HASH_USER_CODE); + response.assign_assume_new("verification_uri", JSON{verification_uri}, + HASH_VERIFICATION_URI); + if (!verification_uri_complete.empty()) { + response.assign_assume_new("verification_uri_complete", + JSON{verification_uri_complete}, + HASH_VERIFICATION_URI_COMPLETE); + } + + response.assign_assume_new( + "expires_in", JSON{static_cast(expires_in.count())}, + HASH_EXPIRES_IN); + // RFC 8628 Section 3.2: the interval is emitted only when it is positive and + // differs from the default the client would otherwise assume, so a client + // never receives a zero or negative polling delay + if (interval > std::chrono::seconds{0} && interval != DEFAULT_INTERVAL) { + response.assign_assume_new( + "interval", JSON{static_cast(interval.count())}, + HASH_INTERVAL); + } + + return response; +} + +auto oauth_device_user_code() -> std::array { + std::array result{}; + const auto alphabet{static_cast(USER_CODE_ALPHABET.size())}; + // Reject bytes in the biased tail so each character is drawn uniformly from + // the alphabet, preserving the RFC 8628 Section 6.1 entropy of the code + const auto limit{static_cast(256U / alphabet * alphabet)}; + std::size_t filled{0}; + while (filled < result.size()) { + std::array entropy{}; + random_bytes(entropy); + for (const auto byte : entropy) { + if (byte >= limit) { + continue; + } + + result[filled] = USER_CODE_ALPHABET[byte % alphabet]; + filled += 1; + if (filled == result.size()) { + break; + } + } + } + + return result; +} + +auto oauth_device_user_code_matches(const std::string_view presented, + const std::string_view stored) -> bool { + const auto normalized_presented{normalize_user_code(presented)}; + const auto normalized_stored{normalize_user_code(stored)}; + // A code has content, so an empty normalized value on either side, from an + // empty or punctuation-only input, never matches, since secure_equals treats + // two empty strings as equal + if (normalized_presented.empty() || normalized_stored.empty()) { + return false; + } + + return secure_equals(normalized_presented, normalized_stored); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_dpop.cc b/vendor/core/src/core/oauth/oauth_dpop.cc new file mode 100644 index 00000000..367bcfb6 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_dpop.cc @@ -0,0 +1,433 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "oauth_json.h" +#include "oauth_syntax.h" + +#include // std::ranges::find, std::ranges::all_of, std::ranges::count_if +#include // std::array +#include // std::chrono::seconds, std::chrono::duration_cast +#include // std::int64_t, std::uint8_t +#include // std::scoped_lock +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move +#include // std::erase_if + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_TYP{JSON::Object::hash("typ"sv)}; +const auto HASH_ALG{JSON::Object::hash("alg"sv)}; +const auto HASH_JWK{JSON::Object::hash("jwk"sv)}; +const auto HASH_JTI{JSON::Object::hash("jti"sv)}; +const auto HASH_HTM{JSON::Object::hash("htm"sv)}; +const auto HASH_HTU{JSON::Object::hash("htu"sv)}; +const auto HASH_IAT{JSON::Object::hash("iat"sv)}; +const auto HASH_ATH{JSON::Object::hash("ath"sv)}; +const auto HASH_NONCE{JSON::Object::hash("nonce"sv)}; +const auto HASH_JKT{JSON::Object::hash("jkt"sv)}; + +// The private JSON Web Key members whose presence marks a private key (RFC 7518 +// Sections 6.2.2 and 6.3.2, RFC 8037 Section 2, RFC 7518 Section 6.4) +const auto HASH_D{JSON::Object::hash("d"sv)}; +const auto HASH_P{JSON::Object::hash("p"sv)}; +const auto HASH_Q{JSON::Object::hash("q"sv)}; +const auto HASH_DP{JSON::Object::hash("dp"sv)}; +const auto HASH_DQ{JSON::Object::hash("dq"sv)}; +const auto HASH_QI{JSON::Object::hash("qi"sv)}; +const auto HASH_OTH{JSON::Object::hash("oth"sv)}; +const auto HASH_K{JSON::Object::hash("k"sv)}; + +// RFC 9449 Section 4.2: the HTTP target URI without query and fragment, with +// the syntax and scheme normalization Section 4.3 recommends applied so the +// same request compares equal on both sides +auto dpop_normalize_target(const std::string_view url) + -> std::optional { + auto uri{oauth_try_parse_uri(url)}; + if (!uri.has_value()) { + return std::nullopt; + } + + // RFC 9449 Section 4.3 recommends the syntax and scheme normalization of + // RFC 3986 Sections 6.2.2 and 6.2.3, and Section 4.2 excludes the query and + // fragment. RFC 9110 Section 4.2.1 gives an "http" or "https" target URI no + // userinfo, so it is dropped rather than signed into the proof or compared + uri->canonicalize(); + uri->query(""); + uri->userinfo(""); + if (!uri->scheme().has_value() || !uri->host().has_value()) { + return std::nullopt; + } + + // RFC 3986 Section 6.2.3: an authority with an empty path is equivalent to + // one with a path of "/", a scheme-based normalization the general + // canonicalizer leaves to the caller + if (!uri->path().has_value() || uri->path().value().empty()) { + uri->path(std::string{"/"}); + } + + return uri->recompose_without_fragment(); +} + +// RFC 9449 Section 4.2 and Section 7: the base64url-encoded SHA-256 hash of the +// access token, the value bound into a proof through the hash claim +auto dpop_access_token_hash(const std::string_view access_token) + -> std::string { + const auto digest{sha256_digest(access_token)}; + return base64url_encode(std::string_view{ + reinterpret_cast(digest.data()), digest.size()}); +} + +// RFC 9449 Section 4.3 check 7: the public key MUST NOT contain a private key, +// whose presence is marked by any private JSON Web Key member +auto dpop_has_private_material(const JSON &key) -> bool { + return key.try_at("d"sv, HASH_D) != nullptr || + key.try_at("p"sv, HASH_P) != nullptr || + key.try_at("q"sv, HASH_Q) != nullptr || + key.try_at("dp"sv, HASH_DP) != nullptr || + key.try_at("dq"sv, HASH_DQ) != nullptr || + key.try_at("qi"sv, HASH_QI) != nullptr || + key.try_at("oth"sv, HASH_OTH) != nullptr || + key.try_at("k"sv, HASH_K) != nullptr; +} + +} // namespace + +OAuthDPoPProofer::OAuthDPoPProofer(JWKPrivate key, const JWSAlgorithm algorithm) + : key_{std::move(key)}, algorithm_{algorithm} {} + +auto OAuthDPoPProofer::proof(const std::string_view server, + const std::string_view method, + const std::string_view url, + const std::string_view access_token, + const std::chrono::system_clock::time_point now, + std::string &sink) -> bool { + // RFC 9449 Section 4.2: a DPoP proof MUST be signed with an asymmetric + // algorithm, so a symmetric one yields no proof rather than an invalid one + if (!jws_algorithm_is_asymmetric(this->algorithm_)) { + return false; + } + + auto target{dpop_normalize_target(url)}; + if (!target.has_value()) { + return false; + } + + auto public_key{this->key_.public_jwk()}; + if (!public_key.has_value()) { + return false; + } + + auto header{JSON::make_object()}; + header.assign_assume_new("typ", JSON{"dpop+jwt"}, HASH_TYP); + header.assign_assume_new("alg", JSON{jws_algorithm_name(this->algorithm_)}, + HASH_ALG); + header.assign_assume_new("jwk", std::move(public_key.value()), HASH_JWK); + + auto payload{JSON::make_object()}; + const auto identifier{oauth_random_token()}; + payload.assign_assume_new( + "jti", JSON{std::string_view{identifier.data(), identifier.size()}}, + HASH_JTI); + payload.assign_assume_new("htm", JSON{method}, HASH_HTM); + payload.assign_assume_new("htu", JSON{std::move(target.value())}, HASH_HTU); + payload.assign_assume_new( + "iat", + JSON{static_cast( + std::chrono::duration_cast( + now.time_since_epoch()) + .count())}, + HASH_IAT); + if (!access_token.empty()) { + payload.assign_assume_new("ath", JSON{dpop_access_token_hash(access_token)}, + HASH_ATH); + } + + { + const std::scoped_lock lock{this->mutex_}; + const auto iterator{this->nonces_.find(server)}; + if (iterator != this->nonces_.end()) { + payload.assign_assume_new("nonce", JSON{iterator->second}, HASH_NONCE); + } + } + + const auto token{jwt_sign(header, payload, this->key_)}; + if (!token.has_value()) { + return false; + } + + sink.append(token.value()); + return true; +} + +auto OAuthDPoPProofer::observe(const std::string_view server, + const std::string_view nonce) -> void { + // RFC 9449 Section 8.1: a nonce is a non-empty string of the nonce character + // set, so a malformed one is dropped rather than echoed into a later proof + if (!oauth_is_valid_dpop_nonce(nonce)) { + return; + } + + const std::scoped_lock lock{this->mutex_}; + this->nonces_.insert_or_assign(std::string{server}, std::string{nonce}); +} + +auto OAuthDPoPProofer::thumbprint() const -> std::optional { + return this->key_.thumbprint(); +} + +auto oauth_dpop_confirmation(const std::string_view thumbprint) -> JSON { + auto confirmation{JSON::make_object()}; + confirmation.assign_assume_new("jkt", JSON{thumbprint}, HASH_JKT); + return confirmation; +} + +auto oauth_dpop_proof_thumbprint(const std::string_view proof) + -> std::optional { + const auto token{JWT::from(proof)}; + if (!token.has_value()) { + return std::nullopt; + } + + const auto &header{token.value().header()}; + if (!header.is_object()) { + return std::nullopt; + } + + const auto *key{header.try_at("jwk"sv, HASH_JWK)}; + if (key == nullptr || !key->is_object()) { + return std::nullopt; + } + + // RFC 9449 Section 4.3 check 7: a proof whose embedded key carries private + // material is invalid, so no thumbprint is derived to bind or match against + if (dpop_has_private_material(*key)) { + return std::nullopt; + } + + const auto parsed{JWK::from(*key)}; + if (!parsed.has_value()) { + return std::nullopt; + } + + return parsed.value().thumbprint(); +} + +auto oauth_dpop_verify(const std::string_view proof, + const std::string_view method, + const std::string_view url, + const std::chrono::system_clock::time_point now, + const OAuthDPoPVerifyOptions &options) + -> std::optional { + // RFC 9449 Section 4.3 check 1: exactly one DPoP header field + if (options.proof_count != 1) { + return OAuthDPoPError::ProofCount; + } + + // Check 2: a single well-formed JSON Web Token + const auto token{JWT::from(proof)}; + if (!token.has_value()) { + return OAuthDPoPError::Malformed; + } + + const auto &parsed{token.value()}; + + // Check 4: the token type is dpop+jwt + const auto type{parsed.type()}; + if (!type.has_value() || type.value() != "dpop+jwt") { + return OAuthDPoPError::UnexpectedType; + } + + // Check 5: an asymmetric algorithm, not none, within local policy + const auto algorithm{parsed.algorithm()}; + if (!algorithm.has_value() || + !jws_algorithm_is_asymmetric(algorithm.value())) { + return OAuthDPoPError::UnsupportedAlgorithm; + } + + if (!options.allowed_algorithms.empty() && + std::ranges::find(options.allowed_algorithms, algorithm.value()) == + options.allowed_algorithms.end()) { + return OAuthDPoPError::UnsupportedAlgorithm; + } + + // Check 3: all required header parameters and claims are present + const auto &header{parsed.header()}; + const auto *key{header.is_object() ? header.try_at("jwk"sv, HASH_JWK) + : nullptr}; + if (key == nullptr || !key->is_object()) { + return OAuthDPoPError::MissingClaim; + } + + const auto identifier{parsed.token_id()}; + const auto method_claim{ + oauth_json_string_member(parsed.payload(), "htm"sv, HASH_HTM)}; + const auto target_claim{ + oauth_json_string_member(parsed.payload(), "htu"sv, HASH_HTU)}; + const auto issued{parsed.issued_at()}; + if (!identifier.has_value() || !method_claim.has_value() || + !target_claim.has_value() || !issued.has_value()) { + return OAuthDPoPError::MissingClaim; + } + + // Check 7: the embedded key carries no private material + if (dpop_has_private_material(*key)) { + return OAuthDPoPError::PrivateKey; + } + + // Check 6: the signature verifies with the embedded key + const auto public_key{JWK::from(*key)}; + if (!public_key.has_value() || + !jwt_verify_signature(parsed, public_key.value())) { + return OAuthDPoPError::Signature; + } + + // Check 8: the method claim matches the request method + if (method_claim.value() != method) { + return OAuthDPoPError::MethodMismatch; + } + + // Check 9: the target claim matches the request target + const auto normalized_claim{dpop_normalize_target(target_claim.value())}; + const auto normalized_request{dpop_normalize_target(url)}; + if (!normalized_claim.has_value() || !normalized_request.has_value() || + normalized_claim.value() != normalized_request.value()) { + return OAuthDPoPError::TargetMismatch; + } + + // Check 10: the nonce claim matches the nonce the server issued + const auto nonce_claim{ + oauth_json_string_member(parsed.payload(), "nonce"sv, HASH_NONCE)}; + if (options.expected_nonce.has_value()) { + if (!nonce_claim.has_value()) { + return OAuthDPoPError::MissingNonce; + } + + if (!secure_equals(nonce_claim.value(), options.expected_nonce.value())) { + return OAuthDPoPError::NonceMismatch; + } + } + + // Check 11: the creation time is within the acceptable window + if (issued.value() < now - options.past_window || + issued.value() > now + options.future_window) { + return OAuthDPoPError::Expired; + } + + // Check 12: the access token hash and the key binding, when a token is + // presented + if (options.access_token.has_value()) { + const auto hash_claim{ + oauth_json_string_member(parsed.payload(), "ath"sv, HASH_ATH)}; + if (!hash_claim.has_value()) { + return OAuthDPoPError::AccessTokenMismatch; + } + + const auto expected{dpop_access_token_hash(options.access_token.value())}; + if (!secure_equals(hash_claim.value(), expected)) { + return OAuthDPoPError::AccessTokenMismatch; + } + + // RFC 9449 Section 7.1: a protected resource MUST confirm the proof key + // matches the key the token is bound to, so presenting a token without a + // binding to check against fails closed rather than skipping that + // confirmation + if (!options.bound_thumbprint.has_value()) { + return OAuthDPoPError::KeyMismatch; + } + } + + if (options.bound_thumbprint.has_value()) { + const auto proof_thumbprint{public_key.value().thumbprint()}; + if (!proof_thumbprint.has_value() || + !secure_equals(proof_thumbprint.value(), + options.bound_thumbprint.value())) { + return OAuthDPoPError::KeyMismatch; + } + } + + return std::nullopt; +} + +auto OAuthDPoPReplayStore::check_and_insert( + const std::string_view identifier, const std::string_view target, + const std::chrono::system_clock::time_point now, + const std::chrono::seconds window, const bool normalize_target) -> bool { + // RFC 9449 Section 11.1: the identifier is tracked in the context of the + // target, so the digest covers both and one store safely spans targets. A + // DPoP target is normalized the same way the verifier compares the htu claim + // (Section 4.3 check 9), so a replay to an equivalent but differently spelled + // target is still detected rather than admitted as a fresh identifier, while + // a target compared verbatim, such as an assertion audience, is keyed as is + const auto normalized{normalize_target ? dpop_normalize_target(target) + : std::nullopt}; + const std::string_view effective{ + normalized.has_value() ? std::string_view{normalized.value()} : target}; + std::string material; + material.reserve(identifier.size() + 1 + effective.size()); + material.append(identifier); + material.push_back('\0'); + material.append(effective); + const auto digest{sha256_digest(material)}; + + const std::scoped_lock lock{this->mutex_}; + std::erase_if(this->entries_, [now](const Entry &entry) -> bool { + return entry.expiry <= now; + }); + + for (const auto &existing : this->entries_) { + if (existing.digest == digest) { + return false; + } + } + + // RFC 9449 Section 11.1: once the expired entries are pruned every remaining + // one still guards against a replay, so a full store fails closed rather than + // evict a live entry. Evicting one would drop its guard and let that proof be + // replayed, so the new proof is rejected until a slot frees on expiry. The + // capacity bounds the memory an attacker minting distinct proofs can consume + if (this->entries_.size() >= this->capacity_) { + return false; + } + + this->entries_.push_back(Entry{.digest = digest, .expiry = now + window}); + return true; +} + +auto OAuthDPoPReplayStore::size( + const std::chrono::system_clock::time_point now) const -> std::size_t { + const std::scoped_lock lock{this->mutex_}; + return static_cast( + std::ranges::count_if(this->entries_, [now](const Entry &entry) -> bool { + return entry.expiry > now; + })); +} + +auto oauth_is_valid_dpop_nonce(const std::string_view value) noexcept -> bool { + if (value.empty()) { + return false; + } + + // RFC 6749 Appendix A: NQCHAR = %x21 / %x23-5B / %x5D-7E, the printable ASCII + // set excluding the space, the double quote, and the backslash + return std::ranges::all_of(value, [](const char character) -> bool { + const auto byte{static_cast(character)}; + return byte == 0x21 || (byte >= 0x23 && byte <= 0x5B) || + (byte >= 0x5D && byte <= 0x7E); + }); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_encode.h b/vendor/core/src/core/oauth/oauth_encode.h new file mode 100644 index 00000000..0903d6cd --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_encode.h @@ -0,0 +1,30 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ENCODE_H_ +#define SOURCEMETA_CORE_OAUTH_ENCODE_H_ + +#include + +#include // std::string_view + +namespace sourcemeta::core { + +// Append one "application/x-www-form-urlencoded" parameter to a body under +// construction, inserting the "&" separator only between parameters so that +// grant and client authentication builders compose into one buffer. The name +// and value are percent-encoded through the URI escaper into the sink, which is +// a wiping string for a body that carries secrets and an ordinary string +// otherwise +template +inline auto oauth_append_form_parameter(Sink &sink, const std::string_view name, + const std::string_view value) -> void { + if (!sink.empty() && sink.back() != '&') { + sink.push_back('&'); + } + + URI::escape(name, sink); + sink.push_back('='); + URI::escape(value, sink); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_error.cc b/vendor/core/src/core/oauth/oauth_error.cc new file mode 100644 index 00000000..05d4501d --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_error.cc @@ -0,0 +1,221 @@ +#include + +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthAuthorizationError::InvalidRequest: + return "invalid_request"; + case OAuthAuthorizationError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthAuthorizationError::AccessDenied: + return "access_denied"; + case OAuthAuthorizationError::UnsupportedResponseType: + return "unsupported_response_type"; + case OAuthAuthorizationError::InvalidScope: + return "invalid_scope"; + case OAuthAuthorizationError::ServerError: + return "server_error"; + case OAuthAuthorizationError::TemporarilyUnavailable: + return "temporarily_unavailable"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthTokenError error) noexcept + -> std::string_view { + switch (error) { + case OAuthTokenError::InvalidRequest: + return "invalid_request"; + case OAuthTokenError::InvalidClient: + return "invalid_client"; + case OAuthTokenError::InvalidGrant: + return "invalid_grant"; + case OAuthTokenError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthTokenError::UnsupportedGrantType: + return "unsupported_grant_type"; + case OAuthTokenError::InvalidScope: + return "invalid_scope"; + case OAuthTokenError::AuthorizationPending: + return "authorization_pending"; + case OAuthTokenError::SlowDown: + return "slow_down"; + case OAuthTokenError::AccessDenied: + return "access_denied"; + case OAuthTokenError::ExpiredToken: + return "expired_token"; + case OAuthTokenError::InvalidTarget: + return "invalid_target"; + case OAuthTokenError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthTokenError::UseDPoPNonce: + return "use_dpop_nonce"; + case OAuthTokenError::UnsupportedTokenType: + return "unsupported_token_type"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view { + switch (error) { + case OAuthBearerError::InvalidRequest: + return "invalid_request"; + case OAuthBearerError::InvalidToken: + return "invalid_token"; + case OAuthBearerError::InsufficientScope: + return "insufficient_scope"; + case OAuthBearerError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthBearerError::UseDPoPNonce: + return "use_dpop_nonce"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthRegistrationError::InvalidRedirectURI: + return "invalid_redirect_uri"; + case OAuthRegistrationError::InvalidClientMetadata: + return "invalid_client_metadata"; + case OAuthRegistrationError::InvalidSoftwareStatement: + return "invalid_software_statement"; + case OAuthRegistrationError::UnapprovedSoftwareStatement: + return "unapproved_software_statement"; + } + + std::unreachable(); +} + +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthAuthorizationError::InvalidRequest; + } else if (code == "unauthorized_client") { + return OAuthAuthorizationError::UnauthorizedClient; + } else if (code == "access_denied") { + return OAuthAuthorizationError::AccessDenied; + } else if (code == "unsupported_response_type") { + return OAuthAuthorizationError::UnsupportedResponseType; + } else if (code == "invalid_scope") { + return OAuthAuthorizationError::InvalidScope; + } else if (code == "server_error") { + return OAuthAuthorizationError::ServerError; + } else if (code == "temporarily_unavailable") { + return OAuthAuthorizationError::TemporarilyUnavailable; + } else { + return std::nullopt; + } +} + +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthTokenError::InvalidRequest; + } else if (code == "invalid_client") { + return OAuthTokenError::InvalidClient; + } else if (code == "invalid_grant") { + return OAuthTokenError::InvalidGrant; + } else if (code == "unauthorized_client") { + return OAuthTokenError::UnauthorizedClient; + } else if (code == "unsupported_grant_type") { + return OAuthTokenError::UnsupportedGrantType; + } else if (code == "invalid_scope") { + return OAuthTokenError::InvalidScope; + } else if (code == "authorization_pending") { + return OAuthTokenError::AuthorizationPending; + } else if (code == "slow_down") { + return OAuthTokenError::SlowDown; + } else if (code == "access_denied") { + return OAuthTokenError::AccessDenied; + } else if (code == "expired_token") { + return OAuthTokenError::ExpiredToken; + } else if (code == "invalid_target") { + return OAuthTokenError::InvalidTarget; + } else if (code == "invalid_dpop_proof") { + return OAuthTokenError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthTokenError::UseDPoPNonce; + } else if (code == "unsupported_token_type") { + return OAuthTokenError::UnsupportedTokenType; + } else { + return std::nullopt; + } +} + +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthBearerError::InvalidRequest; + } else if (code == "invalid_token") { + return OAuthBearerError::InvalidToken; + } else if (code == "insufficient_scope") { + return OAuthBearerError::InsufficientScope; + } else if (code == "invalid_dpop_proof") { + return OAuthBearerError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthBearerError::UseDPoPNonce; + } else { + return std::nullopt; + } +} + +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_redirect_uri") { + return OAuthRegistrationError::InvalidRedirectURI; + } else if (code == "invalid_client_metadata") { + return OAuthRegistrationError::InvalidClientMetadata; + } else if (code == "invalid_software_statement") { + return OAuthRegistrationError::InvalidSoftwareStatement; + } else if (code == "unapproved_software_statement") { + return OAuthRegistrationError::UnapprovedSoftwareStatement; + } else { + return std::nullopt; + } +} + +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> HTTPStatus { + // RFC 6749 Section 5.2: "If the client attempted to authenticate via the + // 'Authorization' request header field, the authorization server MUST respond + // with an HTTP 401 (Unauthorized) status code and include the + // WWW-Authenticate response header field" + if (error == OAuthTokenError::InvalidClient && authenticated_via_header) { + return HTTP_STATUS_UNAUTHORIZED; + } + + return HTTP_STATUS_BAD_REQUEST; +} + +auto oauth_bearer_error_status(const OAuthBearerError error) noexcept + -> HTTPStatus { + // RFC 6750 Section 3.1 SHOULD-level status mapping, with the DPoP resource + // codes accompanying a 401 (RFC 9449 Section 7) + switch (error) { + case OAuthBearerError::InvalidRequest: + return HTTP_STATUS_BAD_REQUEST; + case OAuthBearerError::InvalidToken: + case OAuthBearerError::InvalidDPoPProof: + case OAuthBearerError::UseDPoPNonce: + return HTTP_STATUS_UNAUTHORIZED; + case OAuthBearerError::InsufficientScope: + return HTTP_STATUS_FORBIDDEN; + } + + std::unreachable(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_introspection.cc b/vendor/core/src/core/oauth/oauth_introspection.cc new file mode 100644 index 00000000..285e8a9f --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_introspection.cc @@ -0,0 +1,120 @@ +#include + +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::chrono::seconds +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ACTIVE{JSON::Object::hash("active"sv)}; +const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +const auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; +const auto HASH_USERNAME{JSON::Object::hash("username"sv)}; +const auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; +const auto HASH_SUB{JSON::Object::hash("sub"sv)}; +const auto HASH_ISS{JSON::Object::hash("iss"sv)}; +const auto HASH_JTI{JSON::Object::hash("jti"sv)}; +const auto HASH_EXP{JSON::Object::hash("exp"sv)}; +const auto HASH_IAT{JSON::Object::hash("iat"sv)}; +const auto HASH_NBF{JSON::Object::hash("nbf"sv)}; + +} // namespace + +auto oauth_build_introspection_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "token", token); + if (!token_type_hint.empty()) { + oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + } +} + +OAuthIntrospectionResponse::OAuthIntrospectionResponse(const JSON &data) + : data_{&data}, active_{[&data]() -> bool { + if (!data.is_object()) { + return false; + } + + // RFC 7662 Section 2.2: active is a REQUIRED boolean, so a missing or + // non-boolean value is treated as inactive rather than trusted + const auto *member{data.try_at("active"sv, HASH_ACTIVE)}; + return member != nullptr && member->is_boolean() && + member->to_boolean(); + }()} {} + +auto OAuthIntrospectionResponse::active() const noexcept -> bool { + return this->active_; +} + +auto OAuthIntrospectionResponse::scope() const + -> std::optional { + return oauth_json_string_member(*this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthIntrospectionResponse::client_id() const + -> std::optional { + return oauth_json_string_member(*this->data_, "client_id"sv, HASH_CLIENT_ID); +} + +auto OAuthIntrospectionResponse::username() const + -> std::optional { + return oauth_json_string_member(*this->data_, "username"sv, HASH_USERNAME); +} + +auto OAuthIntrospectionResponse::token_type() const + -> std::optional { + return oauth_json_string_member(*this->data_, "token_type"sv, + HASH_TOKEN_TYPE); +} + +auto OAuthIntrospectionResponse::subject() const + -> std::optional { + return oauth_json_string_member(*this->data_, "sub"sv, HASH_SUB); +} + +auto OAuthIntrospectionResponse::issuer() const + -> std::optional { + return oauth_json_string_member(*this->data_, "iss"sv, HASH_ISS); +} + +auto OAuthIntrospectionResponse::jti() const + -> std::optional { + return oauth_json_string_member(*this->data_, "jti"sv, HASH_JTI); +} + +auto OAuthIntrospectionResponse::expiration() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "exp"sv, HASH_EXP); +} + +auto OAuthIntrospectionResponse::issued_at() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "iat"sv, HASH_IAT); +} + +auto OAuthIntrospectionResponse::not_before() const + -> std::optional { + return oauth_json_seconds_member(*this->data_, "nbf"sv, HASH_NBF); +} + +auto OAuthIntrospectionResponse::data() const -> const JSON & { + return *this->data_; +} + +auto oauth_make_introspection_inactive() -> JSON { + auto response{JSON::make_object()}; + response.assign_assume_new("active", JSON{false}, HASH_ACTIVE); + return response; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_json.h b/vendor/core/src/core/oauth/oauth_json.h new file mode 100644 index 00000000..d08f7b6b --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_json.h @@ -0,0 +1,55 @@ +#ifndef SOURCEMETA_CORE_OAUTH_JSON_H_ +#define SOURCEMETA_CORE_OAUTH_JSON_H_ + +#include + +#include // std::chrono::seconds +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +// Read a string member of a JSON object as a view into it, or no value when the +// object, the member, or its type is absent +inline auto oauth_json_string_member(const JSON &data, + const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + if (!data.is_object()) { + return std::nullopt; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +// Read an integer member of a JSON object as a duration in seconds, rejecting a +// negative value as malformed, and one past the range of the duration so a +// bad lifetime or interval cannot flow to a caller as a usable or narrowed +// duration +inline auto oauth_json_seconds_member(const JSON &data, + const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + if (!data.is_object()) { + return std::nullopt; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_integer() || member->to_integer() < 0 || + member->to_integer() > + std::numeric_limits::max()) { + return std::nullopt; + } + + return std::chrono::seconds{member->to_integer()}; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_metadata.cc b/vendor/core/src/core/oauth/oauth_metadata.cc new file mode 100644 index 00000000..ec1a9b87 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_metadata.cc @@ -0,0 +1,588 @@ +#include + +#include +#include +#include + +#include "oauth_syntax.h" + +#include // std::ranges::find +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ISSUER{JSON::Object::hash("issuer"sv)}; +const auto HASH_AUTHORIZATION_ENDPOINT{ + JSON::Object::hash("authorization_endpoint"sv)}; +const auto HASH_TOKEN_ENDPOINT{JSON::Object::hash("token_endpoint"sv)}; +const auto HASH_REGISTRATION_ENDPOINT{ + JSON::Object::hash("registration_endpoint"sv)}; +const auto HASH_PAR_ENDPOINT{ + JSON::Object::hash("pushed_authorization_request_endpoint"sv)}; +const auto HASH_REQUIRE_PAR{ + JSON::Object::hash("require_pushed_authorization_requests"sv)}; +const auto HASH_REVOCATION_ENDPOINT{ + JSON::Object::hash("revocation_endpoint"sv)}; +const auto HASH_INTROSPECTION_ENDPOINT{ + JSON::Object::hash("introspection_endpoint"sv)}; +const auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; +const auto HASH_RESPONSE_TYPES{ + JSON::Object::hash("response_types_supported"sv)}; +const auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types_supported"sv)}; +const auto HASH_CODE_CHALLENGE_METHODS{ + JSON::Object::hash("code_challenge_methods_supported"sv)}; +const auto HASH_TOKEN_AUTH_METHODS{ + JSON::Object::hash("token_endpoint_auth_methods_supported"sv)}; +const auto HASH_TOKEN_AUTH_ALGS{ + JSON::Object::hash("token_endpoint_auth_signing_alg_values_supported"sv)}; +const auto HASH_ISS_SUPPORTED{ + JSON::Object::hash("authorization_response_iss_parameter_supported"sv)}; +const auto HASH_RESOURCE{JSON::Object::hash("resource"sv)}; +const auto HASH_AUTHORIZATION_SERVERS{ + JSON::Object::hash("authorization_servers"sv)}; +const auto HASH_BEARER_METHODS{ + JSON::Object::hash("bearer_methods_supported"sv)}; +const auto HASH_SCOPES_SUPPORTED{JSON::Object::hash("scopes_supported"sv)}; +const auto HASH_DPOP_BOUND_REQUIRED{ + JSON::Object::hash("dpop_bound_access_tokens_required"sv)}; +const auto HASH_RESOURCE_SIGNING_ALGS{ + JSON::Object::hash("resource_signing_alg_values_supported"sv)}; + +auto string_member(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + if (!data.is_object()) { + return std::nullopt; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +auto array_member_contains(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash, + const JSON::StringView value) -> bool { + if (!data.is_object()) { + return false; + } + + const auto *member{data.try_at(name, hash)}; + return member != nullptr && member->is_array() && member->contains(value); +} + +auto validated_server_metadata(JSON &&data, const std::string_view issuer) + -> JSON { + if (!data.is_object()) { + throw OAuthMetadataParseError{}; + } + + const auto *issuer_member{data.try_at("issuer"sv, HASH_ISSUER)}; + if (issuer_member == nullptr || !issuer_member->is_string()) { + 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 + 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 + const auto *response_types{ + data.try_at("response_types_supported"sv, HASH_RESPONSE_TYPES)}; + if (response_types == nullptr || !response_types->is_array() || + response_types->empty()) { + throw OAuthMetadataParseError{}; + } + + // RFC 8414 Section 2: a signing algorithm list is REQUIRED alongside the JWT + // authentication methods, "none" MUST NOT appear in it, and Section 3.2 + // forbids a zero-element array, so an empty list is also invalid + if (array_member_contains(data, "token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS, "private_key_jwt") || + array_member_contains(data, "token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS, "client_secret_jwt")) { + const auto *algorithms{ + data.try_at("token_endpoint_auth_signing_alg_values_supported"sv, + HASH_TOKEN_AUTH_ALGS)}; + if (algorithms == nullptr || !algorithms->is_array() || + algorithms->empty() || algorithms->contains("none")) { + throw OAuthMetadataParseError{}; + } + } + + return std::move(data); +} + +auto validated_resource_metadata(JSON &&data, const std::string_view resource) + -> JSON { + if (!data.is_object()) { + throw OAuthMetadataParseError{}; + } + + const auto *resource_member{data.try_at("resource"sv, HASH_RESOURCE)}; + if (resource_member == nullptr || !resource_member->is_string()) { + 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 + 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: "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{}; + } + + return std::move(data); +} + +} // namespace + +auto oauth_well_known_url(const std::string_view identifier, + const OAuthWellKnownKind kind, std::string &sink) + -> bool { + static constexpr std::string_view scheme{"https://"}; + // Reject a missing scheme or a fragment, then require a non-empty host so an + // authority such as ":443" with no host is not accepted (RFC 3986 + // Section 3.2) + if (!identifier.starts_with(scheme) || + identifier.find('#') != std::string_view::npos) { + return false; + } + + const auto parsed{oauth_try_parse_uri(identifier)}; + if (!parsed.has_value() || !parsed->host().has_value() || + parsed->host().value().empty()) { + return false; + } + + const bool protected_resource{kind == OAuthWellKnownKind::ProtectedResource}; + const auto query_position{identifier.find('?')}; + // RFC 8414 Section 2: an issuer carries no query, unlike a protected resource + if (query_position != std::string_view::npos && !protected_resource) { + return false; + } + + std::string_view suffix; + switch (kind) { + case OAuthWellKnownKind::AuthorizationServer: + suffix = "oauth-authorization-server"; + break; + case OAuthWellKnownKind::ProtectedResource: + suffix = "oauth-protected-resource"; + break; + case OAuthWellKnownKind::OpenIDConfigurationInserted: + case OAuthWellKnownKind::OpenIDConfigurationAppended: + suffix = "openid-configuration"; + break; + } + + static constexpr std::string_view infix{"/.well-known/"}; + if (kind == OAuthWellKnownKind::OpenIDConfigurationAppended) { + // RFC 8414 Section 5: the legacy OpenID Connect form appends the well-known + // string after the path rather than inserting it + auto base{identifier}; + if (base.ends_with('/')) { + base.remove_suffix(1); + } + + sink.reserve(sink.size() + base.size() + infix.size() + suffix.size()); + sink.append(base); + sink.append(infix); + sink.append(suffix); + return true; + } + + std::string_view query; + auto without_query{identifier}; + if (query_position != std::string_view::npos) { + query = identifier.substr(query_position); + without_query = identifier.substr(0, query_position); + } + + // RFC 8414 Section 3.1: the well-known string is inserted between the host + // and the path, with any terminating slash on the path removed first + auto authority{without_query}; + std::string_view path; + const auto path_position{without_query.find('/', scheme.size())}; + if (path_position != std::string_view::npos) { + authority = without_query.substr(0, path_position); + path = without_query.substr(path_position); + } + + if (path.ends_with('/')) { + path.remove_suffix(1); + } + + sink.reserve(sink.size() + authority.size() + infix.size() + suffix.size() + + path.size() + query.size()); + sink.append(authority); + sink.append(infix); + sink.append(suffix); + sink.append(path); + sink.append(query); + return true; +} + +OAuthServerMetadata::OAuthServerMetadata(JSON &&data, + const std::string_view issuer) + : data_{validated_server_metadata(std::move(data), issuer)} {} + +auto OAuthServerMetadata::from(JSON &&data, const std::string_view issuer) + -> std::optional { + try { + return OAuthServerMetadata{std::move(data), issuer}; + } catch (const OAuthMetadataParseError &) { + return std::nullopt; + } +} + +auto OAuthServerMetadata::issuer() const -> std::string_view { + return string_member(this->data_, "issuer"sv, HASH_ISSUER).value(); +} + +auto OAuthServerMetadata::authorization_endpoint() const + -> std::optional { + return string_member(this->data_, "authorization_endpoint"sv, + HASH_AUTHORIZATION_ENDPOINT); +} + +auto OAuthServerMetadata::token_endpoint() const + -> std::optional { + return string_member(this->data_, "token_endpoint"sv, HASH_TOKEN_ENDPOINT); +} + +auto OAuthServerMetadata::registration_endpoint() const + -> std::optional { + return string_member(this->data_, "registration_endpoint"sv, + HASH_REGISTRATION_ENDPOINT); +} + +auto OAuthServerMetadata::revocation_endpoint() const + -> std::optional { + return string_member(this->data_, "revocation_endpoint"sv, + HASH_REVOCATION_ENDPOINT); +} + +auto OAuthServerMetadata::introspection_endpoint() const + -> std::optional { + return string_member(this->data_, "introspection_endpoint"sv, + HASH_INTROSPECTION_ENDPOINT); +} + +auto OAuthServerMetadata::jwks_uri() const -> std::optional { + return string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +auto OAuthServerMetadata::pushed_authorization_request_endpoint() const + -> std::optional { + return string_member(this->data_, "pushed_authorization_request_endpoint"sv, + HASH_PAR_ENDPOINT); +} + +auto OAuthServerMetadata::require_pushed_authorization_requests() const + -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at( + "require_pushed_authorization_requests"sv, HASH_REQUIRE_PAR)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OAuthServerMetadata::authorization_response_iss_parameter_supported() const + -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at( + "authorization_response_iss_parameter_supported"sv, HASH_ISS_SUPPORTED)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OAuthServerMetadata::supports_response_type( + const std::string_view value) const -> bool { + return array_member_contains(this->data_, "response_types_supported"sv, + HASH_RESPONSE_TYPES, value); +} + +auto OAuthServerMetadata::supports_grant_type( + const std::string_view value) const -> bool { + const auto *member{ + this->data_.is_object() + ? this->data_.try_at("grant_types_supported"sv, HASH_GRANT_TYPES) + : nullptr}; + if (member == nullptr || !member->is_array()) { + // RFC 8414 Section 2: the default is the authorization code and implicit + // grants + return value == "authorization_code" || value == "implicit"; + } + + return member->contains(value); +} + +auto OAuthServerMetadata::supports_code_challenge_method( + const std::string_view value) const -> bool { + // RFC 8414 Section 2: an omitted list means PKCE is not supported, so there + // is no default to fall back to + return array_member_contains(this->data_, + "code_challenge_methods_supported"sv, + HASH_CODE_CHALLENGE_METHODS, value); +} + +auto OAuthServerMetadata::supports_token_endpoint_auth_method( + const std::string_view value) const -> bool { + const auto *member{ + this->data_.is_object() + ? this->data_.try_at("token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS) + : nullptr}; + if (member == nullptr || !member->is_array()) { + // RFC 8414 Section 2: the default is client_secret_basic + return value == "client_secret_basic"; + } + + return member->contains(value); +} + +auto OAuthServerMetadata::data() const -> const JSON & { return this->data_; } + +OAuthResourceMetadata::OAuthResourceMetadata(JSON &&data, + const std::string_view resource) + : data_{validated_resource_metadata(std::move(data), resource)} {} + +auto OAuthResourceMetadata::from(JSON &&data, const std::string_view resource) + -> std::optional { + try { + return OAuthResourceMetadata{std::move(data), resource}; + } catch (const OAuthMetadataParseError &) { + return std::nullopt; + } +} + +auto OAuthResourceMetadata::resource() const -> std::string_view { + return string_member(this->data_, "resource"sv, HASH_RESOURCE).value(); +} + +auto OAuthResourceMetadata::first_authorization_server() const + -> std::optional { + if (!this->data_.is_object()) { + return std::nullopt; + } + + const auto *member{this->data_.try_at("authorization_servers"sv, + HASH_AUTHORIZATION_SERVERS)}; + if (member == nullptr || !member->is_array()) { + return std::nullopt; + } + + for (const auto &element : member->as_array()) { + if (element.is_string()) { + return std::string_view{element.to_string()}; + } + } + + return std::nullopt; +} + +auto OAuthResourceMetadata::supports_authorization_server( + const std::string_view value) const -> bool { + return array_member_contains(this->data_, "authorization_servers"sv, + HASH_AUTHORIZATION_SERVERS, value); +} + +auto OAuthResourceMetadata::jwks_uri() const + -> std::optional { + return string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +auto OAuthResourceMetadata::supports_bearer_method( + const std::string_view value) const -> bool { + return array_member_contains(this->data_, "bearer_methods_supported"sv, + HASH_BEARER_METHODS, value); +} + +auto OAuthResourceMetadata::supports_scope(const std::string_view value) const + -> bool { + return array_member_contains(this->data_, "scopes_supported"sv, + HASH_SCOPES_SUPPORTED, value); +} + +auto OAuthResourceMetadata::dpop_bound_access_tokens_required() const -> bool { + if (!this->data_.is_object()) { + return false; + } + + const auto *member{this->data_.try_at("dpop_bound_access_tokens_required"sv, + HASH_DPOP_BOUND_REQUIRED)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OAuthResourceMetadata::data() const -> const JSON & { return this->data_; } + +namespace { + +auto assign_scalar_member(JSON &object, const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::string_view value) -> void { + if (!value.empty()) { + object.assign_assume_new(std::string{name}, JSON{value}, hash); + } +} + +auto assign_array_member(JSON &object, const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::span values) + -> void { + // RFC 8414 Section 3.2: "Claims with zero elements MUST be omitted", so an + // empty list is not emitted + if (values.empty()) { + return; + } + + auto array{JSON::make_array()}; + for (const auto value : values) { + array.push_back(JSON{value}); + } + + object.assign_assume_new(std::string{name}, std::move(array), hash); +} + +auto span_contains(const std::span values, + const std::string_view target) -> bool { + return std::ranges::find(values, target) != values.end(); +} + +} // 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()) { + 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 + 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"; + })}; + 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_required_and_valid || + (!config.registration_endpoint.empty() && + !oauth_is_resource_identifier(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))) { + return std::nullopt; + } + + // RFC 9126 Section 5: requiring pushed authorization requests without + // advertising the endpoint to submit them to yields a document a client + // cannot comply with + if (config.require_pushed_authorization_requests && + config.pushed_authorization_request_endpoint.empty()) { + return std::nullopt; + } + + // RFC 8414 Section 2: "none" MUST NOT appear in the signing algorithm list + // unconditionally, the list is REQUIRED alongside the JWT authentication + // methods, and Section 3.2 forbids a zero-element array, so the document + // would otherwise be rejected by its own parser + if (span_contains(config.token_endpoint_auth_signing_alg_values_supported, + "none")) { + return std::nullopt; + } + + if ((span_contains(config.token_endpoint_auth_methods_supported, + "private_key_jwt") || + span_contains(config.token_endpoint_auth_methods_supported, + "client_secret_jwt")) && + config.token_endpoint_auth_signing_alg_values_supported.empty()) { + return std::nullopt; + } + + auto document{JSON::make_object()}; + document.assign_assume_new("issuer", JSON{config.issuer}, HASH_ISSUER); + assign_scalar_member(document, "authorization_endpoint", + HASH_AUTHORIZATION_ENDPOINT, + config.authorization_endpoint); + assign_scalar_member(document, "token_endpoint", HASH_TOKEN_ENDPOINT, + config.token_endpoint); + assign_scalar_member(document, "registration_endpoint", + HASH_REGISTRATION_ENDPOINT, + config.registration_endpoint); + assign_scalar_member(document, "pushed_authorization_request_endpoint", + HASH_PAR_ENDPOINT, + config.pushed_authorization_request_endpoint); + assign_scalar_member(document, "jwks_uri", HASH_JWKS_URI, config.jwks_uri); + assign_array_member(document, "response_types_supported", HASH_RESPONSE_TYPES, + config.response_types_supported); + assign_array_member(document, "grant_types_supported", HASH_GRANT_TYPES, + config.grant_types_supported); + assign_array_member(document, "code_challenge_methods_supported", + HASH_CODE_CHALLENGE_METHODS, + config.code_challenge_methods_supported); + assign_array_member(document, "token_endpoint_auth_methods_supported", + HASH_TOKEN_AUTH_METHODS, + config.token_endpoint_auth_methods_supported); + assign_array_member(document, + "token_endpoint_auth_signing_alg_values_supported", + HASH_TOKEN_AUTH_ALGS, + config.token_endpoint_auth_signing_alg_values_supported); + assign_array_member(document, "scopes_supported", HASH_SCOPES_SUPPORTED, + config.scopes_supported); + // 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) { + document.assign_assume_new("require_pushed_authorization_requests", + JSON{true}, HASH_REQUIRE_PAR); + } + + return document; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_metadata_provider.cc b/vendor/core/src/core/oauth/oauth_metadata_provider.cc new file mode 100644 index 00000000..fe8aea78 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_metadata_provider.cc @@ -0,0 +1,209 @@ +#include + +#include +#include + +#include "oauth_ttl.h" + +#include // assert +#include // std::chrono::seconds, std::chrono::system_clock +#include // std::make_shared, std::shared_ptr +#include // std::scoped_lock +#include // std::optional +#include // std::string +#include // std::move + +namespace sourcemeta::core { + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher) + : OAuthMetadataProvider{std::move(issuer), kind, std::move(fetcher), + Options{}} {} + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher, Options options) + : OAuthMetadataProvider{std::move(issuer), kind, std::move(fetcher), + options, Clock{&std::chrono::system_clock::now}} {} + +OAuthMetadataProvider::OAuthMetadataProvider(std::string issuer, + const OAuthWellKnownKind kind, + Fetcher fetcher, Options options, + Clock clock) + : issuer_{std::move(issuer)}, kind_{kind}, fetcher_{std::move(fetcher)}, + options_{options}, clock_{std::move(clock)} { + // A protected resource document is not authorization server metadata, so it + // would never validate and the provider would permanently return no value + assert(kind != OAuthWellKnownKind::ProtectedResource); +} + +auto OAuthMetadataProvider::install_locked( + const OAuthWellKnownKind kind, + const std::chrono::system_clock::time_point now) -> bool { + std::string url; + if (!oauth_well_known_url(this->issuer_, kind, url)) { + return false; + } + + std::optional fetched; + try { + fetched = this->fetcher_(url); + } catch (...) { + // A fetcher signals failure by returning no value, but a throwing transport + // is treated as just another failed retrieval, so a misbehaving fetcher can + // never escape a metadata lookup + return false; + } + + if (!fetched.has_value()) { + return false; + } + + auto document{try_parse_json(fetched.value().body)}; + if (!document.has_value()) { + return false; + } + + auto parsed{ + OAuthServerMetadata::from(std::move(document).value(), this->issuer_)}; + if (!parsed.has_value()) { + return false; + } + + this->metadata_ = + std::make_shared(std::move(parsed).value()); + this->next_refresh_ = now + oauth_clamp_ttl(fetched.value().max_age, + this->options_.fallback_ttl, + this->options_.minimum_ttl, + this->options_.maximum_ttl); + return true; +} + +auto OAuthMetadataProvider::fetch_and_install_locked( + const std::chrono::system_clock::time_point now) -> bool { + if (this->install_locked(this->kind_, now)) { + return true; + } + + // RFC 8414 Section 5: the inserted openid-configuration form is tried first + // and the legacy appended form only as a fallback + if (this->kind_ == OAuthWellKnownKind::OpenIDConfigurationInserted) { + return this->install_locked(OAuthWellKnownKind::OpenIDConfigurationAppended, + now); + } + + return false; +} + +auto OAuthMetadataProvider::metadata() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + // The refresh deadline starts at the epoch, so the first call always + // retrieves. Gating solely on it, rather than also on an absent document, + // means a failed retrieval backs off for the minimum lifetime instead of + // being retried on every call during an outage, whether or not a prior good + // document exists to serve meanwhile + if (now >= this->next_refresh_) { + if (!this->fetch_and_install_locked(now)) { + this->next_refresh_ = now + this->options_.minimum_ttl; + } + } + + return this->metadata_; +} + +auto OAuthMetadataProvider::refresh() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + this->fetch_and_install_locked(now); + return this->metadata_; +} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher) + : OAuthResourceMetadataProvider{std::move(resource), std::move(fetcher), + Options{}} {} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher, Options options) + : OAuthResourceMetadataProvider{std::move(resource), std::move(fetcher), + options, + Clock{&std::chrono::system_clock::now}} {} + +OAuthResourceMetadataProvider::OAuthResourceMetadataProvider( + std::string resource, Fetcher fetcher, Options options, Clock clock) + : resource_{std::move(resource)}, fetcher_{std::move(fetcher)}, + options_{options}, clock_{std::move(clock)} {} + +auto OAuthResourceMetadataProvider::fetch_and_install_locked( + const std::chrono::system_clock::time_point now) -> bool { + std::string url; + if (!oauth_well_known_url(this->resource_, + OAuthWellKnownKind::ProtectedResource, url)) { + return false; + } + + std::optional fetched; + try { + fetched = this->fetcher_(url); + } catch (...) { + // A fetcher signals failure by returning no value, but a throwing transport + // is treated as just another failed retrieval, so a misbehaving fetcher can + // never escape a metadata lookup + return false; + } + + if (!fetched.has_value()) { + return false; + } + + auto document{try_parse_json(fetched.value().body)}; + if (!document.has_value()) { + return false; + } + + auto parsed{OAuthResourceMetadata::from(std::move(document).value(), + this->resource_)}; + if (!parsed.has_value()) { + return false; + } + + this->metadata_ = + std::make_shared(std::move(parsed).value()); + this->next_refresh_ = now + oauth_clamp_ttl(fetched.value().max_age, + this->options_.fallback_ttl, + this->options_.minimum_ttl, + this->options_.maximum_ttl); + return true; +} + +auto OAuthResourceMetadataProvider::metadata() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + // The refresh deadline starts at the epoch, so the first call always + // retrieves. Gating solely on it, rather than also on an absent document, + // means a failed retrieval backs off for the minimum lifetime instead of + // being retried on every call during an outage, whether or not a prior good + // document exists to serve meanwhile + if (now >= this->next_refresh_) { + if (!this->fetch_and_install_locked(now)) { + this->next_refresh_ = now + this->options_.minimum_ttl; + } + } + + return this->metadata_; +} + +auto OAuthResourceMetadataProvider::refresh() + -> std::shared_ptr { + const auto now{this->clock_()}; + const std::scoped_lock lock{this->mutex_}; + this->fetch_and_install_locked(now); + return this->metadata_; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_par.cc b/vendor/core/src/core/oauth/oauth_par.cc new file mode 100644 index 00000000..3d9a4bd5 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_par.cc @@ -0,0 +1,204 @@ +#include + +#include +#include +#include +#include +#include + +#include "oauth_authorization_parse.h" +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::chrono::seconds +#include // std::int64_t +#include // std::function +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_REQUEST_URI{JSON::Object::hash("request_uri"sv)}; +const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; + +// RFC 9126 Section 2.2: the recommended request URI form, a URN whose final +// segment is the random reference +constexpr std::string_view REQUEST_URI_PREFIX{ + "urn:ietf:params:oauth:request_uri:"}; + +} // namespace + +auto oauth_build_par_request(const OAuthAuthorizationRequest &request, + SecureString &sink) -> void { + // RFC 6749 Section 4.1.1: the authorization code flow is the response type + // this builder assumes when the request names none, and the client may + // override it + oauth_append_form_parameter(sink, "response_type", + request.response_type.empty() + ? std::string_view{"code"} + : request.response_type); + if (!request.redirect_uri.empty()) { + oauth_append_form_parameter(sink, "redirect_uri", request.redirect_uri); + } + + if (!request.scope.empty()) { + oauth_append_form_parameter(sink, "scope", request.scope); + } + + if (!request.state.empty()) { + oauth_append_form_parameter(sink, "state", request.state); + } + + if (!request.code_challenge.empty()) { + oauth_append_form_parameter(sink, "code_challenge", request.code_challenge); + // RFC 7636 Section 4.3: the method qualifies a challenge, so it is omitted + // when no challenge is present + if (!request.code_challenge_method.empty()) { + oauth_append_form_parameter(sink, "code_challenge_method", + request.code_challenge_method); + } + } + + if (!request.dpop_jkt.empty()) { + oauth_append_form_parameter(sink, "dpop_jkt", request.dpop_jkt); + } + + // RFC 9126 Section 2.1: a pushed request MUST NOT provide request_uri, so it + // is dropped even when a caller routes it through the repeatable or extension + // parameters rather than the dedicated field + for (const auto &resource : request.resources) { + if (resource.name != "request_uri") { + oauth_append_form_parameter(sink, resource.name, resource.value); + } + } + + for (const auto ¶meter : request.extra) { + if (parameter.name != "request_uri") { + oauth_append_form_parameter(sink, parameter.name, parameter.value); + } + } +} + +auto oauth_build_par_authorization_url(const std::string_view endpoint, + const std::string_view client_id, + const std::string_view request_uri, + std::string &sink) -> void { + // A lower bound over the endpoint, the two keys and separators, and the raw + // value lengths, over which percent-escaping only ever grows + sink.reserve(sink.size() + endpoint.size() + client_id.size() + + request_uri.size() + 128); + sink.append(endpoint); + // The separator before the first parameter: '?' opens a query the endpoint + // lacks, nothing when the endpoint already ends its query, and '&' continues + // one (RFC 6749 Section 3.1) + char separator{'?'}; + if (endpoint.find('?') != std::string_view::npos) { + separator = + (endpoint.empty() || endpoint.back() == '?' || endpoint.back() == '&') + ? '\0' + : '&'; + } + + if (separator != '\0') { + sink.push_back(separator); + } + + sink.append("client_id="); + URI::escape(client_id, sink); + sink.append("&request_uri="); + URI::escape(request_uri, sink); +} + +OAuthPARResponse::OAuthPARResponse(const JSON &data) : data_{&data} {} + +auto OAuthPARResponse::request_uri() const -> std::optional { + return oauth_json_string_member(*this->data_, "request_uri"sv, + HASH_REQUEST_URI); +} + +auto OAuthPARResponse::expires_in() const + -> std::optional { + // RFC 9126 Section 2.2: expires_in is a positive integer, so a zero or + // negative value is malformed and reported as absent + const auto value{ + oauth_json_seconds_member(*this->data_, "expires_in"sv, HASH_EXPIRES_IN)}; + return (value.has_value() && value.value() > std::chrono::seconds{0}) + ? value + : std::nullopt; +} + +auto OAuthPARResponse::data() const -> const JSON & { return *this->data_; } + +auto oauth_parse_par_request( + const std::string_view body, SecureString &storage, + OAuthAuthorizationRequest &result, + const std::function &on_other) + -> bool { + // RFC 9126 Section 2.1: "The request_uri authorization request parameter is + // one exception, and it MUST NOT be provided", so the shared parse rejects it + // on presence, whatever its value, and the body decodes into wiping storage + // because it may carry a client secret + return oauth_parse_authorization_into(body, storage, result, on_other, true); +} + +auto oauth_par_request_uri() -> std::string { + const auto reference{oauth_random_token()}; + std::string result; + result.reserve(REQUEST_URI_PREFIX.size() + reference.size()); + result.append(REQUEST_URI_PREFIX); + result.append(reference.data(), reference.size()); + return result; +} + +auto oauth_make_par_response(const std::string_view request_uri, + const std::chrono::seconds expires_in) + -> std::optional { + // RFC 9126 Section 2.2: request_uri and a positive expires_in lifetime are + // REQUIRED, so a missing or non-positive one yields no document + if (request_uri.empty() || expires_in <= std::chrono::seconds{0}) { + return std::nullopt; + } + + auto response{JSON::make_object()}; + response.assign_assume_new("request_uri", JSON{request_uri}, + HASH_REQUEST_URI); + response.assign_assume_new( + "expires_in", JSON{static_cast(expires_in.count())}, + HASH_EXPIRES_IN); + return response; +} + +auto oauth_par_dpop_binding( + const std::string_view dpop_jkt, + const std::optional proof_thumbprint) + -> std::optional { + const bool has_jkt{!dpop_jkt.empty()}; + const bool has_proof{proof_thumbprint.has_value() && + !proof_thumbprint.value().empty()}; + if (has_jkt && has_proof) { + // RFC 9449 Section 10.1: with both mechanisms the request MUST be rejected + // when the thumbprint in dpop_jkt does not match the DPoP header key + if (!secure_equals(dpop_jkt, proof_thumbprint.value())) { + return std::nullopt; + } + + return dpop_jkt; + } + + if (has_jkt) { + return dpop_jkt; + } + + if (has_proof) { + return proof_thumbprint; + } + + return std::string_view{}; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_pkce.cc b/vendor/core/src/core/oauth/oauth_pkce.cc new file mode 100644 index 00000000..68b049e1 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_pkce.cc @@ -0,0 +1,126 @@ +#include + +#include + +#include "oauth_syntax.h" + +#include // std::ranges::copy +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view { + switch (method) { + case OAuthPKCEMethod::S256: + return "S256"; + case OAuthPKCEMethod::Plain: + return "plain"; + } + + std::unreachable(); +} + +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional { + if (value == "S256") { + return OAuthPKCEMethod::S256; + } else if (value == "plain") { + return OAuthPKCEMethod::Plain; + } else { + return std::nullopt; + } +} + +auto oauth_pkce_verifier() -> std::array { + // The verifier is the base64url of 32 random octets, so both the entropy and + // the encoded form are secret. The scope guard wipes the entropy on every + // exit, including an exception while encoding. The caller owns the returned + // value and is responsible for wiping it in turn + std::string entropy(32, '\x00'); + const SecureStringScope entropy_scope{entropy}; + random_bytes( + std::span{reinterpret_cast(entropy.data()), + entropy.size()}); // NOLINT + SecureString encoded; + base64url_encode(entropy, encoded); + + std::array result{}; + std::ranges::copy(std::string_view{encoded}, result.begin()); + return result; +} + +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array { + const auto digest{sha256_digest(verifier)}; + const auto encoded{base64url_encode( + std::string_view{reinterpret_cast(digest.data()), // NOLINT + digest.size()})}; + + std::array result{}; + std::ranges::copy(encoded, result.begin()); + return result; +} + +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome { + if (challenge.empty()) { + // RFC 9700 Section 2.1.1: a presented verifier with no bound challenge is + // rejected, since it signals a possible authorization code injection + return verifier.empty() ? OAuthPKCEOutcome::NotUsed + : OAuthPKCEOutcome::MissingChallenge; + } + + // OAuth 2.1 Section 4.1.3: iff the authorization request carried a challenge, + // the token request must carry a verifier + if (verifier.empty()) { + return OAuthPKCEOutcome::MissingVerifier; + } + + if (!oauth_is_pkce_verifier(verifier)) { + return OAuthPKCEOutcome::MalformedVerifier; + } + + switch (method) { + case OAuthPKCEMethod::Plain: + // RFC 9700 Section 2.1.1 hardening: the plain method is rejected under + // the strict profile + if (profile == OAuthProfile::Strict) { + return OAuthPKCEOutcome::MethodNotAllowed; + } + + if (!oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + // RFC 7636 Section 4.6: the plain method compares the two values directly + return secure_equals(verifier, challenge) ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + case OAuthPKCEMethod::S256: { + // RFC 7636 Section 4.2: an S256 challenge is the base64url of a SHA-256 + // digest, so it is always exactly 43 characters + if (challenge.size() != 43 || !oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + const auto computed{oauth_pkce_challenge(verifier)}; + return secure_equals(std::string_view{computed.data(), computed.size()}, + challenge) + ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + } + } + + std::unreachable(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_random.cc b/vendor/core/src/core/oauth/oauth_random.cc new file mode 100644 index 00000000..dcc32871 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_random.cc @@ -0,0 +1,26 @@ +#include + +#include + +#include // std::ranges::copy +#include // std::array +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +auto oauth_random_token() -> std::array { + // The token is not secret, so unlike the PKCE verifier the entropy needs no + // wiping storage, matching the state and nonce classification of the design + std::array entropy{}; + random_bytes(entropy); + const auto encoded{base64url_encode( + std::string_view{reinterpret_cast(entropy.data()), // NOLINT + entropy.size()})}; + + std::array result{}; + std::ranges::copy(encoded, result.begin()); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_registration.cc b/vendor/core/src/core/oauth/oauth_registration.cc new file mode 100644 index 00000000..18f4061c --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_registration.cc @@ -0,0 +1,565 @@ +#include + +#include +#include + +#include "oauth_json.h" +#include "oauth_syntax.h" + +#include // std::chrono::seconds +#include // std::int64_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_REDIRECT_URIS{JSON::Object::hash("redirect_uris"sv)}; +const auto HASH_TOKEN_ENDPOINT_AUTH_METHOD{ + JSON::Object::hash("token_endpoint_auth_method"sv)}; +const auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types"sv)}; +const auto HASH_RESPONSE_TYPES{JSON::Object::hash("response_types"sv)}; +const auto HASH_CLIENT_NAME{JSON::Object::hash("client_name"sv)}; +const auto HASH_CLIENT_URI{JSON::Object::hash("client_uri"sv)}; +const auto HASH_LOGO_URI{JSON::Object::hash("logo_uri"sv)}; +const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +const auto HASH_CONTACTS{JSON::Object::hash("contacts"sv)}; +const auto HASH_TOS_URI{JSON::Object::hash("tos_uri"sv)}; +const auto HASH_POLICY_URI{JSON::Object::hash("policy_uri"sv)}; +const auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; +const auto HASH_JWKS{JSON::Object::hash("jwks"sv)}; +const auto HASH_SOFTWARE_ID{JSON::Object::hash("software_id"sv)}; +const auto HASH_SOFTWARE_VERSION{JSON::Object::hash("software_version"sv)}; +const auto HASH_SOFTWARE_STATEMENT{JSON::Object::hash("software_statement"sv)}; +const auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; +const auto HASH_CLIENT_SECRET{JSON::Object::hash("client_secret"sv)}; +const auto HASH_CLIENT_ID_ISSUED_AT{ + JSON::Object::hash("client_id_issued_at"sv)}; +const auto HASH_CLIENT_SECRET_EXPIRES_AT{ + JSON::Object::hash("client_secret_expires_at"sv)}; +const auto HASH_REGISTRATION_ACCESS_TOKEN{ + JSON::Object::hash("registration_access_token"sv)}; +const auto HASH_REGISTRATION_CLIENT_URI{ + JSON::Object::hash("registration_client_uri"sv)}; +const auto HASH_ERROR{JSON::Object::hash("error"sv)}; +const auto HASH_ERROR_DESCRIPTION{JSON::Object::hash("error_description"sv)}; + +auto array_member_contains(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash, + const JSON::StringView value) -> bool { + if (!data.is_object()) { + return false; + } + + const auto *member{data.try_at(name, hash)}; + return member != nullptr && member->is_array() && member->contains(value); +} + +auto array_member_is_present(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> bool { + const auto *member{data.try_at(name, hash)}; + return member != nullptr && member->is_array(); +} + +auto is_string_array(const JSON &value) -> bool { + if (!value.is_array()) { + return false; + } + + for (const auto &element : value.as_array()) { + if (!element.is_string()) { + return false; + } + } + + return true; +} + +// A membership predicate that falls back to the specification default when the +// array is absent, so an omitted list is read as the default value rather than +// as an empty registration (RFC 7591 Section 2) +auto array_member_contains_or_default(const JSON &data, + const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::string_view value, + const std::string_view fallback) -> bool { + if (!data.is_object()) { + return value == fallback; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_array()) { + return value == fallback; + } + + return member->contains(value); +} + +auto validated_client_metadata(JSON &&data) -> JSON { + if (!data.is_object()) { + throw OAuthRegistrationParseError{}; + } + + // RFC 7591 Section 2: "The 'jwks_uri' and 'jwks' parameters MUST NOT both be + // present in the same request or response" + if (data.defines("jwks_uri"sv, HASH_JWKS_URI) && + data.defines("jwks"sv, HASH_JWKS)) { + throw OAuthRegistrationParseError{}; + } + + // RFC 7591 Section 2: the grant and response types are arrays of strings and + // the authentication method is a string. Each has a default accessor, so a + // member present with the wrong type, down to a non-string array element, is + // rejected here rather than silently read as its default or as fewer values + // than were registered + const auto *grant_types{data.try_at("grant_types"sv, HASH_GRANT_TYPES)}; + const auto *response_types{ + data.try_at("response_types"sv, HASH_RESPONSE_TYPES)}; + const auto *auth_method{data.try_at("token_endpoint_auth_method"sv, + HASH_TOKEN_ENDPOINT_AUTH_METHOD)}; + if ((grant_types != nullptr && !is_string_array(*grant_types)) || + (response_types != nullptr && !is_string_array(*response_types)) || + (auth_method != nullptr && !auth_method->is_string())) { + throw OAuthRegistrationParseError{}; + } + + return std::move(data); +} + +auto assign_scalar_member(JSON &object, const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::string_view value) -> void { + if (!value.empty()) { + object.assign_assume_new(std::string{name}, JSON{value}, hash); + } +} + +auto assign_array_member(JSON &object, const JSON::StringView name, + const JSON::Object::hash_type hash, + const std::span values) + -> void { + if (values.empty()) { + return; + } + + auto array{JSON::make_array()}; + for (const auto value : values) { + array.push_back(JSON{value}); + } + + object.assign_assume_new(std::string{name}, std::move(array), hash); +} + +// RFC 7519 Section 4.1: the registered claims describe the JSON Web Token that +// carries the software statement rather than the client, so they are not +// flattened onto the registration record as client metadata (RFC 7591 +// Section 3.2.1) +auto is_jwt_structural_claim(const std::string_view name) -> bool { + return name == "iss"sv || name == "sub"sv || name == "aud"sv || + name == "exp"sv || name == "nbf"sv || name == "iat"sv || + name == "jti"sv; +} + +} // namespace + +OAuthClientMetadata::OAuthClientMetadata(JSON &&data) + : data_{validated_client_metadata(std::move(data))} {} + +auto OAuthClientMetadata::from(JSON &&data) + -> std::optional { + try { + return OAuthClientMetadata{std::move(data)}; + } catch (const OAuthRegistrationParseError &) { + return std::nullopt; + } +} + +auto OAuthClientMetadata::has_redirect_uri(const std::string_view value) const + -> bool { + return array_member_contains(this->data_, "redirect_uris"sv, + HASH_REDIRECT_URIS, value); +} + +auto OAuthClientMetadata::token_endpoint_auth_method() const + -> std::string_view { + const auto method{oauth_json_string_member(this->data_, + "token_endpoint_auth_method"sv, + HASH_TOKEN_ENDPOINT_AUTH_METHOD)}; + // RFC 7591 Section 2: "If unspecified or omitted, the default is + // 'client_secret_basic'" + return method.value_or("client_secret_basic"sv); +} + +auto OAuthClientMetadata::supports_grant_type( + const std::string_view value) const -> bool { + // RFC 7591 Section 2: "If omitted, the default behavior is that the client + // will use only the 'authorization_code' Grant Type" + return array_member_contains_or_default(this->data_, "grant_types"sv, + HASH_GRANT_TYPES, value, + "authorization_code"sv); +} + +auto OAuthClientMetadata::supports_response_type( + const std::string_view value) const -> bool { + // RFC 7591 Section 2: "If omitted, the default is that the client will use + // only the 'code' response type" + return array_member_contains_or_default(this->data_, "response_types"sv, + HASH_RESPONSE_TYPES, value, "code"sv); +} + +auto OAuthClientMetadata::client_name() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_name"sv, + HASH_CLIENT_NAME); +} + +auto OAuthClientMetadata::client_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_uri"sv, HASH_CLIENT_URI); +} + +auto OAuthClientMetadata::logo_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "logo_uri"sv, HASH_LOGO_URI); +} + +auto OAuthClientMetadata::scope() const -> std::optional { + return oauth_json_string_member(this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthClientMetadata::has_contact(const std::string_view value) const + -> bool { + return array_member_contains(this->data_, "contacts"sv, HASH_CONTACTS, value); +} + +auto OAuthClientMetadata::tos_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "tos_uri"sv, HASH_TOS_URI); +} + +auto OAuthClientMetadata::policy_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "policy_uri"sv, HASH_POLICY_URI); +} + +auto OAuthClientMetadata::jwks_uri() const -> std::optional { + return oauth_json_string_member(this->data_, "jwks_uri"sv, HASH_JWKS_URI); +} + +auto OAuthClientMetadata::software_id() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_id"sv, + HASH_SOFTWARE_ID); +} + +auto OAuthClientMetadata::software_version() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_version"sv, + HASH_SOFTWARE_VERSION); +} + +auto OAuthClientMetadata::software_statement() const + -> std::optional { + return oauth_json_string_member(this->data_, "software_statement"sv, + HASH_SOFTWARE_STATEMENT); +} + +auto OAuthClientMetadata::client_id() const -> std::optional { + return oauth_json_string_member(this->data_, "client_id"sv, HASH_CLIENT_ID); +} + +auto OAuthClientMetadata::client_secret() const + -> std::optional { + return oauth_json_string_member(this->data_, "client_secret"sv, + HASH_CLIENT_SECRET); +} + +auto OAuthClientMetadata::client_id_issued_at() const + -> std::optional { + return oauth_json_seconds_member(this->data_, "client_id_issued_at"sv, + HASH_CLIENT_ID_ISSUED_AT); +} + +auto OAuthClientMetadata::client_secret_expires_at() const + -> std::optional { + return oauth_json_seconds_member(this->data_, "client_secret_expires_at"sv, + HASH_CLIENT_SECRET_EXPIRES_AT); +} + +auto OAuthClientMetadata::registration_access_token() const + -> std::optional { + return oauth_json_string_member(this->data_, "registration_access_token"sv, + HASH_REGISTRATION_ACCESS_TOKEN); +} + +auto OAuthClientMetadata::registration_client_uri() const + -> std::optional { + return oauth_json_string_member(this->data_, "registration_client_uri"sv, + HASH_REGISTRATION_CLIENT_URI); +} + +auto OAuthClientMetadata::data() const -> const JSON & { return this->data_; } + +auto oauth_make_registration_request( + const OAuthClientRegistrationConfig &config) -> std::optional { + // RFC 7591 Section 2: the client information and JWK Set locations MUST point + // to valid URIs, so a malformed one yields a request the server would reject + if ((!config.client_uri.empty() && + !oauth_try_parse_uri(config.client_uri).has_value()) || + (!config.logo_uri.empty() && + !oauth_try_parse_uri(config.logo_uri).has_value()) || + (!config.tos_uri.empty() && + !oauth_try_parse_uri(config.tos_uri).has_value()) || + (!config.policy_uri.empty() && + !oauth_try_parse_uri(config.policy_uri).has_value()) || + (!config.jwks_uri.empty() && + !oauth_try_parse_uri(config.jwks_uri).has_value())) { + return std::nullopt; + } + + // RFC 6749 Section 3.1.2: a redirection URI MUST be an absolute URI and MUST + // NOT include a fragment, so a malformed or fragment-bearing one yields a + // request the server would reject + for (const auto redirect_uri : config.redirect_uris) { + const auto parsed{oauth_try_parse_uri(redirect_uri)}; + if (!parsed.has_value() || parsed.value().fragment().has_value()) { + return std::nullopt; + } + } + + // RFC 7591 Section 2: the JWK Set is a JSON object, and it "MUST NOT" be + // present together with the JWK Set location + if (config.jwks != nullptr && + (!config.jwks->is_object() || !config.jwks_uri.empty())) { + return std::nullopt; + } + + auto document{JSON::make_object()}; + assign_array_member(document, "redirect_uris", HASH_REDIRECT_URIS, + config.redirect_uris); + if (config.jwks != nullptr) { + document.assign_assume_new(std::string{"jwks"}, JSON{*config.jwks}, + HASH_JWKS); + } + assign_scalar_member(document, "token_endpoint_auth_method", + HASH_TOKEN_ENDPOINT_AUTH_METHOD, + config.token_endpoint_auth_method); + assign_array_member(document, "grant_types", HASH_GRANT_TYPES, + config.grant_types); + assign_array_member(document, "response_types", HASH_RESPONSE_TYPES, + config.response_types); + assign_scalar_member(document, "client_name", HASH_CLIENT_NAME, + config.client_name); + assign_scalar_member(document, "client_uri", HASH_CLIENT_URI, + config.client_uri); + assign_scalar_member(document, "logo_uri", HASH_LOGO_URI, config.logo_uri); + assign_scalar_member(document, "scope", HASH_SCOPE, config.scope); + assign_array_member(document, "contacts", HASH_CONTACTS, config.contacts); + assign_scalar_member(document, "tos_uri", HASH_TOS_URI, config.tos_uri); + assign_scalar_member(document, "policy_uri", HASH_POLICY_URI, + config.policy_uri); + assign_scalar_member(document, "jwks_uri", HASH_JWKS_URI, config.jwks_uri); + assign_scalar_member(document, "software_id", HASH_SOFTWARE_ID, + config.software_id); + assign_scalar_member(document, "software_version", HASH_SOFTWARE_VERSION, + config.software_version); + assign_scalar_member(document, "software_statement", HASH_SOFTWARE_STATEMENT, + config.software_statement); + + return document; +} + +auto oauth_make_registration_error_response( + const std::string_view error, const std::string_view error_description) + -> JSON { + auto response{JSON::make_object()}; + // RFC 7591 Section 3.2.2: error is REQUIRED and error_description is OPTIONAL + response.assign_assume_new("error", JSON{error}, HASH_ERROR); + if (!error_description.empty()) { + response.assign_assume_new("error_description", JSON{error_description}, + HASH_ERROR_DESCRIPTION); + } + + return response; +} + +auto oauth_registration_grant_response_consistent( + const OAuthClientMetadata &metadata) -> bool { + const auto &data{metadata.data()}; + const bool grant_types_present{ + data.is_object() && + array_member_is_present(data, "grant_types"sv, HASH_GRANT_TYPES)}; + const bool response_types_present{ + data.is_object() && + array_member_is_present(data, "response_types"sv, HASH_RESPONSE_TYPES)}; + const bool grants_authorization_code{array_member_contains( + data, "grant_types"sv, HASH_GRANT_TYPES, "authorization_code"sv)}; + const bool grants_implicit{array_member_contains( + data, "grant_types"sv, HASH_GRANT_TYPES, "implicit"sv)}; + const bool responds_code{array_member_contains( + data, "response_types"sv, HASH_RESPONSE_TYPES, "code"sv)}; + const bool responds_token{array_member_contains( + data, "response_types"sv, HASH_RESPONSE_TYPES, "token"sv)}; + + // RFC 7591 Section 2.1: the authorization code grant pairs with the code + // response type and the implicit grant with the token response type. Only + // the explicitly registered lists are compared, so an omitted list, which + // the server fills with a default, registers no value that could contradict + // the other and a grant-only client that omits the response types stays + // consistent + if (response_types_present && + ((grants_authorization_code && !responds_code) || + (grants_implicit && !responds_token))) { + return false; + } + + if (grant_types_present && ((responds_code && !grants_authorization_code) || + (responds_token && !grants_implicit))) { + return false; + } + + return true; +} + +auto oauth_apply_software_statement_claims(JSON &metadata, const JSON &claims) + -> bool { + if (!metadata.is_object() || !claims.is_object()) { + return false; + } + + for (const auto &entry : claims.as_object()) { + // RFC 7591 Section 3.2.1: the statement is echoed unmodified, so a claim + // that happens to be named for it does not overwrite the echoed value + if (is_jwt_structural_claim(entry.first) || + entry.first == "software_statement"sv) { + continue; + } + + // RFC 7591 Section 3.1.1: a client metadata value conveyed in a trusted + // software statement takes precedence over the plain JSON value of the + // same name, so it overwrites rather than augments the record + metadata.assign(entry.first, entry.second); + } + + return true; +} + +auto oauth_make_registration_response( + const JSON &metadata, const OAuthClientRegistrationResult &result) + -> std::optional { + // RFC 7591 Section 3.2.1: the client identifier is REQUIRED, and the secret + // and its expiry are returned together, so one without the other yields a + // response this module's own reader would misread + if (!metadata.is_object() || result.client_id.empty() || + (result.client_secret.empty() != + !result.client_secret_expires_at.has_value())) { + return std::nullopt; + } + + // RFC 7591 Section 3.2.1: the issue and expiry times are a number of seconds + // since the epoch, so a negative value is not representable + if ((result.client_id_issued_at.has_value() && + result.client_id_issued_at.value() < std::chrono::seconds::zero()) || + (result.client_secret_expires_at.has_value() && + result.client_secret_expires_at.value() < + std::chrono::seconds::zero())) { + return std::nullopt; + } + + // RFC 7592 Section 3: the registration access token and its management + // location are both REQUIRED in a management response, so one without the + // other is not a well-formed response + if (result.registration_access_token.empty() != + result.registration_client_uri.empty()) { + return std::nullopt; + } + + // RFC 7592 Section 3: the registration management location is a fully + // qualified URL + if (!result.registration_client_uri.empty() && + !oauth_try_parse_uri(result.registration_client_uri).has_value()) { + return std::nullopt; + } + + auto response{metadata}; + // RFC 7591 Section 3.2.1: these members are provisioned by the server, so any + // value the accepted record carried for them is dropped and only the assigned + // value is returned, leaving no client-supplied value to survive + response.erase("client_id"sv); + response.erase("client_secret"sv); + response.erase("client_id_issued_at"sv); + response.erase("client_secret_expires_at"sv); + response.erase("registration_access_token"sv); + response.erase("registration_client_uri"sv); + + response.assign_assume_new(std::string{"client_id"}, JSON{result.client_id}, + HASH_CLIENT_ID); + if (!result.client_secret.empty()) { + response.assign_assume_new(std::string{"client_secret"}, + JSON{result.client_secret}, HASH_CLIENT_SECRET); + } + + if (result.client_id_issued_at.has_value()) { + response.assign_assume_new(std::string{"client_id_issued_at"}, + JSON{static_cast( + result.client_id_issued_at.value().count())}, + HASH_CLIENT_ID_ISSUED_AT); + } + + if (result.client_secret_expires_at.has_value()) { + response.assign_assume_new( + std::string{"client_secret_expires_at"}, + JSON{static_cast( + result.client_secret_expires_at.value().count())}, + HASH_CLIENT_SECRET_EXPIRES_AT); + } + + if (!result.registration_access_token.empty()) { + response.assign_assume_new(std::string{"registration_access_token"}, + JSON{result.registration_access_token}, + HASH_REGISTRATION_ACCESS_TOKEN); + } + + if (!result.registration_client_uri.empty()) { + response.assign_assume_new(std::string{"registration_client_uri"}, + JSON{result.registration_client_uri}, + HASH_REGISTRATION_CLIENT_URI); + } + + return response; +} + +auto oauth_make_registration_update_request( + const OAuthClientRegistrationConfig &config, + const std::string_view client_id, const std::string_view client_secret) + -> std::optional { + // RFC 7592 Section 2.2: the current client identifier MUST be present + if (client_id.empty()) { + return std::nullopt; + } + + auto document{oauth_make_registration_request(config)}; + if (!document.has_value()) { + return std::nullopt; + } + + // The registration request builder never writes these members, so they are + // known-new on the freshly built object and their precomputed hashes apply + document.value().assign_assume_new(std::string{"client_id"}, JSON{client_id}, + HASH_CLIENT_ID); + // RFC 7592 Section 2.2: the client MAY include its current secret to be + // matched against, but MUST NOT choose a new one, so an empty value is + // omitted rather than sent as a chosen secret + if (!client_secret.empty()) { + document.value().assign_assume_new(std::string{"client_secret"}, + JSON{client_secret}, HASH_CLIENT_SECRET); + } + + return document; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_revocation.cc b/vendor/core/src/core/oauth/oauth_revocation.cc new file mode 100644 index 00000000..c8415756 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_revocation.cc @@ -0,0 +1,102 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::function +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto assign_lookup_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: an empty value is treated as omitted, and a + // recognized parameter must not appear twice + if (value.empty()) { + return true; + } + + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +auto oauth_build_revocation_request(const std::string_view token, + const std::string_view token_type_hint, + SecureString &sink) -> void { + oauth_append_form_parameter(sink, "token", token); + if (!token_type_hint.empty()) { + oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + } +} + +auto oauth_parse_revocation_request( + const std::string_view body, SecureString &storage, + OAuthTokenLookupRequest &result, + const std::function &on_other) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1) + storage.reserve(storage.size() + body.size()); + const URI::Query parsed{body}; + bool has_token{false}; + bool has_token_type_hint{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "token") { + valid = assign_lookup_scalar(value, storage, has_token, result.token); + } else if (name == "token_type_hint") { + valid = assign_lookup_scalar(value, storage, has_token_type_hint, + result.token_type_hint); + } else { + std::string_view decoded; + valid = oauth_form_decode_into_secure(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + // RFC 7009 Section 2.1: the token is REQUIRED + return has_token; +} + +auto oauth_revocation_outcome(const HTTPStatus status) noexcept + -> OAuthRevocationOutcome { + if (status.code == HTTP_STATUS_OK.code) { + return OAuthRevocationOutcome::Success; + } + + if (status.code == HTTP_STATUS_SERVICE_UNAVAILABLE.code) { + return OAuthRevocationOutcome::Retry; + } + + return OAuthRevocationOutcome::Error; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_syntax.h b/vendor/core/src/core/oauth/oauth_syntax.h new file mode 100644 index 00000000..27cb15cc --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_syntax.h @@ -0,0 +1,77 @@ +#ifndef SOURCEMETA_CORE_OAUTH_SYNTAX_H_ +#define SOURCEMETA_CORE_OAUTH_SYNTAX_H_ + +#include +#include + +#include // std::ranges::all_of +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +// URI::is_uri validates the RFC 3986 grammar without constructing, but leaves +// the port unbounded while construction rejects a port above 32 bits, so the +// construction can still throw and is guarded rather than assumed to succeed +inline auto oauth_try_parse_uri(const std::string_view value) + -> std::optional { + if (!URI::is_uri(value)) { + return std::nullopt; + } + + try { + return URI{value}; + } catch (const URIParseError &) { + return std::nullopt; + } +} + +// 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 { + 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(); +} + +// 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 { + 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 3986 Section 2.3: "unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"", +// the character set RFC 7636 reuses for the PKCE verifier and challenge +inline auto oauth_is_unreserved(const char character) noexcept -> bool { + return is_alphanum(character) || character == '-' || character == '.' || + character == '_' || character == '~'; +} + +inline auto oauth_is_unreserved_string(const std::string_view value) noexcept + -> bool { + return std::ranges::all_of(value, oauth_is_unreserved); +} + +// RFC 7636 Section 4.1: "code-verifier = 43*128unreserved" +inline auto oauth_is_pkce_verifier(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +// RFC 7636 Section 4.2: "code-challenge = 43*128unreserved" +inline auto oauth_is_pkce_challenge(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_token.cc b/vendor/core/src/core/oauth/oauth_token.cc new file mode 100644 index 00000000..6f2fc2df --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_token.cc @@ -0,0 +1,313 @@ +#include + +#include +#include +#include + +#include "oauth_decode.h" +#include "oauth_encode.h" + +#include // std::chrono::seconds +#include // std::size_t +#include // std::int64_t +#include // std::function +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ACCESS_TOKEN{JSON::Object::hash("access_token"sv)}; +const auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; +const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; +const auto HASH_REFRESH_TOKEN{JSON::Object::hash("refresh_token"sv)}; +const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +const auto HASH_ERROR{JSON::Object::hash("error"sv)}; +const auto HASH_ERROR_DESCRIPTION{JSON::Object::hash("error_description"sv)}; +const auto HASH_ERROR_URI{JSON::Object::hash("error_uri"sv)}; + +auto string_member(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + if (!data.is_object()) { + return std::nullopt; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +auto oauth_append_resources(SecureString &sink, + const std::span resources) + -> void { + for (const auto &resource : resources) { + oauth_append_form_parameter(sink, resource.name, resource.value); + } +} + +auto assign_token_scalar(const std::string_view value, SecureString &storage, + bool &seen, std::string_view &field) -> bool { + // RFC 6749 Section 3.2: "Parameters sent without a value MUST be treated as + // if they were omitted", so an empty occurrence neither counts as a duplicate + // nor marks the parameter present + if (value.empty()) { + return true; + } + + // RFC 6749 Section 3.2: a recognized parameter must not appear twice at the + // token endpoint + if (seen) { + return false; + } + + seen = true; + return oauth_form_decode_into_secure(value, storage, field); +} + +} // namespace + +auto oauth_build_token_request_code( + const std::string_view code, const std::string_view redirect_uri, + const std::string_view code_verifier, + const std::span resources, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "grant_type", "authorization_code"); + oauth_append_form_parameter(sink, "code", code); + if (!redirect_uri.empty()) { + oauth_append_form_parameter(sink, "redirect_uri", redirect_uri); + } + + if (!code_verifier.empty()) { + oauth_append_form_parameter(sink, "code_verifier", code_verifier); + } + + oauth_append_resources(sink, resources); +} + +auto oauth_build_token_request_refresh( + const std::string_view refresh_token, const std::string_view scope, + const std::span resources, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "grant_type", "refresh_token"); + oauth_append_form_parameter(sink, "refresh_token", refresh_token); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + oauth_append_resources(sink, resources); +} + +auto oauth_build_token_request_client_credentials( + const std::string_view scope, + const std::span resources, SecureString &sink) + -> void { + oauth_append_form_parameter(sink, "grant_type", "client_credentials"); + if (!scope.empty()) { + oauth_append_form_parameter(sink, "scope", scope); + } + + oauth_append_resources(sink, resources); +} + +OAuthTokenResponse::OAuthTokenResponse(const JSON &data) : data_{&data} {} + +auto OAuthTokenResponse::access_token() const + -> std::optional { + return string_member(*this->data_, "access_token"sv, HASH_ACCESS_TOKEN); +} + +auto OAuthTokenResponse::token_type() const -> std::optional { + return string_member(*this->data_, "token_type"sv, HASH_TOKEN_TYPE); +} + +auto OAuthTokenResponse::is_bearer_token_type() const -> bool { + const auto type{this->token_type()}; + // RFC 6749 Section 5.1: the token type value is compared case insensitively + return type.has_value() && equals_ignore_case(type.value(), "Bearer"); +} + +auto OAuthTokenResponse::expires_in() const + -> std::optional { + if (!this->data_->is_object()) { + return std::nullopt; + } + + const auto *member{this->data_->try_at("expires_in"sv, HASH_EXPIRES_IN)}; + if (member == nullptr || !member->is_integer()) { + return std::nullopt; + } + + const auto value{member->to_integer()}; + if (value < 0) { + return std::nullopt; + } + + return std::chrono::seconds{value}; +} + +auto OAuthTokenResponse::refresh_token() const + -> std::optional { + return string_member(*this->data_, "refresh_token"sv, HASH_REFRESH_TOKEN); +} + +auto OAuthTokenResponse::scope() const -> std::optional { + return string_member(*this->data_, "scope"sv, HASH_SCOPE); +} + +auto OAuthTokenResponse::has_scope(const std::string_view value) const -> bool { + const auto granted{this->scope()}; + if (!granted.has_value() || value.empty()) { + return false; + } + + // RFC 6749 Section 3.3: scope is a space-delimited, unordered set of tokens, + // scanned without allocation + const auto text{granted.value()}; + std::size_t position{0}; + while (position < text.size()) { + const auto next{text.find(' ', position)}; + const auto token{text.substr(position, next == std::string_view::npos + ? std::string_view::npos + : next - position)}; + if (token == value) { + return true; + } + + if (next == std::string_view::npos) { + break; + } + + position = next + 1; + } + + return false; +} + +auto OAuthTokenResponse::data() const -> const JSON & { return *this->data_; } + +auto oauth_parse_token_request( + const std::string_view body, SecureString &storage, + OAuthTokenRequest &result, + const std::function &on_other) + -> bool { + result = {}; + // A single up-front reserve keeps every later decode from reallocating the + // arena and dangling an earlier borrowed view (design convention 1). The + // token endpoint body carries the request's secrets, the authorization code, + // the PKCE verifier, the refresh token, and the client authentication + // parameters, so the arena is a wiping string (engineering invariant 1) + storage.reserve(storage.size() + body.size()); + const URI::Query parsed{body}; + bool has_grant_type{false}; + bool has_code{false}; + bool has_redirect_uri{false}; + bool has_code_verifier{false}; + bool has_refresh_token{false}; + bool has_scope{false}; + for (const auto ¶meter : parsed) { + // RFC 6749 Appendix B: the application/x-www-form-urlencoded format encodes + // names too, so a name is decoded before it is recognized, and a malformed + // escape fails the parse + std::string_view name; + if (!oauth_form_decode_into_secure(parameter.first, storage, name)) { + return false; + } + + const auto value{parameter.second}; + bool valid{true}; + if (name == "grant_type") { + valid = assign_token_scalar(value, storage, has_grant_type, + result.grant_type); + } else if (name == "code") { + valid = assign_token_scalar(value, storage, has_code, result.code); + } else if (name == "redirect_uri") { + valid = assign_token_scalar(value, storage, has_redirect_uri, + result.redirect_uri); + } else if (name == "code_verifier") { + valid = assign_token_scalar(value, storage, has_code_verifier, + result.code_verifier); + } else if (name == "refresh_token") { + valid = assign_token_scalar(value, storage, has_refresh_token, + result.refresh_token); + } else if (name == "scope") { + valid = assign_token_scalar(value, storage, has_scope, result.scope); + } else { + // Repeatable resource and audience indicators, and every other parameter + // including the client authentication ones, are surfaced with their + // decoded value rather than stored on the result + std::string_view decoded; + valid = oauth_form_decode_into_secure(value, storage, decoded); + if (valid) { + on_other(name, decoded); + } + } + + if (!valid) { + return false; + } + } + + return true; +} + +auto oauth_make_token_response(const OAuthTokenGrant &grant) -> JSON { + auto response{JSON::make_object()}; + // The keys are unique and their hashes are already computed, so the response + // is assembled without a per-key hash or duplicate scan + // RFC 6749 Section 5.1: access_token and token_type are REQUIRED + response.assign_assume_new("access_token", JSON{grant.access_token}, + HASH_ACCESS_TOKEN); + response.assign_assume_new("token_type", JSON{grant.token_type}, + HASH_TOKEN_TYPE); + // RFC 6749 Section 5.1: the lifetime is a number of seconds, so a negative + // duration is not representable and is omitted rather than emitted as a value + // this module's own response reader would reject + if (grant.expires_in.has_value() && + grant.expires_in.value() >= std::chrono::seconds{0}) { + response.assign_assume_new( + "expires_in", + JSON{static_cast(grant.expires_in.value().count())}, + HASH_EXPIRES_IN); + } + + if (!grant.refresh_token.empty()) { + response.assign_assume_new("refresh_token", JSON{grant.refresh_token}, + HASH_REFRESH_TOKEN); + } + + // RFC 6749 Section 5.1: the scope is OPTIONAL when identical to the requested + // scope and REQUIRED otherwise, so it is emitted only when it differs + if (!grant.scope.empty() && grant.scope != grant.requested_scope) { + response.assign_assume_new("scope", JSON{grant.scope}, HASH_SCOPE); + } + + return response; +} + +auto oauth_make_token_error_response(const std::string_view error, + const std::string_view error_description, + const std::string_view error_uri) -> JSON { + auto response{JSON::make_object()}; + // RFC 6749 Section 5.2: error is REQUIRED + response.assign_assume_new("error", JSON{error}, HASH_ERROR); + if (!error_description.empty()) { + response.assign_assume_new("error_description", JSON{error_description}, + HASH_ERROR_DESCRIPTION); + } + + if (!error_uri.empty()) { + response.assign_assume_new("error_uri", JSON{error_uri}, HASH_ERROR_URI); + } + + return response; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_token_exchange.cc b/vendor/core/src/core/oauth/oauth_token_exchange.cc new file mode 100644 index 00000000..6135fd42 --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_token_exchange.cc @@ -0,0 +1,79 @@ +#include + +#include +#include + +#include "oauth_encode.h" +#include "oauth_json.h" + +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +const auto HASH_ISSUED_TOKEN_TYPE{JSON::Object::hash("issued_token_type"sv)}; + +auto oauth_append_repeated(SecureString &sink, const std::string_view name, + const std::span values) + -> void { + for (const auto value : values) { + oauth_append_form_parameter(sink, name, value); + } +} + +} // namespace + +auto oauth_token_exchange_valid(const OAuthTokenExchangeRequest &request) + -> bool { + // RFC 8693 Section 2.1: the subject token and its type are REQUIRED, and the + // actor token and its type are used together or not at all + return !request.subject_token.empty() && + !request.subject_token_type.empty() && + request.actor_token.empty() == request.actor_token_type.empty(); +} + +auto oauth_build_token_request_exchange( + const OAuthTokenExchangeRequest &request, SecureString &sink) -> bool { + if (!oauth_token_exchange_valid(request)) { + return false; + } + + oauth_append_form_parameter( + sink, "grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + oauth_append_form_parameter(sink, "subject_token", request.subject_token); + oauth_append_form_parameter(sink, "subject_token_type", + request.subject_token_type); + if (!request.actor_token.empty()) { + oauth_append_form_parameter(sink, "actor_token", request.actor_token); + oauth_append_form_parameter(sink, "actor_token_type", + request.actor_token_type); + } + + if (!request.requested_token_type.empty()) { + oauth_append_form_parameter(sink, "requested_token_type", + request.requested_token_type); + } + + if (!request.scope.empty()) { + oauth_append_form_parameter(sink, "scope", request.scope); + } + + // RFC 8693 Section 2.1 and RFC 8707 Section 2: each audience and resource is + // emitted under its fixed parameter name, so the caller supplies only values + oauth_append_repeated(sink, "audience", request.audiences); + oauth_append_repeated(sink, "resource", request.resources); + return true; +} + +auto oauth_issued_token_type(const JSON &response) + -> std::optional { + return oauth_json_string_member(response, "issued_token_type"sv, + HASH_ISSUED_TOKEN_TYPE); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_transaction.cc b/vendor/core/src/core/oauth/oauth_transaction.cc new file mode 100644 index 00000000..8c5d939f --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_transaction.cc @@ -0,0 +1,69 @@ +#include + +#include +#include +#include + +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +auto oauth_transaction_mint() -> OAuthTransactionSecrets { + return {.state = oauth_random_token(), + .code_verifier = oauth_pkce_verifier()}; +} + +auto oauth_transaction_check(const OAuthTransaction &transaction, + const OAuthAuthorizationResponse &response, + const OAuthIssuerSupport issuer_support, + const std::string_view received_uri, + std::string_view &code) + -> std::optional { + // RFC 6749 Section 10.12: the state binds the callback to this user agent, so + // it is checked first and in constant time to avoid leaking a match position, + // even though the value itself is not confidential + if (response.state.empty() || + !secure_equals(response.state, transaction.state)) { + return OAuthCallbackError::State; + } + + // RFC 8252 Section 8.10: when the caller knows the URI the response arrived + // on, it must be the one the request was sent with + if (!received_uri.empty() && received_uri != transaction.redirect_uri) { + return OAuthCallbackError::ReceivedURI; + } + + // RFC 9207 Section 2.4: the issuer defends against a mix-up attack + switch (issuer_support) { + case OAuthIssuerSupport::Supported: + if (response.iss.empty() || response.iss != transaction.issuer) { + return OAuthCallbackError::Issuer; + } + + break; + case OAuthIssuerSupport::Unknown: + if (!response.iss.empty() && response.iss != transaction.issuer) { + return OAuthCallbackError::Issuer; + } + + break; + case OAuthIssuerSupport::NotSupported: + break; + } + + // RFC 6749 Section 4.1.2.1: an authenticated error response is a decline, and + // it is distinguished from a missing code only after the checks above pass + if (!response.error.empty()) { + return OAuthCallbackError::Declined; + } + + if (response.code.empty()) { + return OAuthCallbackError::MissingCode; + } + + code = response.code; + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oauth/oauth_ttl.h b/vendor/core/src/core/oauth/oauth_ttl.h new file mode 100644 index 00000000..0dd7e0dc --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_ttl.h @@ -0,0 +1,28 @@ +#ifndef SOURCEMETA_CORE_OAUTH_TTL_H_ +#define SOURCEMETA_CORE_OAUTH_TTL_H_ + +#include // std::max, std::min +#include // std::chrono::seconds +#include // std::optional + +namespace sourcemeta::core { + +// Clamp an advertised freshness lifetime into the honored range, or fall back +// when the transport advertised none. The upper and lower limits are applied +// as separate comparisons, so an inverted band whose minimum exceeds its +// maximum stays well defined with the minimum taking precedence +inline auto oauth_clamp_ttl(const std::optional max_age, + const std::chrono::seconds fallback, + const std::chrono::seconds minimum, + const std::chrono::seconds maximum) + -> std::chrono::seconds { + if (max_age.has_value()) { + return std::max(minimum, std::min(max_age.value(), maximum)); + } + + return fallback; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/regex/CMakeLists.txt b/vendor/core/src/core/regex/CMakeLists.txt index 3e956ae3..958144e9 100644 --- a/vendor/core/src/core/regex/CMakeLists.txt +++ b/vendor/core/src/core/regex/CMakeLists.txt @@ -1,5 +1,5 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME regex - SOURCES regex.cc preprocess.h) + SOURCES regex.cc iregexp.h permissive.h) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME regex) diff --git a/vendor/core/src/core/regex/include/sourcemeta/core/regex.h b/vendor/core/src/core/regex/include/sourcemeta/core/regex.h index 9fc1d85c..cda2876f 100644 --- a/vendor/core/src/core/regex/include/sourcemeta/core/regex.h +++ b/vendor/core/src/core/regex/include/sourcemeta/core/regex.h @@ -69,13 +69,29 @@ enum class RegexIndex : std::uint8_t { }; #endif +/// @ingroup regex +/// The dialects that a regular expression pattern can be interpreted with. +enum class RegexDialect : std::uint8_t { + /// A permissive superset of ECMA 262 with PCRE2 extensions + Permissive, + /// Strict RFC 9485 I-Regexp, where any pattern outside the grammar is + /// rejected, matching considers the whole input, and an unescaped caret + /// or dollar sign outside a character class is an ordinary character + IRegexp, + /// Like the strict RFC 9485 dialect, except that matching considers any + /// substring of the input + IRegexpSearch +}; + /// @ingroup regex /// /// Compile a regular expression from a string. If the regular expression is /// invalid, no value is returned. In this function: /// -/// - Regexes are NOT automatically anchored -/// - Regexes assume `DOTALL` +/// - Permissive regexes are NOT automatically anchored +/// - Permissive regexes assume `DOTALL` +/// - RFC 9485 regexes match the whole input, except in the search dialect, +/// which matches any substring /// - Regexes assume Unicode /// - Regexes are case sensitive /// - No matching happens (only boolean validation) @@ -87,11 +103,14 @@ enum class RegexIndex : std::uint8_t { /// #include /// /// const sourcemeta::core::Regex regex{ -/// sourcemeta::core::to_regex("^foo")}; +/// sourcemeta::core::to_regex("^foo", +/// sourcemeta::core::RegexDialect::Permissive)}; /// assert(regex.has_value()); /// ``` SOURCEMETA_CORE_REGEX_EXPORT -auto to_regex(const std::string_view pattern) -> std::optional; +auto to_regex(const std::string_view pattern, + const RegexDialect dialect = RegexDialect::Permissive) + -> std::optional; /// @ingroup regex /// @@ -123,7 +142,9 @@ auto matches(const Regex ®ex, const std::string_view value) -> bool; /// ``` SOURCEMETA_CORE_REGEX_EXPORT auto matches_if_valid(const std::string_view pattern, - const std::string_view value) -> bool; + const std::string_view value, + const RegexDialect dialect = RegexDialect::Permissive) + -> bool; /// @ingroup regex /// diff --git a/vendor/core/src/core/regex/iregexp.h b/vendor/core/src/core/regex/iregexp.h new file mode 100644 index 00000000..28b83e03 --- /dev/null +++ b/vendor/core/src/core/regex/iregexp.h @@ -0,0 +1,403 @@ +#ifndef SOURCEMETA_CORE_REGEX_IREGEXP_H_ +#define SOURCEMETA_CORE_REGEX_IREGEXP_H_ + +#include + +#include // std::from_chars +#include // std::size_t +#include // std::uint32_t +#include // std::optional, std::nullopt +#include // std::string, std::to_string +#include // std::string_view +#include // std::errc + +namespace sourcemeta::core { + +namespace { + +// RFC 9485 Section 8 permits limiting the composability of patterns that +// challenge the engine. Grouping recurses, so depth is capped to keep +// attacker controlled patterns from overflowing the stack +constexpr std::size_t IREGEXP_MAXIMUM_GROUP_DEPTH{64}; + +// SingleCharEsc = "\" ( %x28-2B ; '('-'+' +// / "-" / "." / "?" / %x5B-5E ; '['-'^' +// / %s"n" / %s"r" / %s"t" / %x7B-7D ; '{'-'}' ) +inline auto is_iregexp_single_char_escape(const char character) -> bool { + return (character >= 0x28 && character <= 0x2B) || character == '-' || + character == '.' || character == '?' || + (character >= 0x5B && character <= 0x5E) || character == 'n' || + character == 'r' || character == 't' || + (character >= 0x7B && character <= 0x7D); +} + +// charProp = IsCategory, where the Others production is %s"C" with an +// optional second letter drawn from c, f, n, and o, so the bare major +// category conforms while the surrogate form does not. Accepting the major +// category cannot reintroduce surrogates, as they have no representation in +// well formed UTF-8 input +inline auto is_iregexp_category(const std::string_view name) -> bool { + if (name.empty() || name.size() > 2) { + return false; + } + + switch (name.front()) { + case 'L': + return name.size() == 1 || std::string_view{"lmotu"}.contains(name[1]); + case 'M': + return name.size() == 1 || std::string_view{"cen"}.contains(name[1]); + case 'N': + return name.size() == 1 || std::string_view{"dlo"}.contains(name[1]); + case 'P': + return name.size() == 1 || std::string_view{"cdefios"}.contains(name[1]); + case 'Z': + return name.size() == 1 || std::string_view{"lps"}.contains(name[1]); + case 'S': + return name.size() == 1 || std::string_view{"ckmo"}.contains(name[1]); + case 'C': + return name.size() == 1 || std::string_view{"cfno"}.contains(name[1]); + default: + return false; + } +} + +// Copy one full UTF-8 encoded code point, rejecting malformed sequences. +// The engine later rejects overlong forms and surrogates when compiling +inline auto iregexp_copy_character(const std::string_view pattern, + std::size_t &position, std::string &output) + -> bool { + const auto lead{static_cast(pattern[position])}; + const auto size{utf8_lead_byte_size(lead)}; + if (size == 0 || position + size > pattern.size()) { + return false; + } + + for (std::size_t offset{1}; offset < size; ++offset) { + if (!is_utf8_continuation( + static_cast(pattern[position + offset]))) { + return false; + } + } + + output.append(pattern.substr(position, size)); + position += size; + return true; +} + +// charClassEsc = catEsc / complEsc, which along with SingleCharEsc are the +// only escape forms in the entire grammar +inline auto iregexp_escape(const std::string_view pattern, + std::size_t &position, std::string &output) -> bool { + if (position + 1 >= pattern.size()) { + return false; + } + + const char next{pattern[position + 1]}; + if (is_iregexp_single_char_escape(next)) { + output += '\\'; + output += next; + position += 2; + return true; + } + + // catEsc = %s"\p{" charProp "}" and complEsc = %s"\P{" charProp "}" + if (next == 'p' || next == 'P') { + if (position + 2 >= pattern.size() || pattern[position + 2] != '{') { + return false; + } + + const auto closing{pattern.find('}', position + 3)}; + if (closing == std::string_view::npos) { + return false; + } + + const auto name{pattern.substr(position + 3, closing - position - 3)}; + if (!is_iregexp_category(name)) { + return false; + } + + output.append(pattern.substr(position, closing - position + 1)); + position = closing + 1; + return true; + } + + return false; +} + +// CCchar = ( %x00-2C / %x2E-5A / %x5E-D7FF / %xE000-10FFFF ) / SingleCharEsc, +// which excludes exactly the dash, both brackets, and the backslash +inline auto iregexp_class_character(const std::string_view pattern, + std::size_t &position, std::string &output) + -> bool { + const char character{pattern[position]}; + if (character == '\\') { + if (position + 1 < pattern.size() && + is_iregexp_single_char_escape(pattern[position + 1])) { + output += '\\'; + output += pattern[position + 1]; + position += 2; + return true; + } + + return false; + } + + if (character == '-' || character == '[' || character == ']') { + return false; + } + + return iregexp_copy_character(pattern, position, output); +} + +// CCE1 = ( CCchar [ "-" CCchar ] ) / charClassEsc +inline auto iregexp_class_element(const std::string_view pattern, + std::size_t &position, std::string &output) + -> bool { + if (pattern[position] == '\\' && position + 1 < pattern.size() && + (pattern[position + 1] == 'p' || pattern[position + 1] == 'P')) { + return iregexp_escape(pattern, position, output); + } + + if (!iregexp_class_character(pattern, position, output)) { + return false; + } + + // A dash followed by a closing bracket is the optional trailing dash of + // the enclosing class expression, not a range + if (position + 1 < pattern.size() && pattern[position] == '-' && + pattern[position + 1] != ']') { + output += '-'; + position += 1; + return iregexp_class_character(pattern, position, output); + } + + return true; +} + +// charClassExpr = "[" [ "^" ] ( "-" / CCE1 ) *CCE1 [ "-" ] "]", with the +// prose restriction that it is not allowed to match "[^]" +inline auto iregexp_class_expression(const std::string_view pattern, + std::size_t &position, std::string &output) + -> bool { + output += '['; + position += 1; + if (position < pattern.size() && pattern[position] == '^') { + output += '^'; + position += 1; + } + + if (position < pattern.size() && pattern[position] == '-') { + output += '-'; + position += 1; + } else { + if (position >= pattern.size() || pattern[position] == ']') { + return false; + } + + if (!iregexp_class_element(pattern, position, output)) { + return false; + } + } + + while (position < pattern.size() && pattern[position] != ']' && + pattern[position] != '-') { + if (!iregexp_class_element(pattern, position, output)) { + return false; + } + } + + if (position < pattern.size() && pattern[position] == '-') { + output += '-'; + position += 1; + } + + if (position >= pattern.size() || pattern[position] != ']') { + return false; + } + + output += ']'; + position += 1; + return true; +} + +// quantifier = ( "*" / "+" / "?" ) / range-quantifier +// range-quantifier = "{" QuantExact [ "," [ QuantExact ] ] "}" +inline auto iregexp_quantifier(const std::string_view pattern, + std::size_t &position, std::string &output) + -> bool { + const char character{pattern[position]}; + if (character == '*' || character == '+' || character == '?') { + output += character; + position += 1; + return true; + } + + const auto minimum_begin{position + 1}; + auto cursor{minimum_begin}; + while (cursor < pattern.size() && pattern[cursor] >= '0' && + pattern[cursor] <= '9') { + cursor += 1; + } + + // QuantExact = 1*%x30-39, so at least one digit is required. Counts that + // overflow the representable range are rejected, as RFC 9485 Section 8 + // permits for quantifiers of excessive magnitude + if (cursor == minimum_begin) { + return false; + } + + std::uint32_t minimum{0}; + const auto minimum_result{std::from_chars(pattern.data() + minimum_begin, + pattern.data() + cursor, minimum)}; + if (minimum_result.ec != std::errc{}) { + return false; + } + + output += '{'; + output += std::to_string(minimum); + if (cursor < pattern.size() && pattern[cursor] == ',') { + output += ','; + cursor += 1; + const auto maximum_begin{cursor}; + while (cursor < pattern.size() && pattern[cursor] >= '0' && + pattern[cursor] <= '9') { + cursor += 1; + } + + if (cursor > maximum_begin) { + std::uint32_t maximum{0}; + const auto maximum_result{std::from_chars( + pattern.data() + maximum_begin, pattern.data() + cursor, maximum)}; + // Out of order bounds are an error, consistent with how the permissive + // dialect rejects them for ECMA 262 + if (maximum_result.ec != std::errc{} || minimum > maximum) { + return false; + } + + output += std::to_string(maximum); + } + } + + if (cursor >= pattern.size() || pattern[cursor] != '}') { + return false; + } + + output += '}'; + position = cursor + 1; + return true; +} + +// i-regexp = branch *( "|" branch ) with branch = *piece, so empty branches +// conform. The caller at depth zero owns the whole input while group +// recursion stops at the closing parenthesis for its caller to consume +inline auto iregexp_branches(const std::string_view pattern, + std::size_t &position, std::string &output, + const std::size_t depth) -> bool { + // piece = atom [ quantifier ], so a quantifier is only valid directly + // after a quantifiable atom and never twice in a row + bool quantifiable{false}; + while (position < pattern.size()) { + const char character{pattern[position]}; + if (character == '|') { + output += '|'; + position += 1; + quantifiable = false; + } else if (character == ')') { + return depth > 0; + } else if (character == '(') { + if (depth >= IREGEXP_MAXIMUM_GROUP_DEPTH) { + return false; + } + + output += '('; + position += 1; + if (!iregexp_branches(pattern, position, output, depth + 1)) { + return false; + } + + if (position >= pattern.size() || pattern[position] != ')') { + return false; + } + + output += ')'; + position += 1; + quantifiable = true; + } else if (character == '*' || character == '+' || character == '?' || + character == '{') { + if (!quantifiable || !iregexp_quantifier(pattern, position, output)) { + return false; + } + + quantifiable = false; + } else if (character == '.') { + // RFC 9485 Section 5.3: for any unescaped dots outside character + // classes, "replace the dot with [^\n\r]" + output += "[^\\n\\r]"; + position += 1; + quantifiable = true; + } else if (character == '[') { + if (!iregexp_class_expression(pattern, position, output)) { + return false; + } + + quantifiable = true; + } else if (character == '\\') { + if (!iregexp_escape(pattern, position, output)) { + return false; + } + + quantifiable = true; + } else if (character == '^' || character == '$') { + // RFC 9485 Section 4 defers to XSD semantics, where the caret and the + // dollar sign outside character classes are ordinary characters, so + // they are escaped for the engine instead of acting as assertions. A + // leading caret inside a character class remains the negation marker + // per the grammar. Note that the Section 5 engine mappings, which are + // "not normative" by their own introduction, pass these characters + // through unescaped and therefore silently give them assertion + // semantics, which is why most implementations and some test suites + // disagree with the normative behavior implemented here + output += '\\'; + output += character; + position += 1; + quantifiable = true; + } else if (character == ']' || character == '}') { + // NormalChar excludes both closing delimiters, so they may only + // appear escaped + return false; + } else { + if (!iregexp_copy_character(pattern, position, output)) { + return false; + } + + quantifiable = true; + } + } + + return depth == 0; +} + +} // namespace + +// Validate a pattern against the RFC 9485 grammar and produce the equivalent +// engine pattern following the RFC 9485 Section 5.4 mapping, which encloses +// the translation "in \A(?: and )\z". Skipping the enclosure yields substring +// search semantics instead of whole input matching +inline auto translate_iregexp(const std::string_view pattern, + const bool anchored) + -> std::optional { + std::string result; + result.reserve(pattern.size() + 8); + result += anchored ? "\\A(?:" : "(?:"; + std::size_t position{0}; + if (!iregexp_branches(pattern, position, result, 0)) { + return std::nullopt; + } + + result += anchored ? ")\\z" : ")"; + return result; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/regex/preprocess.h b/vendor/core/src/core/regex/permissive.h similarity index 95% rename from vendor/core/src/core/regex/preprocess.h rename to vendor/core/src/core/regex/permissive.h index 447c6172..03a48b57 100644 --- a/vendor/core/src/core/regex/preprocess.h +++ b/vendor/core/src/core/regex/permissive.h @@ -1,5 +1,5 @@ -#ifndef SOURCEMETA_CORE_REGEX_PREPROCESS_H_ -#define SOURCEMETA_CORE_REGEX_PREPROCESS_H_ +#ifndef SOURCEMETA_CORE_REGEX_PERMISSIVE_H_ +#define SOURCEMETA_CORE_REGEX_PERMISSIVE_H_ #include @@ -600,6 +600,30 @@ inline auto is_escaped(const std::string &pattern, std::size_t index) -> bool { return (count % 2) == 1; } +// Whether the closing brace at the given position terminates a bounded +// quantifier, as opposed to standing for a literal brace character +inline auto closes_brace_quantifier(const std::string &pattern, + std::size_t brace_position) -> bool { + auto cursor{brace_position}; + bool has_digits{false}; + while (cursor > 0 && is_digit(pattern[cursor - 1])) { + has_digits = true; + --cursor; + } + + if (cursor > 0 && pattern[cursor - 1] == ',') { + --cursor; + has_digits = false; + while (cursor > 0 && is_digit(pattern[cursor - 1])) { + has_digits = true; + --cursor; + } + } + + return has_digits && cursor > 0 && pattern[cursor - 1] == '{' && + !is_escaped(pattern, cursor - 1); +} + struct ShorthandExpansion { char escape; std::string_view inside_class; @@ -632,8 +656,9 @@ inline auto find_shorthand(char escape) -> const ShorthandExpansion * { } // namespace -// The result of preprocessing a regex pattern into PCRE2-compatible form. -struct PreprocessResult { +// The result of translating a permissive dialect pattern into +// PCRE2-compatible form +struct PermissiveResult { // True if the input pattern is strict ECMA-262. bool ecma_valid; // The PCRE2-compatible transformed pattern, if any. @@ -667,7 +692,8 @@ inline auto exceeds_class_depth(const std::string &pattern) -> bool { return false; } -inline auto preprocess_regex(const std::string &pattern) -> PreprocessResult { +inline auto translate_permissive(const std::string &pattern) + -> PermissiveResult { if (exceeds_class_depth(pattern)) { return {.ecma_valid = false, .transformed = std::nullopt}; } @@ -704,11 +730,12 @@ inline auto preprocess_regex(const std::string &pattern) -> PreprocessResult { } } - // PCRE-only possessive quantifiers: *+ ++ ?+ + // PCRE-only possessive quantifiers: *+ ++ ?+ {n,m}+ if (current == '+' && position > 0 && !in_class) { const char prev = pattern[position - 1]; - if ((prev == '*' || prev == '+' || prev == '?') && - !is_escaped(pattern, position - 1)) { + if (!is_escaped(pattern, position - 1) && + (prev == '*' || prev == '+' || prev == '?' || + (prev == '}' && closes_brace_quantifier(pattern, position - 1)))) { ecma_valid = false; } } diff --git a/vendor/core/src/core/regex/regex.cc b/vendor/core/src/core/regex/regex.cc index c827a5a6..e1191f0f 100644 --- a/vendor/core/src/core/regex/regex.cc +++ b/vendor/core/src/core/regex/regex.cc @@ -3,10 +3,12 @@ #include -#include "preprocess.h" +#include "iregexp.h" +#include "permissive.h" #include // std::from_chars #include // std::size_t +#include // std::uint32_t #include // std::regex, std::smatch, std::regex_match #include // std::string #include // std::string_view @@ -15,7 +17,96 @@ namespace sourcemeta::core { -auto to_regex(const std::string_view pattern) -> std::optional { +namespace { + +auto compile_pcre2(const std::string &pattern, const std::uint32_t options) + -> std::optional { + int pcre2_error_code{0}; + PCRE2_SIZE pcre2_error_offset{0}; + pcre2_code *pcre2_regex_raw{pcre2_compile( + reinterpret_cast(pattern.c_str()), pattern.size(), options, + &pcre2_error_code, &pcre2_error_offset, nullptr)}; + if (pcre2_regex_raw == nullptr) { + return std::nullopt; + } + + std::shared_ptr pcre2_regex{pcre2_regex_raw, pcre2_code_free}; + pcre2_jit_compile(pcre2_regex.get(), PCRE2_JIT_COMPLETE); + return RegexTypePCRE2{std::shared_ptr(pcre2_regex)}; +} + +auto compiles_with_ecma_options(const std::string &pattern) -> bool { + int pcre2_error_code{0}; + PCRE2_SIZE pcre2_error_offset{0}; + pcre2_code *pcre2_regex_raw{pcre2_compile( + reinterpret_cast(pattern.c_str()), pattern.size(), + // Capturing groups are kept enabled so that ECMA-262 numbered + // backreferences like (a)\1 compile and match, at a small tracking cost + PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | PCRE2_DOLLAR_ENDONLY | + PCRE2_NEVER_BACKSLASH_C | PCRE2_MATCH_INVALID_UTF | + PCRE2_ALLOW_EMPTY_CLASS, + &pcre2_error_code, &pcre2_error_offset, nullptr)}; + + if (pcre2_regex_raw == nullptr) { + return false; + } + + pcre2_code_free(pcre2_regex_raw); + return true; +} + +auto lookbehinds_as_lookaheads(const std::string &pattern) -> std::string { + std::string result; + result.reserve(pattern.size()); + bool in_class{false}; + for (std::size_t position = 0; position < pattern.size(); ++position) { + const char current{pattern[position]}; + if (current == '\\' && position + 1 < pattern.size()) { + result += current; + result += pattern[position + 1]; + position += 1; + continue; + } + + if (current == '[') { + in_class = true; + } else if (current == ']') { + in_class = false; + } + + if (!in_class && current == '(' && position + 3 < pattern.size() && + pattern[position + 1] == '?' && pattern[position + 2] == '<' && + (pattern[position + 3] == '=' || pattern[position + 3] == '!')) { + result += "(?"; + result += pattern[position + 3]; + position += 3; + continue; + } + + result += current; + } + + return result; +} + +} // namespace + +auto to_regex(const std::string_view pattern, const RegexDialect dialect) + -> std::optional { + if (dialect != RegexDialect::Permissive) { + const auto translated{ + translate_iregexp(pattern, dialect == RegexDialect::IRegexp)}; + if (!translated.has_value()) { + return std::nullopt; + } + + // Grouping in RFC 9485 carries no capturing semantics, as matching is + // strictly Boolean, so capturing is disabled altogether + return compile_pcre2(translated.value(), PCRE2_UTF | PCRE2_UCP | + PCRE2_NO_AUTO_CAPTURE | + PCRE2_MATCH_INVALID_UTF); + } + if (pattern == ".*" || pattern == "^.*$" || pattern == "^(.*)$" || pattern == "(.*)" || pattern == "[\\s\\S]*" || pattern == "^[\\s\\S]*$") { return RegexTypeNoop{}; @@ -64,30 +155,17 @@ auto to_regex(const std::string_view pattern) -> std::optional { return RegexTypeRange{minimum, maximum}; } - const auto preprocessed{preprocess_regex(std::string{pattern})}; - if (!preprocessed.transformed.has_value()) { + const auto translated{translate_permissive(std::string{pattern})}; + if (!translated.transformed.has_value()) { return std::nullopt; } - int pcre2_error_code{0}; - PCRE2_SIZE pcre2_error_offset{0}; - pcre2_code *pcre2_regex_raw{pcre2_compile( - reinterpret_cast(preprocessed.transformed.value().c_str()), - preprocessed.transformed.value().size(), - // Capturing groups are kept enabled so that ECMA-262 numbered - // backreferences like (a)\1 compile and match, at a small tracking cost - PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | PCRE2_DOLLAR_ENDONLY | - PCRE2_NEVER_BACKSLASH_C | PCRE2_MATCH_INVALID_UTF | - PCRE2_ALLOW_EMPTY_CLASS, - &pcre2_error_code, &pcre2_error_offset, nullptr)}; - - if (pcre2_regex_raw != nullptr) { - std::shared_ptr pcre2_regex{pcre2_regex_raw, pcre2_code_free}; - pcre2_jit_compile(pcre2_regex.get(), PCRE2_JIT_COMPLETE); - return RegexTypePCRE2{std::shared_ptr(pcre2_regex)}; - } - - return std::nullopt; + // Capturing groups are kept enabled so that ECMA-262 numbered + // backreferences like (a)\1 compile and match, at a small tracking cost + return compile_pcre2(translated.transformed.value(), + PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | + PCRE2_DOLLAR_ENDONLY | PCRE2_NEVER_BACKSLASH_C | + PCRE2_MATCH_INVALID_UTF | PCRE2_ALLOW_EMPTY_CLASS); } auto matches(const Regex ®ex, const std::string_view value) -> bool { @@ -128,35 +206,30 @@ auto matches(const Regex ®ex, const std::string_view value) -> bool { } auto matches_if_valid(const std::string_view pattern, - const std::string_view value) -> bool { - const auto regex{to_regex(pattern)}; + const std::string_view value, const RegexDialect dialect) + -> bool { + const auto regex{to_regex(pattern, dialect)}; return regex.has_value() && matches(regex.value(), value); } auto is_regex_ecma(const std::string_view pattern) -> bool { - const auto preprocessed{preprocess_regex(std::string{pattern})}; - if (!preprocessed.ecma_valid || !preprocessed.transformed.has_value()) { + const auto translated{translate_permissive(std::string{pattern})}; + if (!translated.ecma_valid || !translated.transformed.has_value()) { return false; } - int pcre2_error_code{0}; - PCRE2_SIZE pcre2_error_offset{0}; - pcre2_code *pcre2_regex_raw{pcre2_compile( - reinterpret_cast(preprocessed.transformed.value().c_str()), - preprocessed.transformed.value().size(), - // Capturing groups are kept enabled so that ECMA-262 numbered - // backreferences like (a)\1 compile and match, at a small tracking cost - PCRE2_UTF | PCRE2_UCP | PCRE2_DOTALL | PCRE2_DOLLAR_ENDONLY | - PCRE2_NEVER_BACKSLASH_C | PCRE2_MATCH_INVALID_UTF | - PCRE2_ALLOW_EMPTY_CLASS, - &pcre2_error_code, &pcre2_error_offset, nullptr)}; - - if (pcre2_regex_raw == nullptr) { - return false; + if (compiles_with_ecma_options(translated.transformed.value())) { + return true; } - pcre2_code_free(pcre2_regex_raw); - return true; + // ECMA-262 places no width restriction on lookbehind assertions, while + // PCRE2 only accepts lookbehinds whose maximum width is bounded. As + // lookbehind and lookahead bodies follow the same grammar, the syntax + // check is retried with every lookbehind turned into a lookahead + const auto rewritten{ + lookbehinds_as_lookaheads(translated.transformed.value())}; + return rewritten != translated.transformed.value() && + compiles_with_ecma_options(rewritten); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/uri/CMakeLists.txt b/vendor/core/src/core/uri/CMakeLists.txt index 586a592f..3be0240c 100644 --- a/vendor/core/src/core/uri/CMakeLists.txt +++ b/vendor/core/src/core/uri/CMakeLists.txt @@ -13,6 +13,6 @@ target_link_libraries(sourcemeta_core_uri target_link_libraries(sourcemeta_core_uri PRIVATE sourcemeta::core::ip) target_link_libraries(sourcemeta_core_uri - PRIVATE sourcemeta::core::text) + PUBLIC sourcemeta::core::text) target_link_libraries(sourcemeta_core_uri PRIVATE sourcemeta::core::unicode) diff --git a/vendor/core/src/core/uri/escape.cc b/vendor/core/src/core/uri/escape.cc index fadd35b4..8d704ac4 100644 --- a/vendor/core/src/core/uri/escape.cc +++ b/vendor/core/src/core/uri/escape.cc @@ -2,58 +2,12 @@ #include #include "escaping.h" -#include "grammar.h" -#include // std::size_t -#include // std::int8_t #include // std::string #include // std::string_view namespace sourcemeta::core { -auto URI::escape(const std::string_view input, std::string &output, - const bool maybe_encoded) -> void { - output.reserve(output.size() + input.size() * 3); - if (!maybe_encoded) { - for (const auto character : input) { - if (uri_is_unreserved(character)) { - output += character; - } else { - uri_percent_encode_byte(output, static_cast(character)); - } - } - - return; - } - - // Treat the input as possibly already encoded, decoding each octet before - // re-encoding so that a valid escape survives and a needlessly encoded - // unreserved octet is decoded - for (std::size_t position = 0; position < input.size();) { - const auto high{input[position] == URI_PERCENT && - position + 2 < input.size() - ? hex_digit_value(input[position + 1]) - : static_cast(-1)}; - const auto low{high < 0 ? static_cast(-1) - : hex_digit_value(input[position + 2])}; - - unsigned char byte{}; - if (low < 0) { - byte = static_cast(input[position]); - position += 1; - } else { - byte = static_cast((high << 4) | low); - position += 3; - } - - if (uri_is_unreserved(static_cast(byte))) { - output += static_cast(byte); - } else { - uri_percent_encode_byte(output, byte); - } - } -} - auto URI::escape(const std::string_view input, const bool maybe_encoded) -> std::string { std::string result; 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 52662990..a0e5b17c 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -5,6 +5,8 @@ #include #endif +#include + // NOLINTBEGIN(misc-include-cleaner) #include // NOLINTEND(misc-include-cleaner) @@ -778,10 +780,11 @@ class SOURCEMETA_CORE_URI_EXPORT URI { [[nodiscard]] static auto escape(std::string_view input, bool maybe_encoded = false) -> std::string; - /// Percent-encode a string per RFC 3986, appending the result to an existing - /// string rather than allocating a new one, optionally treating the input as - /// possibly already encoded. The output must not alias the input. For - /// example: + /// Percent-encode a string per RFC 3986, appending the result to a string + /// like output sink rather than allocating a new string. Besides a + /// `std::string` the sink can be a wiping string for secret material. The + /// input can optionally be treated as possibly already encoded. The output + /// must not alias the input. For example: /// /// ```cpp /// #include @@ -792,8 +795,44 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// sourcemeta::core::URI::escape("foo bar", output); /// assert(output == "key=foo%20bar"); /// ``` - static auto escape(std::string_view input, std::string &output, - bool maybe_encoded = false) -> void; + template + static auto escape(const std::string_view input, Output &output, + const bool maybe_encoded = false) -> void { + output.reserve(output.size() + input.size() * 3); + for (std::size_t position = 0; position < input.size();) { + auto byte{static_cast(input[position])}; + std::size_t advance{1}; + // Treat the input as possibly already encoded by decoding each valid + // escape before re-encoding, so a valid escape survives and a needlessly + // encoded unreserved octet is decoded + if (maybe_encoded && input[position] == '%' && + position + 2 < input.size()) { + const auto high{hex_digit_value(input[position + 1])}; + const auto low{high < 0 ? static_cast(-1) + : hex_digit_value(input[position + 2])}; + if (low >= 0) { + byte = static_cast((high << 4) | low); + advance = 3; + } + } + + position += advance; + // RFC 3986 Section 2.3: the unreserved set passes through unescaped + if (is_alphanum(static_cast(byte)) || byte == '-' || byte == '.' || + byte == '_' || byte == '~') { + output.push_back(static_cast(byte)); + } else { + // RFC 3986 Section 2.1: percent-encode with uppercase hexadecimal + const auto high{static_cast((byte >> 4U) & 0x0FU)}; + const auto low{static_cast(byte & 0x0FU)}; + output.push_back('%'); + output.push_back( + static_cast(high < 10 ? '0' + high : 'A' + high - 10)); + output.push_back( + static_cast(low < 10 ? '0' + low : 'A' + low - 10)); + } + } + } /// Percent-decode every escape sequence in a string per RFC 3986, leaving /// malformed sequences untouched. For example: @@ -807,6 +846,56 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` [[nodiscard]] static auto unescape(std::string_view input) -> std::string; + /// Decode an "application/x-www-form-urlencoded" component (RFC 6749 Appendix + /// B and the HTML URL-encoded form syntax), appending the decoded bytes to + /// the output. Besides a `std::string` the sink can be a wiping string for a + /// secret such as a decoded client credential. Each "+" becomes a space and + /// each "%" followed by two hexadecimal digits becomes its octet. A "%" that + /// is not followed by two hexadecimal digits is rejected, returning false + /// with the output restored to its original contents. The output must not + /// alias the input. For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// std::string output; + /// assert(sourcemeta::core::URI::unescape_form("a+b%2Fc", output)); + /// assert(output == "a b/c"); + /// ``` + template + [[nodiscard]] static auto unescape_form(const std::string_view input, + Output &output) -> bool { + const auto base{output.size()}; + output.reserve(base + input.size()); + for (std::size_t position = 0; position < input.size();) { + const auto character{input[position]}; + if (character == '+') { + output.push_back(' '); + position += 1; + } else if (character == '%') { + const auto high{position + 2 < input.size() + ? hex_digit_value(input[position + 1]) + : static_cast(-1)}; + const auto low{high < 0 ? static_cast(-1) + : hex_digit_value(input[position + 2])}; + if (low < 0) { + output.resize(base, '\0'); + return false; + } + + output.push_back(static_cast((high << 4) | low)); + position += 3; + } else { + output.push_back(character); + position += 1; + } + } + + return true; + } + /// Remove the "." and ".." segments from a URI path per RFC 3986 Section /// 5.2.4, preserving leading ".." segments in a relative path. For example: /// diff --git a/vendor/core/src/lang/numeric/big_coefficient.h b/vendor/core/src/lang/numeric/big_coefficient.h index ab250b3f..1597427d 100644 --- a/vendor/core/src/lang/numeric/big_coefficient.h +++ b/vendor/core/src/lang/numeric/big_coefficient.h @@ -197,8 +197,20 @@ class BigCoefficient { } if (this->words[0] != 0) { + // Each word holds a fixed slice of the whole number, so dividing by 10 + // must carry the remainder of every higher word into the word below while (this->words[0] % 10 == 0) { - this->words[0] /= 10; + std::uint64_t borrow = 0; + for (auto index = this->length; index > 0; index--) { + const auto word = this->words[index - 1]; + this->words[index - 1] = word / 10 + borrow * (BASE / 10); + borrow = word % 10; + } + + if (this->length > 1 && this->words[this->length - 1] == 0) { + this->length--; + } + total_stripped++; } }