From 1730960f320b29d8055845a89b76eb98d8a3c08d Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Tue, 14 Jul 2026 10:47:10 +0200 Subject: [PATCH 1/5] Add spelling Language field and WORDLIST for CRAN Declare `Language: en-US` in DESCRIPTION and add `inst/WORDLIST` so `spelling::spell_check_package()` (run by CRAN) is clean. The wordlist contains only genuine technical terms (cJSON, ReDoS, enum, matcher, SDK, Rtools, vendored, ...) and the package's British spellings; there are no actual misspellings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- r/DESCRIPTION | 1 + r/inst/WORDLIST | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 r/inst/WORDLIST diff --git a/r/DESCRIPTION b/r/DESCRIPTION index 166a317..912f2b8 100644 --- a/r/DESCRIPTION +++ b/r/DESCRIPTION @@ -18,6 +18,7 @@ License: MIT + file LICENSE URL: https://github.com/json-structure/sdk, https://json-structure.org BugReports: https://github.com/json-structure/sdk/issues Encoding: UTF-8 +Language: en-US Depends: R (>= 4.0) Imports: jsonlite, diff --git a/r/inst/WORDLIST b/r/inst/WORDLIST new file mode 100644 index 0000000..1caeadd --- /dev/null +++ b/r/inst/WORDLIST @@ -0,0 +1,20 @@ +Erroring +ReDoS +Rtools +SDK +behaviour +cJSON +cjson +conformant +enum +erroring +lookbehind +marshalled +matcher +prebuilt +schemas +serialisable +shorthands +toolchain +validator +vendored From ef00800f03d1f03c4f47a1acd3059d9af7b9da2d Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Tue, 14 Jul 2026 10:50:23 +0200 Subject: [PATCH 2/5] Add R-devel matrix and ASAN/UBSAN sanitizer job to R CI Extend pre-submission coverage that cannot be run on the Windows dev box: * Add 'devel' to the R version matrix so R CMD check --as-cran also runs on R-devel (the version CRAN builds against) across ubuntu/macos/windows. * Add a `sanitizers` job that runs R CMD check inside the r-hub v2 clang-asan container (R-devel built with AddressSanitizer + UndefinedBehaviorSanitizer). The shared conformance corpus is enabled via JSONSTRUCTURE_TEST_ASSETS so the hand-written C regex matcher in src/regex_utils.c is exercised under the sanitizers alongside the engine and cJSON. * Gate the release tarball job on the sanitizer job as well. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/r.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 4cd99c7..7749e70 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - r: ['release', 'oldrel-1'] + r: ['release', 'oldrel-1', 'devel'] env: R_KEEP_PKG_SOURCE: yes @@ -60,6 +60,31 @@ jobs: error-on: '"warning"' upload-snapshots: true + sanitizers: + name: ASAN/UBSAN (R-devel, clang) + runs-on: ubuntu-latest + container: + # r-hub v2 container: R-devel built with clang AddressSanitizer + + # UndefinedBehaviorSanitizer and R-tuned ASAN/UBSAN option suppressions. + image: ghcr.io/r-hub/containers/clang-asan:latest + env: + # Run the shared conformance corpus (including `pattern` cases) so the + # hand-written C regex matcher in src/regex_utils.c is exercised under + # the sanitizers, not just the engine and cJSON. + JSONSTRUCTURE_TEST_ASSETS: ${{ github.workspace }}/test-assets + steps: + - uses: actions/checkout@v6 + + - name: Install package dependencies (compiled under sanitizers) + working-directory: r + run: R -q -e 'install.packages("remotes", repos = "https://cloud.r-project.org"); remotes::install_deps(dependencies = TRUE, upgrade = "never")' + + - name: R CMD check under ASAN/UBSAN + working-directory: r + run: | + R CMD build --no-build-vignettes --no-manual . + R CMD check --no-manual --no-build-vignettes jsonstructure_*.tar.gz + lint: name: Lint R code runs-on: ubuntu-latest @@ -87,7 +112,7 @@ jobs: release: name: Attach R source tarball to release runs-on: ubuntu-latest - needs: [test, lint] + needs: [test, sanitizers, lint] if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write From d3c8a991fc77b5889f48a3602f8b8cf564f4f5b2 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Tue, 14 Jul 2026 11:03:26 +0200 Subject: [PATCH 3/5] Use focused clang ASAN/UBSAN harness for the regex matcher The whole-package r-hub clang-asan container job proved flaky: installing the package's dependencies (jsonlite/testthat) from the r-hub binary repo under R-devel intermittently fails on a missing prebuilt, which surfaces as a "package dependencies ... ERROR" unrelated to memory safety. Replace it with a deterministic, dependency-free job: compile the hand-written pure-C matcher (src/regex_utils.c) together with a dedicated harness (tools/sanitizer/regex_asan.c) using clang -fsanitize=address,undefined -fno-sanitize-recover=all and run it. The harness stresses valid patterns, rejected constructs, adversarial ReDoS shapes, long inputs and cache eviction. This is the only new memory-sensitive C in the R package; the shared engine and cJSON are already covered by the C SDK's Valgrind job. The harness lives under tools/ and is excluded from the package tarball via .Rbuildignore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/r.yml | 43 ++++--- r/tools/sanitizer/regex_asan.c | 225 +++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 r/tools/sanitizer/regex_asan.c diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 7749e70..228df1b 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -60,30 +60,31 @@ jobs: error-on: '"warning"' upload-snapshots: true - sanitizers: - name: ASAN/UBSAN (R-devel, clang) + regex-sanitizers: + name: Regex ASAN/UBSAN (clang) runs-on: ubuntu-latest - container: - # r-hub v2 container: R-devel built with clang AddressSanitizer + - # UndefinedBehaviorSanitizer and R-tuned ASAN/UBSAN option suppressions. - image: ghcr.io/r-hub/containers/clang-asan:latest - env: - # Run the shared conformance corpus (including `pattern` cases) so the - # hand-written C regex matcher in src/regex_utils.c is exercised under - # the sanitizers, not just the engine and cJSON. - JSONSTRUCTURE_TEST_ASSETS: ${{ github.workspace }}/test-assets steps: - uses: actions/checkout@v6 - - name: Install package dependencies (compiled under sanitizers) - working-directory: r - run: R -q -e 'install.packages("remotes", repos = "https://cloud.r-project.org"); remotes::install_deps(dependencies = TRUE, upgrade = "never")' - - - name: R CMD check under ASAN/UBSAN - working-directory: r - run: | - R CMD build --no-build-vignettes --no-manual . - R CMD check --no-manual --no-build-vignettes jsonstructure_*.tar.gz + # The engine and cJSON are covered by the C SDK's Valgrind job; the only + # hand-written, memory-sensitive C added for the R package is the pure-C + # regex matcher, so we stress it directly under AddressSanitizer and + # UndefinedBehaviorSanitizer with a dedicated harness (no R needed, so the + # check is fast and deterministic). + - name: Compile matcher + harness under ASAN/UBSAN + run: >- + clang -std=gnu11 -g -O1 -Wall -Wextra + -fsanitize=address,undefined -fno-sanitize-recover=all + -fno-omit-frame-pointer + -I r/src + r/src/regex_utils.c r/tools/sanitizer/regex_asan.c + -o regex_asan + + - name: Run regex sanitizer harness + env: + ASAN_OPTIONS: abort_on_error=1:detect_leaks=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: ./regex_asan lint: name: Lint R code @@ -112,7 +113,7 @@ jobs: release: name: Attach R source tarball to release runs-on: ubuntu-latest - needs: [test, sanitizers, lint] + needs: [test, regex-sanitizers, lint] if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write diff --git a/r/tools/sanitizer/regex_asan.c b/r/tools/sanitizer/regex_asan.c new file mode 100644 index 0000000..e2bde0e --- /dev/null +++ b/r/tools/sanitizer/regex_asan.c @@ -0,0 +1,225 @@ +/* + * regex_asan.c - AddressSanitizer / UndefinedBehaviorSanitizer harness for the + * hand-written pure-C ECMAScript-subset matcher in src/regex_utils.c. + * + * This is a CI/maintenance tool: it is excluded from the package tarball via + * .Rbuildignore (^tools$) and is compiled with clang -fsanitize=address, + * undefined in the R workflow to prove the matcher is memory-safe and free of + * undefined behaviour across valid patterns, invalid patterns, adversarial + * (ReDoS) patterns, boundary inputs and cache eviction. + * + * Exit status: 0 = all good; non-zero = a functional assertion failed. Any + * sanitizer diagnostic aborts the process with a non-zero status regardless. + */ +#include "regex_utils.h" + +#include +#include +#include + +static int failures = 0; + +/* Assert an expected match verdict; keeps going so we report every mismatch. */ +static void expect_match(const char *pattern, const char *text, bool want) { + bool got = js_regex_match(pattern, text); + if (got != want) { + fprintf(stderr, "MATCH MISMATCH pattern=<%s> text=<%s> want=%d got=%d\n", + pattern, text, (int)want, (int)got); + failures++; + } +} + +static void expect_valid(const char *pattern, bool want) { + bool got = js_regex_is_valid(pattern); + if (got != want) { + fprintf(stderr, "VALID MISMATCH pattern=<%s> want=%d got=%d\n", + pattern, (int)want, (int)got); + failures++; + } +} + +/* Patterns that must be accepted (inside the supported subset). */ +static const char *const kValidPatterns[] = { + "", + "abc", + ".", + "^abc$", + "a*", + "a+", + "a?", + "a{3}", + "a{2,}", + "a{2,5}", + "a*?", + "a+?", + "a{2,5}?", + "[abc]", + "[^abc]", + "[a-z]", + "[A-Za-z0-9_]", + "[a-z-]", + "[-a-z]", + "\\d+", + "\\D+", + "\\w+", + "\\W+", + "\\s+", + "\\S+", + "\\bword\\b", + "\\Bword", + "(abc)", + "(?:abc)", + "(a|b|c)", + "(foo|bar)+", + "^[A-Z][a-z]+$", + "^[A-Z]", + "^[a-z]+$", + "^\\d{4}-\\d{2}-\\d{2}$", + "colou?r", + "\\x41\\x42", + "\\u00e9", + "a\\.b", + "[\\d.]+", + "^(a+)+$", /* classic ReDoS - must compile, must not hang */ + "(x+x+)+y", /* another catastrophic-backtracking shape */ + ".*", + ".+", + "\\n\\r\\t", + "a{", /* ECMAScript: `{` not forming a quantifier is a literal */ +}; + +/* Patterns that must be rejected (outside the supported subset / malformed). */ +static const char *const kInvalidPatterns[] = { + "(?<=foo)", /* lookbehind */ + "(?x)", /* named group */ + "(?=foo)", /* lookahead */ + "(?!foo)", /* negative lookahead */ + "(?i)abc", /* inline flags */ + "(?m)^x", + "(?s).", + "\\p{L}", /* unicode property */ + "\\P{L}", + "[invalid", /* unterminated class */ + "(unclosed", /* unmatched paren */ + "a)b", /* stray close paren */ + "a\\", /* dangling escape */ + "a**", /* stacked quantifier */ + "*abc", /* nothing to repeat */ + "[z-a]", /* reversed range */ +}; + +/* A spread of input texts, incl. empty, control chars and high bytes. */ +static const char *const kTexts[] = { + "", + "a", + "abc", + "Hello", + "hello", + "HELLO", + "Hello World", + "2024-01-31", + "not-a-date", + "\t\n\r ", + "\x41\x42", + "caf\xc3\xa9", /* UTF-8 e-acute bytes */ + "the quick brown fox", + "aaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaab", + "!@#$%^&*()_+-=[]{}|;':\",./<>?", +}; + +static void run_matrix(void) { + size_t np = sizeof(kValidPatterns) / sizeof(kValidPatterns[0]); + size_t ni = sizeof(kInvalidPatterns) / sizeof(kInvalidPatterns[0]); + size_t nt = sizeof(kTexts) / sizeof(kTexts[0]); + + for (size_t p = 0; p < np; ++p) { + expect_valid(kValidPatterns[p], true); + for (size_t t = 0; t < nt; ++t) { + /* Verdict not asserted here; we only require memory-safety. */ + (void)js_regex_match(kValidPatterns[p], kTexts[t]); + } + } + + for (size_t p = 0; p < ni; ++p) { + expect_valid(kInvalidPatterns[p], false); + for (size_t t = 0; t < nt; ++t) { + /* Matching an invalid pattern must be safe and never true. */ + if (js_regex_match(kInvalidPatterns[p], kTexts[t])) { + fprintf(stderr, "invalid pattern <%s> unexpectedly matched <%s>\n", + kInvalidPatterns[p], kTexts[t]); + failures++; + } + } + } +} + +/* A handful of exact-verdict checks that mirror what the R corpus relies on. */ +static void run_semantics(void) { + expect_match("^[A-Z][a-z]+$", "Hello", true); + expect_match("^[A-Z][a-z]+$", "hello", false); /* must be rejected */ + expect_match("^[A-Z][a-z]+$", "HELLO", false); + expect_match("^[a-z]+$", "hello", true); + expect_match("^[A-Z]", "Hello", true); + expect_match("^[A-Z]", "hello", false); + expect_match("\\d+", "abc123", true); /* search semantics */ + expect_match("^\\d+$", "abc123", false); + expect_match("colou?r", "color", true); + expect_match("colou?r", "colour", true); + expect_match("(foo|bar)+", "foobarfoo", true); + expect_match("a{2,3}", "a", false); + expect_match("a{2,3}", "aa", true); +} + +/* ReDoS guard: pathological patterns against long non-matching input must + * return (bounded by the step budget) without crashing or hanging. */ +static void run_redos(void) { + char buf[64]; + memset(buf, 'a', sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + buf[sizeof(buf) - 2] = '!'; /* force a non-match at the end */ + (void)js_regex_match("^(a+)+$", buf); + (void)js_regex_match("(x+x+)+y", "xxxxxxxxxxxxxxxxxxxxxxxx!"); +} + +/* Long input to catch buffer-boundary issues in the VM. */ +static void run_long_input(void) { + size_t n = 100000; + char *big = (char *)malloc(n + 1); + if (!big) return; + memset(big, 'a', n); + big[n] = '\0'; + (void)js_regex_match("a+", big); + (void)js_regex_match("^a+$", big); + (void)js_regex_match("z", big); + (void)js_regex_match("[a-z]{10,20}", big); + free(big); +} + +/* Exercise the compile cache past its capacity to hit LRU eviction paths. */ +static void run_cache_churn(void) { + char pat[32]; + for (int i = 0; i < 200; ++i) { + snprintf(pat, sizeof(pat), "pat%d[a-z]*%d", i, i % 7); + (void)js_regex_is_valid(pat); + (void)js_regex_match(pat, "pat123abc4"); + } +} + +int main(void) { + run_matrix(); + run_semantics(); + run_redos(); + run_long_input(); + run_cache_churn(); + + /* Free all cached compiled programs so LeakSanitizer sees a clean exit. */ + js_regex_cache_clear(); + + if (failures) { + fprintf(stderr, "regex_asan: %d functional assertion(s) failed\n", failures); + return 1; + } + printf("regex_asan: OK (no sanitizer errors, all assertions passed)\n"); + return 0; +} From 452324098790e99f89de2eb430aa5822bc8309bb Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Tue, 14 Jul 2026 11:42:03 +0200 Subject: [PATCH 4/5] Remove diagnostic-suppressing pragma from vendored cJSON (0E/0W/1N) CRAN's "checking pragmas in C/C++ headers and code" flagged src/cJSON.c for a `#pragma GCC diagnostic ignored "-Wcast-qual"` (with push/pop) wrapping the internal cast_away_const() helper. Remove the pragma wrapper, keeping the helper unchanged. -Wcast-qual is not part of the flags R uses to compile packages, so the helper compiles cleanly under -Wall -Wextra with no new warning; the suppression was purely defensive. Verified with `R CMD check --as-cran` (_R_CHECK_CRAN_INCOMING_REMOTE_=TRUE): the pragma check is now OK and the overall result is 0 errors / 0 warnings / 1 note (the unavoidable "New submission" note). Document the modification alongside the existing sprintf->snprintf change in inst/COPYRIGHTS, and update cran-comments.md to reflect the single remaining note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- r/cran-comments.md | 22 ++++++++-------------- r/inst/COPYRIGHTS | 11 ++++++++--- r/src/cJSON.c | 9 --------- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/r/cran-comments.md b/r/cran-comments.md index d9289bd..b395db9 100644 --- a/r/cran-comments.md +++ b/r/cran-comments.md @@ -1,22 +1,9 @@ ## R CMD check results -0 errors | 0 warnings | 2 notes +0 errors | 0 warnings | 1 note * This is a new submission. -* checking pragmas in C/C++ headers and code ... NOTE - File which contains pragma(s) suppressing diagnostics: 'src/cJSON.c' - - `src/cJSON.c` is the vendored cJSON library (v1.7.18, MIT, - © Dave Gamble and cJSON contributors). It contains a - `#pragma GCC diagnostic ignored "-Wcast-qual"` in its upstream source. The - file is bundled so that the package's native engine has no external - dependency; the pragma only takes effect under `-Wcast-qual`, which is not part - of the default R compilation flags. The only modification to the upstream - source is replacing its `sprintf()` calls with bounded `snprintf()` to satisfy - the CRAN "compiled code" check (no `sprintf`/`__sprintf_chk` entry point). - Authorship and copyright are recorded in `DESCRIPTION` and `inst/COPYRIGHTS`. - ## Test environments * Windows 11 x86_64, R 4.6.1 (local), gcc 14.3.0 (Rtools45) @@ -31,3 +18,10 @@ run time. The package is pure C and links only with the C runtime; `pattern` keyword matching is provided by a small embedded regular-expression engine, so there is no `std::regex`/C++ dependency and behaviour is identical on every platform. + +The bundled cJSON (v1.7.18, MIT) is used unmodified except for two changes made +for CRAN compliance: its `sprintf()` calls are replaced by bounded `snprintf()` +(no `sprintf` entry point in compiled code), and the upstream +`#pragma GCC diagnostic ignored "-Wcast-qual"` around an internal helper is +removed (no diagnostic-suppressing pragmas). Both changes are recorded in +`inst/COPYRIGHTS`. diff --git a/r/inst/COPYRIGHTS b/r/inst/COPYRIGHTS index 92b1718..af37a7e 100644 --- a/r/inst/COPYRIGHTS +++ b/r/inst/COPYRIGHTS @@ -22,7 +22,12 @@ cJSON Copyright: (c) 2009-2017 Dave Gamble and cJSON contributors License: MIT (SPDX-License-Identifier: MIT) Source: https://github.com/DaveGamble/cJSON (tag v1.7.18) - Note: src/cJSON.c is vendored from the tag above with one change: its - sprintf() calls are replaced by bounded snprintf() so the package - passes the CRAN "compiled code" entry-point check. + Note: src/cJSON.c is vendored from the tag above with two changes made + to satisfy CRAN policy: (1) its sprintf() calls are replaced by + bounded snprintf() so the package passes the CRAN "compiled code" + entry-point check; (2) the upstream "#pragma GCC diagnostic + ignored \"-Wcast-qual\"" (and its push/pop) around the internal + cast_away_const() helper is removed so the package passes the CRAN + "pragmas suppressing diagnostics" check. The helper is unchanged + and produces no warning under the compiler flags R uses. ------------------------------------------------------------------------------- diff --git a/r/src/cJSON.c b/r/src/cJSON.c index a5dbec4..ed78805 100644 --- a/r/src/cJSON.c +++ b/r/src/cJSON.c @@ -2014,20 +2014,11 @@ CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item) return add_item_to_array(array, item); } -#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5)))) - #pragma GCC diagnostic push -#endif -#ifdef __GNUC__ -#pragma GCC diagnostic ignored "-Wcast-qual" -#endif /* helper function to cast away const */ static void* cast_away_const(const void* string) { return (void*)string; } -#if defined(__clang__) || (defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5)))) - #pragma GCC diagnostic pop -#endif static cJSON_bool add_item_to_object(cJSON * const object, const char * const string, cJSON * const item, const internal_hooks * const hooks, const cJSON_bool constant_key) From dd023266702fc6080257919fc7204a3911972290 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Tue, 14 Jul 2026 12:12:18 +0200 Subject: [PATCH 5/5] Detect circular $extends chains; add R adversarial + warnings coverage The shared C schema engine only validated the direct target of a `$extends` keyword, so a multi-hop cycle among abstract/unreferenced definitions (A -> B -> C -> A) was silently accepted. Add check_extends_cycle(): a bounded visited-set walk over string $extends links that reports JS_SCHEMA_REF_CIRCULAR on a revisit or excessive depth, wired into validate_schema_node's $extends block. This makes circular $extends detectable regardless of whether the type is used. Add C conformance tests (invalid_circular_extends_chain, invalid_self_referencing_extends) and re-vendor the fix into r/src. Bring the R testthat suite to parity with the other SDKs by running the shared adversarial schema/instance corpus and the warnings corpus, mirroring python/tests/test_assets.py. This coverage is what surfaced the engine bug (extends-circular-chain.struct.json). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/src/schema_validator.c | 51 +++++++++ c/tests/test_conformance.c | 60 ++++++++++ r/src/schema_validator.c | 51 +++++++++ r/tests/testthat/test-test-assets.R | 167 ++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+) diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index 2844aeb..bf7b826 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -1586,6 +1586,54 @@ static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* exten return valid; } +/* Detect a circular $extends chain starting at `start_node`. Follows string + * ($ref-style) $extends links, recording each resolved node in a bounded + * visited set; if a node is revisited the chain is circular. Reports + * JS_SCHEMA_REF_CIRCULAR and returns false on a cycle (or a chain that is + * implausibly deep). A non-string / array-valued $extends or an unresolvable + * target ends the simple walk (those are reported by validate_extends_keyword). + * Note: an unreferenced abstract type whose $extends forms a cycle would not be + * reached through instance-driven $ref resolution, so this walk is what makes + * circular $extends detectable regardless of whether the type is used. */ +static bool check_extends_cycle(validate_context_t* ctx, const cJSON* start_node) { + enum { JS_MAX_EXTENDS_DEPTH = 128 }; + const cJSON* visited[JS_MAX_EXTENDS_DEPTH]; + size_t count = 0; + + const cJSON* current = start_node; + while (current) { + if (count >= JS_MAX_EXTENDS_DEPTH) { + add_error(ctx, JS_SCHEMA_REF_CIRCULAR, + "$extends chain too deep (possible circular $extends)"); + return false; + } + visited[count++] = current; + + const cJSON* ext = cJSON_GetObjectItemCaseSensitive(current, "$extends"); + if (!cJSON_IsString(ext) || !ext->valuestring || ext->valuestring[0] != '#') { + return true; /* chain ends, or is not a simple string reference */ + } + + const cJSON* target = resolve_ref(ctx, ext->valuestring); + if (!target) { + return true; /* missing target is reported by validate_extends_keyword */ + } + + for (size_t i = 0; i < count; i++) { + if (visited[i] == target) { + char msg[256]; + snprintf(msg, sizeof(msg), + "Circular $extends chain detected at '%s'", + ext->valuestring); + add_error(ctx, JS_SCHEMA_REF_CIRCULAR, msg); + return false; + } + } + current = target; + } + return true; +} + static bool validate_composition(validate_context_t* ctx, const cJSON* schema) { bool valid = true; const cJSON* allOf = cJSON_GetObjectItemCaseSensitive(schema, "allOf"); @@ -1785,6 +1833,9 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { if (!validate_extends_keyword(ctx, extends_node)) { valid = false; } + if (!check_extends_cycle(ctx, schema)) { + valid = false; + } pop_path(ctx, prev_len); } diff --git a/c/tests/test_conformance.c b/c/tests/test_conformance.c index 5e63e80..f809727 100644 --- a/c/tests/test_conformance.c +++ b/c/tests/test_conformance.c @@ -126,6 +126,64 @@ TEST(invalid_circular_ref_direct) { return !valid ? 0 : 1; } +TEST(invalid_circular_extends_chain) { + /* A -> C -> B -> A via $extends, on abstract types that are never + * referenced. Must be reported as circular even though nothing uses them. */ + const char* schema = "{" + "\"$id\": \"https://example.com/test\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"ExtendsCycle\"," + "\"type\": \"object\"," + "\"properties\": {\"data\": {\"type\": \"string\"}}," + "\"definitions\": {" + "\"A\": {\"type\": \"object\", \"abstract\": true, \"$extends\": \"#/definitions/C\", \"properties\": {\"a\": {\"type\": \"string\"}}}," + "\"B\": {\"type\": \"object\", \"abstract\": true, \"$extends\": \"#/definitions/A\", \"properties\": {\"b\": {\"type\": \"string\"}}}," + "\"C\": {\"type\": \"object\", \"abstract\": true, \"$extends\": \"#/definitions/B\", \"properties\": {\"c\": {\"type\": \"string\"}}}" + "}" + "}"; + + js_result_t result; + js_result_init(&result); + bool valid = js_validate_schema(schema, &result); + bool has_circular = false; + for (size_t i = 0; i < result.error_count; i++) { + if (result.errors[i].code == JS_SCHEMA_REF_CIRCULAR) { + has_circular = true; + break; + } + } + js_result_cleanup(&result); + /* Must be rejected specifically for the circular $extends chain. */ + return (!valid && has_circular) ? 0 : 1; +} + +TEST(invalid_self_referencing_extends) { + /* An abstract type whose $extends points at itself. */ + const char* schema = "{" + "\"$id\": \"https://example.com/test\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"SelfExtends\"," + "\"type\": \"object\"," + "\"properties\": {\"data\": {\"type\": \"string\"}}," + "\"definitions\": {" + "\"R\": {\"type\": \"object\", \"abstract\": true, \"$extends\": \"#/definitions/R\", \"properties\": {\"v\": {\"type\": \"string\"}}}" + "}" + "}"; + + js_result_t result; + js_result_init(&result); + bool valid = js_validate_schema(schema, &result); + bool has_circular = false; + for (size_t i = 0; i < result.error_count; i++) { + if (result.errors[i].code == JS_SCHEMA_REF_CIRCULAR) { + has_circular = true; + break; + } + } + js_result_cleanup(&result); + return (!valid && has_circular) ? 0 : 1; +} + TEST(invalid_defs_not_object) { const char* schema = "{" "\"$id\": \"https://example.com/test\"," @@ -1884,6 +1942,8 @@ int test_conformance(void) { RUN_TEST(invalid_allof_not_array); RUN_TEST(invalid_array_missing_items); RUN_TEST(invalid_circular_ref_direct); + RUN_TEST(invalid_circular_extends_chain); + RUN_TEST(invalid_self_referencing_extends); RUN_TEST(invalid_defs_not_object); RUN_TEST(invalid_enum_duplicates); RUN_TEST(invalid_enum_empty); diff --git a/r/src/schema_validator.c b/r/src/schema_validator.c index 2844aeb..bf7b826 100644 --- a/r/src/schema_validator.c +++ b/r/src/schema_validator.c @@ -1586,6 +1586,54 @@ static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* exten return valid; } +/* Detect a circular $extends chain starting at `start_node`. Follows string + * ($ref-style) $extends links, recording each resolved node in a bounded + * visited set; if a node is revisited the chain is circular. Reports + * JS_SCHEMA_REF_CIRCULAR and returns false on a cycle (or a chain that is + * implausibly deep). A non-string / array-valued $extends or an unresolvable + * target ends the simple walk (those are reported by validate_extends_keyword). + * Note: an unreferenced abstract type whose $extends forms a cycle would not be + * reached through instance-driven $ref resolution, so this walk is what makes + * circular $extends detectable regardless of whether the type is used. */ +static bool check_extends_cycle(validate_context_t* ctx, const cJSON* start_node) { + enum { JS_MAX_EXTENDS_DEPTH = 128 }; + const cJSON* visited[JS_MAX_EXTENDS_DEPTH]; + size_t count = 0; + + const cJSON* current = start_node; + while (current) { + if (count >= JS_MAX_EXTENDS_DEPTH) { + add_error(ctx, JS_SCHEMA_REF_CIRCULAR, + "$extends chain too deep (possible circular $extends)"); + return false; + } + visited[count++] = current; + + const cJSON* ext = cJSON_GetObjectItemCaseSensitive(current, "$extends"); + if (!cJSON_IsString(ext) || !ext->valuestring || ext->valuestring[0] != '#') { + return true; /* chain ends, or is not a simple string reference */ + } + + const cJSON* target = resolve_ref(ctx, ext->valuestring); + if (!target) { + return true; /* missing target is reported by validate_extends_keyword */ + } + + for (size_t i = 0; i < count; i++) { + if (visited[i] == target) { + char msg[256]; + snprintf(msg, sizeof(msg), + "Circular $extends chain detected at '%s'", + ext->valuestring); + add_error(ctx, JS_SCHEMA_REF_CIRCULAR, msg); + return false; + } + } + current = target; + } + return true; +} + static bool validate_composition(validate_context_t* ctx, const cJSON* schema) { bool valid = true; const cJSON* allOf = cJSON_GetObjectItemCaseSensitive(schema, "allOf"); @@ -1785,6 +1833,9 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { if (!validate_extends_keyword(ctx, extends_node)) { valid = false; } + if (!check_extends_cycle(ctx, schema)) { + valid = false; + } pop_path(ctx, prev_len); } diff --git a/r/tests/testthat/test-test-assets.R b/r/tests/testthat/test-test-assets.R index 8ac6e28..7cce799 100644 --- a/r/tests/testthat/test-test-assets.R +++ b/r/tests/testthat/test-test-assets.R @@ -156,3 +156,170 @@ test_that("primer sample schemas are accepted", { paste(failed, collapse = "\n ")) ) }) + +# --------------------------------------------------------------------------- +# Adversarial corpus (mirrors python/tests/test_assets.py). These stress the +# validator for crashes, hangs and mis-verdicts. Robustness first: most +# adversarial schemas need only be handled without crashing, while a known +# subset must be rejected outright. Termination on ReDoS/pathological input is +# guaranteed by the engine's bounded-backtracking regex matcher, which is +# additionally exercised under ASAN/UBSAN by the `regex-sanitizers` CI job. +# --------------------------------------------------------------------------- + +# Adversarial schemas that MUST fail schema validation. +INVALID_ADVERSARIAL_SCHEMAS <- c( + "ref-to-nowhere.struct.json", + "malformed-json-pointer.struct.json", + "self-referencing-extends.struct.json", + "extends-circular-chain.struct.json" +) + +# Adversarial instance file -> the adversarial schema it is validated against. +ADVERSARIAL_INSTANCE_SCHEMA_MAP <- c( + "deep-nesting.json" = "deep-nesting-100.struct.json", + "recursive-tree.json" = "recursive-array-items.struct.json", + "property-name-edge-cases.json" = "property-name-edge-cases.struct.json", + "unicode-edge-cases.json" = "unicode-edge-cases.struct.json", + "string-length-surrogate.json" = "string-length-surrogate.struct.json", + "int64-precision.json" = "int64-precision-loss.struct.json", + "floating-point.json" = "floating-point-precision.struct.json", + "null-edge-cases.json" = "null-edge-cases.struct.json", + "empty-collections-invalid.json" = "empty-arrays-objects.struct.json", + "redos-attack.json" = "redos-pattern.struct.json", + "allof-conflict.json" = "allof-conflicting-types.struct.json", + "oneof-all-match.json" = "oneof-all-match.struct.json", + "type-union-int.json" = "type-union-ambiguous.struct.json", + "type-union-number.json" = "type-union-ambiguous.struct.json", + "conflicting-constraints.json" = "conflicting-constraints.struct.json", + "format-invalid.json" = "format-edge-cases.struct.json", + "format-valid.json" = "format-edge-cases.struct.json", + "pattern-flags.json" = "pattern-with-flags.struct.json", + "additionalProperties-combined.json" = "additionalProperties-combined.struct.json", + "extends-override.json" = "extends-with-overrides.struct.json", + "quadratic-blowup.json" = "quadratic-blowup.struct.json", + "anyof-none-match.json" = "anyof-none-match.struct.json" +) + +# Drop the "$schema" association from an adversarial instance (mirrors the +# Python harness `instance.pop("$schema")`) and return JSON text to validate. +strip_schema_association <- function(instance_json) { + instance <- jsonlite::fromJSON(instance_json, simplifyVector = FALSE) + if (is.list(instance)) { + instance[["$schema"]] <- NULL + } + as.character(jsonlite::toJSON(instance, auto_unbox = TRUE, + null = "null", na = "null", digits = NA)) +} + +test_that("adversarial schemas are handled without crashing", { + skip_if_no_binding() + assets <- find_test_assets() + skip_if(is.null(assets), "test-assets directory not found") + dir <- file.path(assets, "schemas", "adversarial") + skip_if_not(dir.exists(dir), "adversarial schemas directory not found") + + files <- Sys.glob(file.path(dir, "*.struct.json")) + skip_if(length(files) == 0, "no adversarial schema files found") + + wrong <- character(0) + for (file in files) { + content <- paste(readLines(file, warn = FALSE), collapse = "\n") + res <- js_validate_schema(content) + expect_s3_class(res, "js_validation_result") + must_reject <- basename(file) %in% INVALID_ADVERSARIAL_SCHEMAS + if (must_reject && is_valid(res)) { + wrong <- c(wrong, basename(file)) + } + } + expect_true( + length(wrong) == 0, + info = paste("Expected these adversarial schemas to be rejected but they", + "were accepted:", paste(wrong, collapse = ", ")) + ) +}) + +test_that("adversarial instances do not crash the validator", { + skip_if_no_binding() + assets <- find_test_assets() + skip_if(is.null(assets), "test-assets directory not found") + schemas_dir <- file.path(assets, "schemas", "adversarial") + instances_dir <- file.path(assets, "instances", "adversarial") + skip_if_not(dir.exists(instances_dir), + "adversarial instances directory not found") + + files <- Sys.glob(file.path(instances_dir, "*.json")) + skip_if(length(files) == 0, "no adversarial instance files found") + + checked <- 0L + for (instance_file in files) { + schema_name <- ADVERSARIAL_INSTANCE_SCHEMA_MAP[[basename(instance_file)]] + if (is.null(schema_name)) next + schema_file <- file.path(schemas_dir, schema_name) + if (!file.exists(schema_file)) next + + schema_content <- paste(readLines(schema_file, warn = FALSE), + collapse = "\n") + instance_json <- paste(readLines(instance_file, warn = FALSE), + collapse = "\n") + instance_value <- strip_schema_association(instance_json) + + # Must return a result object without raising or hanging. + res <- js_validate_instance(instance_value, schema_content) + expect_s3_class(res, "js_validation_result") + checked <- checked + 1L + } + expect_gt(checked, 0) +}) + +# --------------------------------------------------------------------------- +# Warnings corpus: schemas that use validation-extension keywords without a +# `$uses` declaration must be VALID but must emit a "not enabled" warning; the +# same keywords with `$uses` present must not emit that warning. Mirrors +# python/tests/test_assets.py. +# --------------------------------------------------------------------------- + +EXT_KEYWORD_WARNING <- "validation extensions are not enabled" + +test_that("extension keywords without $uses produce warnings", { + skip_if_no_binding() + assets <- find_test_assets() + skip_if(is.null(assets), "test-assets directory not found") + dir <- file.path(assets, "schemas", "warnings") + skip_if_not(dir.exists(dir), "warnings schemas directory not found") + + files <- Sys.glob(file.path(dir, "*-without-uses.struct.json")) + skip_if(length(files) == 0, "no warning schema files found") + + failed <- character(0) + for (file in files) { + content <- paste(readLines(file, warn = FALSE), collapse = "\n") + res <- js_validate_schema(content) + warns <- js_warning_messages(res) + if (!is_valid(res)) { + failed <- c(failed, sprintf("%s (unexpectedly invalid)", basename(file))) + } else if (!any(grepl(EXT_KEYWORD_WARNING, warns, fixed = TRUE))) { + failed <- c(failed, + sprintf("%s (no extension-keyword warning)", basename(file))) + } + } + expect_true( + length(failed) == 0, + info = paste("Warning-schema expectations not met:\n", + paste(failed, collapse = "\n ")) + ) +}) + +test_that("extension keywords with $uses produce no 'not enabled' warning", { + skip_if_no_binding() + assets <- find_test_assets() + skip_if(is.null(assets), "test-assets directory not found") + file <- file.path(assets, "schemas", "warnings", + "all-extension-keywords-with-uses.struct.json") + skip_if_not(file.exists(file), "with-uses warning schema not found") + + content <- paste(readLines(file, warn = FALSE), collapse = "\n") + res <- js_validate_schema(content) + expect_true(is_valid(res)) + warns <- js_warning_messages(res) + expect_false(any(grepl(EXT_KEYWORD_WARNING, warns, fixed = TRUE))) +})