Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .github/workflows/r.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +60,32 @@ jobs:
error-on: '"warning"'
upload-snapshots: true

regex-sanitizers:
name: Regex ASAN/UBSAN (clang)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

# 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
runs-on: ubuntu-latest
Expand Down Expand Up @@ -87,7 +113,7 @@ jobs:
release:
name: Attach R source tarball to release
runs-on: ubuntu-latest
needs: [test, lint]
needs: [test, regex-sanitizers, lint]
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
Expand Down
51 changes: 51 additions & 0 deletions c/src/schema_validator.c
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

Expand Down
60 changes: 60 additions & 0 deletions c/tests/test_conformance.c
Original file line number Diff line number Diff line change
Expand Up @@ -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\","
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions r/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 8 additions & 14 deletions r/cran-comments.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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`.
11 changes: 8 additions & 3 deletions r/inst/COPYRIGHTS
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-------------------------------------------------------------------------------
20 changes: 20 additions & 0 deletions r/inst/WORDLIST
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Erroring
ReDoS
Rtools
SDK
behaviour
cJSON
cjson
conformant
enum
erroring
lookbehind
marshalled
matcher
prebuilt
schemas
serialisable
shorthands
toolchain
validator
vendored
9 changes: 0 additions & 9 deletions r/src/cJSON.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions r/src/schema_validator.c
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading