From 7fa0d1fafb54b818b51b9b1f30ac771d04e67dbe Mon Sep 17 00:00:00 2001 From: Praveen Babu J D Date: Fri, 28 Aug 2026 16:04:43 -0700 Subject: [PATCH 1/3] Fix CI policy and Linux test build Co-authored-by: TRAE CLI --- .github/workflows/ci.yml | 4 ---- pjsontest/src/tests_streaming.cpp | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb6d3f3..44da94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,10 +168,6 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 - with: - arch: x64 - - name: Configure run: cmake -S . -B out/build-msvc -A x64 diff --git a/pjsontest/src/tests_streaming.cpp b/pjsontest/src/tests_streaming.cpp index 4ed91b4..ae11e83 100644 --- a/pjsontest/src/tests_streaming.cpp +++ b/pjsontest/src/tests_streaming.cpp @@ -19,6 +19,7 @@ #include "test_harness.h" #include "test_util.h" +#include #include #include #include From 604edc7e8628bf1be95e07ac4bab31cbe77f3576 Mon Sep 17 00:00:00 2001 From: Praveen Babu J D Date: Mon, 31 Aug 2026 11:00:43 -0700 Subject: [PATCH 2/3] Fix cross-platform packaging and schema depth safety Co-authored-by: TRAE CLI --- CHANGELOG.md | 4 + README.md | 7 +- cmake/RunInstallConsumer.cmake | 25 ++- docs/06-schema-validation.md | 4 +- docs/reference/pjson-api.dox | 11 ++ examples/src/06_schema_validation.cpp | 2 +- pjsonlib/CMakeLists.txt | 9 +- pjsonlib/include/pjson.h | 3 +- pjsonlib/src/pjson.cpp | 220 +++++++++++++--------- pjsontest/src/tests_schema_vocabulary.cpp | 43 ++++- 10 files changed, 215 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d930f26..9dc6d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow - Correct schema Unicode-length counting, numeric-bound and `multipleOf` precision behavior, malformed keyword handling, format validation, and speculative combinator error reporting. +- Prevent schema-validation stack exhaustion by resolving consecutive local + references iteratively and enforcing a conservative recursive-depth ceiling. +- Make relocatable pkg-config metadata generation portable to Windows and + nested installation library directories. - Preserve destination state and structured errors on allocation, output-budget, and Patch/Merge Patch failures. diff --git a/README.md b/README.md index 21ba3b6..b29818d 100644 --- a/README.md +++ b/README.md @@ -937,12 +937,13 @@ Notes: limits pattern and subject sizes and rejects unsafe expressions. - `SchemaOptions` defaults `maxRegexPatternBytes` to 256, `maxRegexSubjectBytes` to 4096, `allowUnsafeRegex` to `false`, - `maxValidationDepth` to 512, `maxRefResolutions` to 1024, + `maxValidationDepth` to 128, `maxRefResolutions` to 1024, `maxValidationWork` to 1,000,000, `maxErrors` to 100, and `validateFormats` to `true`. Zero removes only a regex byte limit; zero for a validation, reference, work, or error budget retains its documented hard - ceiling. `trustedRegex()` removes only the regex limits/safety screen; reserve - it for trusted schemas and data. + ceiling. Validation depth has an absolute hard ceiling of 128, so larger + configured values are clamped to 128. `trustedRegex()` removes only the regex + limits/safety screen; reserve it for trusted schemas and data. This is the documented pjson subset, not a complete JSON Schema draft. See [the schema tutorial](docs/06-schema-validation.md) and the diff --git a/cmake/RunInstallConsumer.cmake b/cmake/RunInstallConsumer.cmake index a9f1a35..1c8c916 100644 --- a/cmake/RunInstallConsumer.cmake +++ b/cmake/RunInstallConsumer.cmake @@ -121,15 +121,15 @@ run_checked("pjson package install" file(RENAME "${stage_prefix}" "${relocated_prefix}") file(GLOB_RECURSE config_files LIST_DIRECTORIES FALSE - "${relocated_prefix}/*/cmake/pjson/pjsonConfig.cmake") + "${relocated_prefix}/*/pjsonConfig.cmake") file(GLOB_RECURSE version_files LIST_DIRECTORIES FALSE - "${relocated_prefix}/*/cmake/pjson/pjsonConfigVersion.cmake") + "${relocated_prefix}/*/pjsonConfigVersion.cmake") file(GLOB_RECURSE target_files LIST_DIRECTORIES FALSE - "${relocated_prefix}/*/cmake/pjson/pjsonTargets.cmake") + "${relocated_prefix}/*/pjsonTargets.cmake") file(GLOB_RECURSE package_cmake_files LIST_DIRECTORIES FALSE - "${relocated_prefix}/*/cmake/pjson/*.cmake") + "${relocated_prefix}/*/pjson/*.cmake") file(GLOB_RECURSE pc_files LIST_DIRECTORIES FALSE - "${relocated_prefix}/*/pkgconfig/pjson.pc") + "${relocated_prefix}/*/pjson.pc") file(GLOB_RECURSE library_files LIST_DIRECTORIES FALSE "${relocated_prefix}/libpjson.*" "${relocated_prefix}/libpjson*.dylib" @@ -144,6 +144,19 @@ endforeach() if(NOT EXISTS "${relocated_prefix}/include/pjson.h") message(FATAL_ERROR "Installed package is missing include/pjson.h") endif() +list(GET pc_files 0 pc_file) +get_filename_component(pc_dir "${pc_file}" DIRECTORY) +file(RELATIVE_PATH pc_dir_from_prefix "${relocated_prefix}" "${pc_dir}") +set(expected_pc_prefix ".") +cmake_path(RELATIVE_PATH expected_pc_prefix + BASE_DIRECTORY "${pc_dir_from_prefix}") +cmake_path(NORMAL_PATH expected_pc_prefix) +file(STRINGS "${pc_file}" pc_prefix_line REGEX "^prefix=") +if(NOT pc_prefix_line STREQUAL + "prefix=\${pcfiledir}/${expected_pc_prefix}") + message(FATAL_ERROR + "Installed pkg-config prefix is not relative to pcfiledir: ${pc_prefix_line}") +endif() foreach(metadata_file IN LISTS package_cmake_files pc_files) file(READ "${metadata_file}" metadata_contents) string(FIND "${metadata_contents}" "${stage_prefix}" old_prefix_position) @@ -183,8 +196,6 @@ if(NOT DEFINED PJSON_PKG_CONFIG_EXECUTABLE OR find_program(PJSON_PKG_CONFIG_EXECUTABLE NAMES pkg-config pkgconf) endif() if(PJSON_PKG_CONFIG_EXECUTABLE) - list(GET pc_files 0 pc_file) - get_filename_component(pc_dir "${pc_file}" DIRECTORY) run_checked("relocated pkg-config validation" "${CMAKE_COMMAND}" -E env "PKG_CONFIG_PATH=${pc_dir}" diff --git a/docs/06-schema-validation.md b/docs/06-schema-validation.md index e9ca4b7..37f4102 100644 --- a/docs/06-schema-validation.md +++ b/docs/06-schema-validation.md @@ -137,7 +137,7 @@ pjson::SchemaOptions options; options.maxRegexPatternBytes = 256; options.maxRegexSubjectBytes = 4096; options.allowUnsafeRegex = false; -options.maxValidationDepth = 512; +options.maxValidationDepth = 128; options.maxRefResolutions = 1024; options.maxValidationWork = 1000000; options.maxErrors = 100; @@ -151,6 +151,8 @@ These are the defaults. A zero regex byte limit disables that individual regex limit and should be reserved for trusted input. Zero for the validation-depth, reference-resolution, work, or error-count budget retains that budget's documented hard ceiling rather than disabling it. +Validation depth has an absolute hard ceiling of 128; larger configured values +are clamped to 128 to bound native-stack use during recursive keyword evaluation. `SchemaOptions::trustedRegex()` disables both regex byte limits and permits unsafe regular expressions while retaining all other defaults. Set `validateFormats = false` when known formats should act only as annotations. diff --git a/docs/reference/pjson-api.dox b/docs/reference/pjson-api.dox index 6de48fc..ff61eec 100644 --- a/docs/reference/pjson-api.dox +++ b/docs/reference/pjson-api.dox @@ -75,6 +75,17 @@ * extend the allocator's lifetime. */ +/** + * @struct ByteDance::pjson::SchemaOptions + * @brief Bounds schema-validation work and controls optional format checks. + * + * Recursive validation depth defaults to an absolute hard ceiling of 128. A + * zero value selects that ceiling, and larger values are clamped to it so no + * caller configuration can make recursive keyword evaluation exceed the + * conservative native-stack bound. Other zero-valued validation budgets retain their + * documented hard ceilings; only regex byte limits use zero as unlimited. + */ + /** * @struct ByteDance::pjson::PatchOptions * @brief Bounds JSON Patch and Merge Patch transactional amplification. diff --git a/examples/src/06_schema_validation.cpp b/examples/src/06_schema_validation.cpp index 822b17a..5137e05 100644 --- a/examples/src/06_schema_validation.cpp +++ b/examples/src/06_schema_validation.cpp @@ -55,7 +55,7 @@ int main() { // These limits bound traversal and reference work. Known string formats, // such as the date above, are checked because validateFormats is enabled. pjson::SchemaOptions options; - options.maxValidationDepth = 512; + options.maxValidationDepth = 128; options.maxRefResolutions = 1024; options.validateFormats = true; std::cout << "good is valid: " << (good->validate(*schema, options) ? "yes" : "no") << "\n"; diff --git a/pjsonlib/CMakeLists.txt b/pjsonlib/CMakeLists.txt index fb216c7..251e61c 100644 --- a/pjsonlib/CMakeLists.txt +++ b/pjsonlib/CMakeLists.txt @@ -87,8 +87,13 @@ install(FILES # prefix. This keeps the metadata valid after an installed tree is relocated, # including when CMAKE_INSTALL_LIBDIR is changed to lib64 or a nested path. set(PJSON_INSTALL_PKGCONFIGDIR "${CMAKE_INSTALL_LIBDIR}/pkgconfig") -file(RELATIVE_PATH PJSON_PC_PREFIX_FROM_PCFILEDIR - "/${PJSON_INSTALL_PKGCONFIGDIR}" "/") +# Keep this calculation lexical and relative. Synthesizing a root with a +# leading slash is not a native absolute path on Windows, so file(RELATIVE_PATH) +# rejects it during configuration. +set(PJSON_PC_PREFIX_FROM_PCFILEDIR ".") +cmake_path(RELATIVE_PATH PJSON_PC_PREFIX_FROM_PCFILEDIR + BASE_DIRECTORY "${PJSON_INSTALL_PKGCONFIGDIR}") +cmake_path(NORMAL_PATH PJSON_PC_PREFIX_FROM_PCFILEDIR) configure_file( "${CMAKE_CURRENT_LIST_DIR}/../cmake/pjson.pc.in" "${CMAKE_CURRENT_BINARY_DIR}/pjson.pc" diff --git a/pjsonlib/include/pjson.h b/pjsonlib/include/pjson.h index 8293cb9..d0a7880 100644 --- a/pjsonlib/include/pjson.h +++ b/pjsonlib/include/pjson.h @@ -302,7 +302,8 @@ namespace ByteDance { size_t maxRegexPatternBytes; // 0 = unlimited (default: 256) size_t maxRegexSubjectBytes; // 0 = unlimited (default: 4096) bool allowUnsafeRegex; // default false - /// Recursive depth (default 512); zero still selects the hard ceiling of 512. + /// Recursive validation depth (default and absolute hard ceiling: 128). + /// Zero selects 128, and larger values are clamped to 128. size_t maxValidationDepth; /// Resolved references (default 1024); zero selects the hard ceiling of 1024. size_t maxRefResolutions; diff --git a/pjsonlib/src/pjson.cpp b/pjsonlib/src/pjson.cpp index 8631382..756b643 100644 --- a/pjsonlib/src/pjson.cpp +++ b/pjsonlib/src/pjson.cpp @@ -277,6 +277,12 @@ typedef pjson::ParseError ParseError; typedef pjson::SaxHandler SaxHandler; typedef pjsonImpl::ParseCtx ParseCtx; +// Schema validation still uses native recursion for applicator keywords. Keep +// its logical depth below a conservative stack-safe ceiling even when callers +// request a larger value. Consecutive local references are resolved iteratively +// but continue to consume this same logical-depth budget. +static const size_t kSchemaValidationDepthHardLimit = 128; + namespace { //===------------------------------------------------------------------===// // Parse diagnostics and SAX cursor adapters @@ -1212,7 +1218,7 @@ pjson::SchemaOptions::SchemaOptions() : maxRegexPatternBytes(256) , maxRegexSubjectBytes(4096) , allowUnsafeRegex(false) - , maxValidationDepth(512) + , maxValidationDepth(kSchemaValidationDepthHardLimit) , maxRefResolutions(1024) , maxValidationWork(1000000) , maxErrors(100) @@ -5276,22 +5282,34 @@ namespace { // Balances the shared recursion counter across every return and exception. struct SchemaDepthGuard { pjsonImpl::SchemaValidationCtx& ctx; + size_t levels; explicit SchemaDepthGuard(pjsonImpl::SchemaValidationCtx& aCtx) - : ctx(aCtx) { + : ctx(aCtx) + , levels(1) { + ++ctx.depth; + } + void enterResolvedReference() { ++ctx.depth; + ++levels; } - ~SchemaDepthGuard() { --ctx.depth; } + ~SchemaDepthGuard() { ctx.depth -= levels; } }; - // Keeps one (instance, resolved-schema) pair active only for its recursive call. + // Keeps every iteratively resolved (instance, schema) pair active until the + // terminal schema has been evaluated, matching nested-call cycle semantics. struct ActiveRefGuard { std::vector>& refs; - ActiveRefGuard(std::vector>& aRefs, const pjson* node, - const pjson* schema) - : refs(aRefs) { + const size_t initialSize; + explicit ActiveRefGuard(std::vector>& aRefs) + : refs(aRefs) + , initialSize(aRefs.size()) {} + void push(const pjson* node, const pjson* schema) { refs.push_back(std::make_pair(node, schema)); } - ~ActiveRefGuard() { refs.pop_back(); } + ~ActiveRefGuard() { + while (refs.size() > initialSize) + refs.pop_back(); + } }; // Aborts all remaining branches and ensures a budget failure reaches the @@ -5314,7 +5332,9 @@ namespace { } size_t validationDepthLimit(const SchemaOptions& options) { - return options.maxValidationDepth == 0 ? size_t(512) : options.maxValidationDepth; + const size_t requested = options.maxValidationDepth == 0 ? kSchemaValidationDepthHardLimit + : options.maxValidationDepth; + return std::min(requested, kSchemaValidationDepthHardLimit); } size_t validationRefLimit(const SchemaOptions& options) { @@ -5513,75 +5533,87 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std return false; } SchemaDepthGuard depthGuard(aCtx); - - // A boolean schema accepts (true) or rejects (false) everything. - if (aSchema.isBool()) { - if (!pjsonImpl::_boolean(aSchema)) { - aErrors.push_back(SchemaError(aPath, "schema is false; no value is valid here")); - return false; + ActiveRefGuard activeRefGuard(aCtx.activeRefs); + const pjson* currentSchema = &aSchema; + + // Resolve consecutive local references without consuming native stack. + // Each hop still behaves like a logical _validateCtx invocation: it charges + // work, enters the depth budget, and keeps its active pair until the final + // target has been evaluated. Draft-07 reference objects ignore siblings. + for (;;) { + // A boolean schema accepts (true) or rejects (false) everything. + if (currentSchema->isBool()) { + if (!pjsonImpl::_boolean(*currentSchema)) { + aErrors.push_back(SchemaError(aPath, "schema is false; no value is valid here")); + return false; + } + return true; } - return true; - } - // Only object schemas carry keywords; anything else is treated as "accept". - if (!aSchema.isObject()) { - return true; - } + // Only object schemas carry keywords; anything else is treated as "accept". + if (!currentSchema->isObject()) + return true; - const size_t before = aErrors.size(); + // Draft-07 treats an object containing a string $ref as a reference + // object: all sibling keywords are ignored. Only same-document fragment + // references are supported; percent-decoding precedes RFC 6901 decoding. + const pjson* ref = currentSchema->find("$ref"); + if (ref == nullptr || !ref->isString()) + break; - // Draft-07 treats an object containing $ref as a reference object: all - // sibling keywords are ignored. Only same-document fragment references - // are supported; percent-decoding precedes RFC 6901 token decoding. - if (const pjson* ref = aSchema.find("$ref")) { - if (ref->isString()) { - const std::string refText = pjsonImpl::_string(*ref); - if (refText.empty() || refText[0] == '#') { - if (aCtx.refResolutions >= validationRefLimit(aCtx.options)) { - failValidationBudget(aCtx, aErrors, aPath, - "schema $ref resolution budget exceeded"); - return false; - } - ++aCtx.refResolutions; + const std::string refText = pjsonImpl::_string(*ref); + if (!refText.empty() && refText[0] != '#') { + aErrors.push_back(SchemaError(aPath, "non-local $ref is not supported: " + refText)); + return false; + } + if (aCtx.refResolutions >= validationRefLimit(aCtx.options)) { + failValidationBudget(aCtx, aErrors, aPath, "schema $ref resolution budget exceeded"); + return false; + } + ++aCtx.refResolutions; - std::string pointer; - const std::string fragment = refText.empty() ? std::string() : refText.substr(1); - if (!decodeSchemaFragment(fragment, pointer)) { - aErrors.push_back( - SchemaError(aPath, "malformed local $ref fragment: " + refText)); - return false; - } + std::string pointer; + const std::string fragment = refText.empty() ? std::string() : refText.substr(1); + if (!decodeSchemaFragment(fragment, pointer)) { + aErrors.push_back(SchemaError(aPath, "malformed local $ref fragment: " + refText)); + return false; + } - pjson::PointerError pointerError; - const pjson* target = aCtx.rootSchema.findPointer(pointer, pointerError); - if (target == nullptr) { - const bool malformed = - pointerError.code == pjson::PointerError::InvalidSyntax || - pointerError.code == pjson::PointerError::InvalidEscape || - pointerError.code == pjson::PointerError::InvalidArrayIndex || - pointerError.code == pjson::PointerError::AppendTokenNotAllowed; - aErrors.push_back( - SchemaError(aPath, std::string(malformed ? "malformed" : "unresolved") + - " local $ref: " + refText)); - return false; - } + pjson::PointerError pointerError; + const pjson* target = aCtx.rootSchema.findPointer(pointer, pointerError); + if (target == nullptr) { + const bool malformed = pointerError.code == pjson::PointerError::InvalidSyntax || + pointerError.code == pjson::PointerError::InvalidEscape || + pointerError.code == pjson::PointerError::InvalidArrayIndex || + pointerError.code == pjson::PointerError::AppendTokenNotAllowed; + aErrors.push_back( + SchemaError(aPath, std::string(malformed ? "malformed" : "unresolved") + + " local $ref: " + refText)); + return false; + } - const std::pair active(&aNode, target); - if (std::find(aCtx.activeRefs.begin(), aCtx.activeRefs.end(), active) != - aCtx.activeRefs.end()) { - aErrors.push_back(SchemaError(aPath, "local $ref cycle detected: " + refText)); - return false; - } - ActiveRefGuard refGuard(aCtx.activeRefs, &aNode, target); - return _validateCtx(aNode, *target, aPath, aErrors, aCtx); - } + const std::pair active(&aNode, target); + if (std::find(aCtx.activeRefs.begin(), aCtx.activeRefs.end(), active) != + aCtx.activeRefs.end()) { + aErrors.push_back(SchemaError(aPath, "local $ref cycle detected: " + refText)); + return false; + } + activeRefGuard.push(&aNode, target); - aErrors.push_back(SchemaError(aPath, "non-local $ref is not supported: " + refText)); + if (!chargeValidationWork(aCtx, aErrors, aPath)) + return false; + if (aCtx.depth >= validationDepthLimit(aCtx.options)) { + failValidationBudget(aCtx, aErrors, aPath, "schema validation depth budget exceeded"); return false; } + depthGuard.enterResolvedReference(); + currentSchema = target; } + const pjson& schema = *currentSchema; + const size_t before = aErrors.size(); + // ---- type ---- - if (const pjson* t = aSchema.find("type")) { + if (const pjson* t = schema.find("type")) { if (t->isString()) { if (!_typeMatches(aNode, pjsonImpl::_string(*t))) { aErrors.push_back(SchemaError(aPath, "expected type " + pjsonImpl::_string(*t) + @@ -5612,7 +5644,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } // ---- const ---- - if (const pjson* cst = aSchema.find("const")) { + if (const pjson* cst = schema.find("const")) { bool equal = false; if (!_equalWithBudget(aNode, *cst, aCtx, aErrors, aPath, equal)) return false; @@ -5622,7 +5654,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } // ---- enum ---- - if (const pjson* en = aSchema.find("enum")) { + if (const pjson* en = schema.find("enum")) { if (en->isArray()) { bool found = false; for (const pjson* opt : pjsonImpl::_array(*en)) { @@ -5642,14 +5674,14 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std // ---- numeric constraints ---- if (aNode.isNumber()) { - if (const pjson* m = aSchema.find("minimum")) { + if (const pjson* m = schema.find("minimum")) { if (m->isNumber() && _compareNumbers(aNode, *m) < 0) { addSchemaError(aCtx, aErrors, aPath, "value " + formatNumber(aNode) + " is below minimum " + formatNumber(*m)); } } - if (const pjson* m = aSchema.find("maximum")) { + if (const pjson* m = schema.find("maximum")) { const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; if (comparison != 2 && comparison > 0) { addSchemaError(aCtx, aErrors, aPath, @@ -5657,7 +5689,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std formatNumber(*m)); } } - if (const pjson* m = aSchema.find("exclusiveMinimum")) { + if (const pjson* m = schema.find("exclusiveMinimum")) { const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; if (comparison <= 0) { addSchemaError(aCtx, aErrors, aPath, @@ -5665,7 +5697,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std " is not greater than exclusiveMinimum " + formatNumber(*m)); } } - if (const pjson* m = aSchema.find("exclusiveMaximum")) { + if (const pjson* m = schema.find("exclusiveMaximum")) { const int comparison = m->isNumber() ? _compareNumbers(aNode, *m) : 2; if (comparison != 2 && comparison >= 0) { addSchemaError(aCtx, aErrors, aPath, @@ -5673,7 +5705,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std " is not less than exclusiveMaximum " + formatNumber(*m)); } } - if (const pjson* m = aSchema.find("multipleOf")) { + if (const pjson* m = schema.find("multipleOf")) { if (m->isNumber() && !isExactMultiple(aNode, *m)) { addSchemaError(aCtx, aErrors, aPath, "value " + formatNumber(aNode) + " is not a multiple of " + @@ -5688,7 +5720,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std size_t length = 0; if (!unicodeLength(s, aCtx, aErrors, aPath, length)) return false; - if (const pjson* m = aSchema.find("minLength")) { + if (const pjson* m = schema.find("minLength")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && (aboveRange || length < bound)) @@ -5696,7 +5728,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std "string length " + std::to_string(length) + " is below minLength " + formatNumber(*m)); } - if (const pjson* m = aSchema.find("maxLength")) { + if (const pjson* m = schema.find("maxLength")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && !aboveRange && length > bound) @@ -5704,7 +5736,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std "string length " + std::to_string(length) + " is above maxLength " + formatNumber(*m)); } - if (const pjson* p = aSchema.find("pattern")) { + if (const pjson* p = schema.find("pattern")) { if (p->isString()) { const std::string pattern = pjsonImpl::_string(*p); bool matches = false; @@ -5714,7 +5746,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } if (aCtx.options.validateFormats) { - if (const pjson* format = aSchema.find("format")) { + if (const pjson* format = schema.find("format")) { if (format->isString()) { bool known = false; if (!knownFormatValid(pjsonImpl::_string(*format), s, known) && known) @@ -5729,7 +5761,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std // ---- array constraints ---- if (aNode.isArray()) { const PJSONARRAY& arr = *aNode._uValue._pValueArray; - if (const pjson* m = aSchema.find("minItems")) { + if (const pjson* m = schema.find("minItems")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && (aboveRange || arr.size() < bound)) @@ -5737,7 +5769,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std "array has " + std::to_string(arr.size()) + " items, below minItems " + formatNumber(*m)); } - if (const pjson* m = aSchema.find("maxItems")) { + if (const pjson* m = schema.find("maxItems")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && !aboveRange && arr.size() > bound) @@ -5745,7 +5777,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std "array has " + std::to_string(arr.size()) + " items, above maxItems " + formatNumber(*m)); } - if (const pjson* u = aSchema.find("uniqueItems")) { + if (const pjson* u = schema.find("uniqueItems")) { if (u->isBool() && pjsonImpl::_boolean(*u)) { bool dup = false; for (size_t i = 0; i < arr.size() && !dup; ++i) { @@ -5764,7 +5796,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } } - if (const pjson* items = aSchema.find("items")) { + if (const pjson* items = schema.find("items")) { if (items->isArray()) { const PJSONARRAY& tuple = pjsonImpl::_array(*items); const size_t count = std::min(arr.size(), tuple.size()); @@ -5789,7 +5821,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std if (aNode.isObject()) { const PJSONMAP& obj = *aNode._uValue._pValueMap; - if (const pjson* req = aSchema.find("required")) { + if (const pjson* req = schema.find("required")) { if (req->isArray()) { for (const pjson* k : pjsonImpl::_array(*req)) { if (!chargeLoopWork(aCtx, aErrors, aPath)) @@ -5801,7 +5833,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } } - if (const pjson* m = aSchema.find("minProperties")) { + if (const pjson* m = schema.find("minProperties")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && (aboveRange || obj.size() < bound)) @@ -5809,7 +5841,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std "object has " + std::to_string(obj.size()) + " properties, below minProperties " + formatNumber(*m)); } - if (const pjson* m = aSchema.find("maxProperties")) { + if (const pjson* m = schema.find("maxProperties")) { size_t bound = 0; bool aboveRange = false; if (schemaSize(*m, bound, aboveRange) && !aboveRange && obj.size() > bound) @@ -5818,7 +5850,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std " properties, above maxProperties " + formatNumber(*m)); } - const pjson* props = aSchema.find("properties"); + const pjson* props = schema.find("properties"); if (props && props->isObject()) { for (const auto& kv : pjsonImpl::_object(*props)) { if (!chargeLoopWork(aCtx, aErrors, aPath)) @@ -5833,7 +5865,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } - const pjson* patternProps = aSchema.find("patternProperties"); + const pjson* patternProps = schema.find("patternProperties"); // A set avoids the prior O(properties * matches) membership scan when // additionalProperties is evaluated after patternProperties. std::set patternMatched; @@ -5856,7 +5888,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } - if (const pjson* propertyNames = aSchema.find("propertyNames")) { + if (const pjson* propertyNames = schema.find("propertyNames")) { for (const auto& kv : obj) { if (!chargeLoopWork(aCtx, aErrors, aPath)) return false; @@ -5869,7 +5901,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } - const pjson* dependentRequired = aSchema.find("dependentRequired"); + const pjson* dependentRequired = schema.find("dependentRequired"); if (dependentRequired && dependentRequired->isObject()) { for (const auto& dependency : pjsonImpl::_object(*dependentRequired)) { if (!chargeLoopWork(aCtx, aErrors, aPath)) @@ -5889,7 +5921,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } - const pjson* dependencies = aSchema.find("dependencies"); + const pjson* dependencies = schema.find("dependencies"); if (dependencies && dependencies->isObject()) { for (const auto& dependency : pjsonImpl::_object(*dependencies)) { if (!chargeLoopWork(aCtx, aErrors, aPath)) @@ -5916,7 +5948,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } - if (const pjson* addl = aSchema.find("additionalProperties")) { + if (const pjson* addl = schema.find("additionalProperties")) { for (const auto& kv : obj) { if (!chargeLoopWork(aCtx, aErrors, aPath)) return false; @@ -5946,7 +5978,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std // are speculative: branches validate into scratch vectors so only the // combinator-level outcome is exposed to callers. Budget aborts bypass that // isolation through failValidationBudget and stop all remaining work. - if (const pjson* allOf = aSchema.find("allOf")) { + if (const pjson* allOf = schema.find("allOf")) { if (allOf->isArray()) { for (const pjson* sub : pjsonImpl::_array(*allOf)) { if (!chargeLoopWork(aCtx, aErrors, aPath)) @@ -5957,7 +5989,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } } - if (const pjson* anyOf = aSchema.find("anyOf")) { + if (const pjson* anyOf = schema.find("anyOf")) { if (anyOf->isArray()) { bool any = false; for (const pjson* sub : pjsonImpl::_array(*anyOf)) { @@ -5977,7 +6009,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } } - if (const pjson* oneOf = aSchema.find("oneOf")) { + if (const pjson* oneOf = schema.find("oneOf")) { if (oneOf->isArray()) { int matches = 0; for (const pjson* sub : pjsonImpl::_array(*oneOf)) { @@ -5996,7 +6028,7 @@ bool pjsonImpl::_validateCtx(const pjson& aNode, const pjson& aSchema, const std } } } - const pjson* nots = aSchema.find("not"); + const pjson* nots = schema.find("not"); if (nots != nullptr && (nots->isBool() || nots->isObject())) { std::vector scratch; SchemaErrorSink scratchSink(scratch, aCtx, false); diff --git a/pjsontest/src/tests_schema_vocabulary.cpp b/pjsontest/src/tests_schema_vocabulary.cpp index 5635495..c422de2 100644 --- a/pjsontest/src/tests_schema_vocabulary.cpp +++ b/pjsontest/src/tests_schema_vocabulary.cpp @@ -284,20 +284,55 @@ TEST(schema_vocab_ref_resolution_budget) { CHECK(hasMessageContaining(errors, "budget")); } +TEST(schema_vocab_ref_chain_still_obeys_depth_budget) { + pjson::SchemaOptions opts = optionsWithDepthBudget(4); + opts.maxRefResolutions = 16; + const pjson schema = makeReferenceChainSchema(4); + pjson instance; + instance = int64_t(1); + + std::vector errors; + CHECK(!instance.validate(schema, errors, opts)); + CHECK(hasMessageContaining(errors, "depth")); + CHECK(hasMessageContaining(errors, "budget")); +} + TEST(schema_vocab_ref_zero_depth_uses_hard_ceiling) { pjson::SchemaOptions opts = optionsWithDepthBudget(0); - const pjson schema = makeNestedPropertySchema(520); - const pjson instance = makeNestedPropertyInstance(520); + CHECK_EQ(pjson::SchemaOptions().maxValidationDepth, size_t(128)); + const pjson schema = makeNestedPropertySchema(128); + const pjson instance = makeNestedPropertyInstance(128); std::vector errors; CHECK(!instance.validate(schema, errors, opts)); CHECK(hasMessageContaining(errors, "depth")); } +TEST(schema_vocab_ref_requested_depth_is_clamped_to_hard_ceiling) { + pjson::SchemaOptions opts = optionsWithDepthBudget(2048); + const pjson withinLimitSchema = makeNestedPropertySchema(127); + const pjson withinLimitInstance = makeNestedPropertyInstance(127); + const pjson schema = makeNestedPropertySchema(128); + const pjson instance = makeNestedPropertyInstance(128); + + CHECK(withinLimitInstance.validate(withinLimitSchema, opts)); + std::vector errors; + CHECK(!instance.validate(schema, errors, opts)); + CHECK(hasMessageContaining(errors, "depth")); +} + +TEST(schema_vocab_ref_zero_resolution_budget_allows_hard_ceiling) { + pjson::SchemaOptions opts = optionsWithRefBudget(0); + const pjson schema = makeBranchingWorkSchema(10); + pjson instance; + instance = int64_t(1); + + CHECK(instance.validate(schema, opts)); +} + TEST(schema_vocab_ref_zero_resolution_budget_uses_hard_ceiling) { pjson::SchemaOptions opts = optionsWithRefBudget(0); - opts.maxValidationDepth = 2048; - const pjson schema = makeReferenceChainSchema(1030); + const pjson schema = makeBranchingWorkSchema(11); pjson instance; instance = int64_t(1); From 951c74c9fcbf18a309e23be2ca9d575e91764e04 Mon Sep 17 00:00:00 2001 From: Praveen Babu J D Date: Mon, 31 Aug 2026 11:09:08 -0700 Subject: [PATCH 3/3] Use a cross-platform schema depth ceiling Co-authored-by: TRAE CLI --- README.md | 6 +++--- docs/06-schema-validation.md | 6 +++--- docs/reference/pjson-api.dox | 2 +- examples/src/06_schema_validation.cpp | 2 +- pjsonlib/include/pjson.h | 4 ++-- pjsonlib/src/pjson.cpp | 2 +- pjsontest/src/tests_schema_vocabulary.cpp | 16 ++++++++-------- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b29818d..e720c66 100644 --- a/README.md +++ b/README.md @@ -937,12 +937,12 @@ Notes: limits pattern and subject sizes and rejects unsafe expressions. - `SchemaOptions` defaults `maxRegexPatternBytes` to 256, `maxRegexSubjectBytes` to 4096, `allowUnsafeRegex` to `false`, - `maxValidationDepth` to 128, `maxRefResolutions` to 1024, + `maxValidationDepth` to 64, `maxRefResolutions` to 1024, `maxValidationWork` to 1,000,000, `maxErrors` to 100, and `validateFormats` to `true`. Zero removes only a regex byte limit; zero for a validation, reference, work, or error budget retains its documented hard - ceiling. Validation depth has an absolute hard ceiling of 128, so larger - configured values are clamped to 128. `trustedRegex()` removes only the regex + ceiling. Validation depth has an absolute hard ceiling of 64, so larger + configured values are clamped to 64. `trustedRegex()` removes only the regex limits/safety screen; reserve it for trusted schemas and data. This is the documented pjson subset, not a complete JSON Schema draft. See diff --git a/docs/06-schema-validation.md b/docs/06-schema-validation.md index 37f4102..91c783d 100644 --- a/docs/06-schema-validation.md +++ b/docs/06-schema-validation.md @@ -137,7 +137,7 @@ pjson::SchemaOptions options; options.maxRegexPatternBytes = 256; options.maxRegexSubjectBytes = 4096; options.allowUnsafeRegex = false; -options.maxValidationDepth = 128; +options.maxValidationDepth = 64; options.maxRefResolutions = 1024; options.maxValidationWork = 1000000; options.maxErrors = 100; @@ -151,8 +151,8 @@ These are the defaults. A zero regex byte limit disables that individual regex limit and should be reserved for trusted input. Zero for the validation-depth, reference-resolution, work, or error-count budget retains that budget's documented hard ceiling rather than disabling it. -Validation depth has an absolute hard ceiling of 128; larger configured values -are clamped to 128 to bound native-stack use during recursive keyword evaluation. +Validation depth has an absolute hard ceiling of 64; larger configured values +are clamped to 64 to bound native-stack use during recursive keyword evaluation. `SchemaOptions::trustedRegex()` disables both regex byte limits and permits unsafe regular expressions while retaining all other defaults. Set `validateFormats = false` when known formats should act only as annotations. diff --git a/docs/reference/pjson-api.dox b/docs/reference/pjson-api.dox index ff61eec..447783f 100644 --- a/docs/reference/pjson-api.dox +++ b/docs/reference/pjson-api.dox @@ -79,7 +79,7 @@ * @struct ByteDance::pjson::SchemaOptions * @brief Bounds schema-validation work and controls optional format checks. * - * Recursive validation depth defaults to an absolute hard ceiling of 128. A + * Recursive validation depth defaults to an absolute hard ceiling of 64. A * zero value selects that ceiling, and larger values are clamped to it so no * caller configuration can make recursive keyword evaluation exceed the * conservative native-stack bound. Other zero-valued validation budgets retain their diff --git a/examples/src/06_schema_validation.cpp b/examples/src/06_schema_validation.cpp index 5137e05..7ca9aaf 100644 --- a/examples/src/06_schema_validation.cpp +++ b/examples/src/06_schema_validation.cpp @@ -55,7 +55,7 @@ int main() { // These limits bound traversal and reference work. Known string formats, // such as the date above, are checked because validateFormats is enabled. pjson::SchemaOptions options; - options.maxValidationDepth = 128; + options.maxValidationDepth = 64; options.maxRefResolutions = 1024; options.validateFormats = true; std::cout << "good is valid: " << (good->validate(*schema, options) ? "yes" : "no") << "\n"; diff --git a/pjsonlib/include/pjson.h b/pjsonlib/include/pjson.h index d0a7880..097f012 100644 --- a/pjsonlib/include/pjson.h +++ b/pjsonlib/include/pjson.h @@ -302,8 +302,8 @@ namespace ByteDance { size_t maxRegexPatternBytes; // 0 = unlimited (default: 256) size_t maxRegexSubjectBytes; // 0 = unlimited (default: 4096) bool allowUnsafeRegex; // default false - /// Recursive validation depth (default and absolute hard ceiling: 128). - /// Zero selects 128, and larger values are clamped to 128. + /// Recursive validation depth (default and absolute hard ceiling: 64). + /// Zero selects 64, and larger values are clamped to 64. size_t maxValidationDepth; /// Resolved references (default 1024); zero selects the hard ceiling of 1024. size_t maxRefResolutions; diff --git a/pjsonlib/src/pjson.cpp b/pjsonlib/src/pjson.cpp index 756b643..5fbac7f 100644 --- a/pjsonlib/src/pjson.cpp +++ b/pjsonlib/src/pjson.cpp @@ -281,7 +281,7 @@ typedef pjsonImpl::ParseCtx ParseCtx; // its logical depth below a conservative stack-safe ceiling even when callers // request a larger value. Consecutive local references are resolved iteratively // but continue to consume this same logical-depth budget. -static const size_t kSchemaValidationDepthHardLimit = 128; +static const size_t kSchemaValidationDepthHardLimit = 64; namespace { //===------------------------------------------------------------------===// diff --git a/pjsontest/src/tests_schema_vocabulary.cpp b/pjsontest/src/tests_schema_vocabulary.cpp index c422de2..7c7c324 100644 --- a/pjsontest/src/tests_schema_vocabulary.cpp +++ b/pjsontest/src/tests_schema_vocabulary.cpp @@ -299,9 +299,9 @@ TEST(schema_vocab_ref_chain_still_obeys_depth_budget) { TEST(schema_vocab_ref_zero_depth_uses_hard_ceiling) { pjson::SchemaOptions opts = optionsWithDepthBudget(0); - CHECK_EQ(pjson::SchemaOptions().maxValidationDepth, size_t(128)); - const pjson schema = makeNestedPropertySchema(128); - const pjson instance = makeNestedPropertyInstance(128); + CHECK_EQ(pjson::SchemaOptions().maxValidationDepth, size_t(64)); + const pjson schema = makeNestedPropertySchema(64); + const pjson instance = makeNestedPropertyInstance(64); std::vector errors; CHECK(!instance.validate(schema, errors, opts)); @@ -310,10 +310,10 @@ TEST(schema_vocab_ref_zero_depth_uses_hard_ceiling) { TEST(schema_vocab_ref_requested_depth_is_clamped_to_hard_ceiling) { pjson::SchemaOptions opts = optionsWithDepthBudget(2048); - const pjson withinLimitSchema = makeNestedPropertySchema(127); - const pjson withinLimitInstance = makeNestedPropertyInstance(127); - const pjson schema = makeNestedPropertySchema(128); - const pjson instance = makeNestedPropertyInstance(128); + const pjson withinLimitSchema = makeNestedPropertySchema(63); + const pjson withinLimitInstance = makeNestedPropertyInstance(63); + const pjson schema = makeNestedPropertySchema(64); + const pjson instance = makeNestedPropertyInstance(64); CHECK(withinLimitInstance.validate(withinLimitSchema, opts)); std::vector errors; @@ -711,7 +711,7 @@ TEST(schema_additional_properties_large_object_stays_within_work_budget) { TEST(schema_validation_zero_work_budget_uses_hard_ceiling) { pjson::SchemaOptions opts = optionsWithWorkBudget(0); - opts.maxValidationDepth = 128; + opts.maxValidationDepth = 64; opts.maxRefResolutions = 2000000; const pjson schema = makeBranchingWorkSchema(20); pjson instance;