schemagen crashes / emits non-compiling code for nullable root object reached via $ref that is referenced in a cycle #61

Open
opened 2026-08-23 09:59:55 +00:00 by weaselbot · 0 comments
Member

Summary

weaseljson_schemagen.py mishandles a schema whose root is a nullable object reached via $ref (i.e. the root $ref points at a $defs entry with "type": ["object", "null"]) when that same $defs entry is referenced by another field -- directly (self-reference) or through another object.

The nullable-root-object handling in Emitter.emit() renames the struct to avoid the using Root = std::optional<Root>; self-conflict, but it only updates self.b.objects and reassigns self.root_ty. It does not update the TObj.name of field types that still point at the original object, so those references become stale. Depending on how the stale reference is reached this either:

  1. crashes schemagen with KeyError in break_cycles, or
  2. silently emits C++ that does not compile ('<Name>' was not declared in this scope).

This uses only supported schema features ($ref, type: ["object","null"], object self/cross references, additionalProperties: false).

Root cause

contrib/schemagen/weaseljson_schemagen.py, Emitter.emit() (lines 608-614):

        # A nullable root object would otherwise produce
        #   using Root = std::optional<Root>;
        # which conflicts with the struct named Root.  Rename the inner struct.
        if isinstance(self.root_ty, TObj) and self.root_nullable:
            old_name = self.root_ty.name
            new_name = self.b.unique_name("RootInner")
            obj = self.b.objects.pop(old_name)
            obj.name = new_name
            self.b.objects[new_name] = obj
            self.root_ty = TObj(new_name)

When the root is reached via $ref, root_ty is the same TObj instance that other fields hold (returned from build_def, which caches (TObj, nullable) in self._building). The rename creates a new TObj(new_name) for root_ty and renames the ObjectType, but every other field that $refs this definition still holds the original TObj instance whose .name is the old name (e.g. "Node"). Those TObj.name values are then never updated.

Reproduction 1 -- schemagen crashes (self-referential)

schema.json:

{
  "$ref": "#/$defs/Node",
  "$defs": {
    "Node": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "properties": { "next": { "$ref": "#/$defs/Node" } }
    }
  }
}
python3 contrib/schemagen/weaseljson_schemagen.py schema.json

Result (exits non-zero with a traceback):

Traceback (most recent call last):
  ...
  File "contrib/schemagen/weaseljson_schemagen.py", line 616, in emit
    break_cycles(self.b.objects)
  File "contrib/schemagen/weaseljson_schemagen.py", line 493, in break_cycles
    if color.get(name, 0) == 0 and dfs(name):
  File "contrib/schemagen/weaseljson_schemagen.py", line 483, in dfs
    if c == 0 and dfs(t):
  File "contrib/schemagen/weaseljson_schemagen.py", line 475, in dfs
    for f in objects[name].fields:
             ~~~~~~~^^^^^^
KeyError: 'Node'

break_cycles recurses into f.ty.name (the stale "Node"), but the object was renamed to RootInner in self.b.objects, so objects["Node"] raises KeyError. A cross-reference cycle (Node <-> Other) crashes the same way.

Reproduction 2 -- schemagen emits non-compiling code (cycle via arrays)

When the stale reference is reached through an array field, needs_complete() is false so break_cycles skips it and does not crash; instead schemagen exits 0 and emits a header that does not compile.

schema.json:

{
  "$ref": "#/$defs/Node",
  "$defs": {
    "Node": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "properties": { "items": { "type": "array", "items": { "$ref": "#/$defs/Other" } } }
    },
    "Other": {
      "type": "object",
      "additionalProperties": false,
      "properties": { "nodes": { "type": "array", "items": { "$ref": "#/$defs/Node" } } }
    }
  }
}
python3 contrib/schemagen/weaseljson_schemagen.py schema.json -o gen.h --namespace ts
c++ -std=c++20 -fsyntax-only -I include -I . -x c++ - <<'EOF'
#include "gen.h"
int main(){ ts::RootBuilder b; (void)b; }
EOF

Generated gen.h (the struct is renamed to RootInner, but Other.nodes still references the old name):

struct Other;
struct RootInner;
struct Other {
  std::optional<std::vector<std::optional<Node>>> nodes;  // "nodes"
  ...
};
using Root = std::optional<RootInner>;

Compile error:

gen.h:21:43: error: 'Node' was not declared in this scope
   21 |   std::optional<std::vector<std::optional<Node>>> nodes;  // "nodes"
      |                                           ^~~~

What is NOT affected

  • A non-nullable root reached via $ref with the same self-reference generates and compiles fine (no rename).
  • An inline nullable root object (not via $ref) that references a separate $defs entry works (the existing regression test test_self_referential_nullable_object_accepts_nested_null covers only this inline-root case, so the $ref-root case is untested).

Impact

A valid schema using only supported features cannot be processed: schemagen either aborts with a Python KeyError traceback, or silently produces a header that fails to compile. The rename in emit() needs to also rewrite TObj.name for every field type that still points at the renamed object (or, equivalently, rename the shared TObj instance in place instead of creating a new one and leaving the old one referenced).

## Summary `weaseljson_schemagen.py` mishandles a schema whose root is a **nullable object reached via `$ref`** (i.e. the root `$ref` points at a `$defs` entry with `"type": ["object", "null"]`) when that same `$defs` entry is referenced by another field -- directly (self-reference) or through another object. The nullable-root-object handling in `Emitter.emit()` renames the struct to avoid the `using Root = std::optional<Root>;` self-conflict, but it only updates `self.b.objects` and reassigns `self.root_ty`. It does **not** update the `TObj.name` of field types that still point at the original object, so those references become stale. Depending on how the stale reference is reached this either: 1. **crashes** schemagen with `KeyError` in `break_cycles`, or 2. **silently emits C++ that does not compile** (`'<Name>' was not declared in this scope`). This uses only supported schema features (`$ref`, `type: ["object","null"]`, object self/cross references, `additionalProperties: false`). ## Root cause `contrib/schemagen/weaseljson_schemagen.py`, `Emitter.emit()` (lines 608-614): ```python # A nullable root object would otherwise produce # using Root = std::optional<Root>; # which conflicts with the struct named Root. Rename the inner struct. if isinstance(self.root_ty, TObj) and self.root_nullable: old_name = self.root_ty.name new_name = self.b.unique_name("RootInner") obj = self.b.objects.pop(old_name) obj.name = new_name self.b.objects[new_name] = obj self.root_ty = TObj(new_name) ``` When the root is reached via `$ref`, `root_ty` is the same `TObj` instance that other fields hold (returned from `build_def`, which caches `(TObj, nullable)` in `self._building`). The rename creates a **new** `TObj(new_name)` for `root_ty` and renames the `ObjectType`, but every other field that `$ref`s this definition still holds the original `TObj` instance whose `.name` is the old name (e.g. `"Node"`). Those `TObj.name` values are then never updated. ## Reproduction 1 -- schemagen crashes (self-referential) `schema.json`: ```json { "$ref": "#/$defs/Node", "$defs": { "Node": { "type": ["object", "null"], "additionalProperties": false, "properties": { "next": { "$ref": "#/$defs/Node" } } } } } ``` ```sh python3 contrib/schemagen/weaseljson_schemagen.py schema.json ``` Result (exits non-zero with a traceback): ``` Traceback (most recent call last): ... File "contrib/schemagen/weaseljson_schemagen.py", line 616, in emit break_cycles(self.b.objects) File "contrib/schemagen/weaseljson_schemagen.py", line 493, in break_cycles if color.get(name, 0) == 0 and dfs(name): File "contrib/schemagen/weaseljson_schemagen.py", line 483, in dfs if c == 0 and dfs(t): File "contrib/schemagen/weaseljson_schemagen.py", line 475, in dfs for f in objects[name].fields: ~~~~~~~^^^^^^ KeyError: 'Node' ``` `break_cycles` recurses into `f.ty.name` (the stale `"Node"`), but the object was renamed to `RootInner` in `self.b.objects`, so `objects["Node"]` raises `KeyError`. A cross-reference cycle (`Node` <-> `Other`) crashes the same way. ## Reproduction 2 -- schemagen emits non-compiling code (cycle via arrays) When the stale reference is reached through an array field, `needs_complete()` is `false` so `break_cycles` skips it and does not crash; instead schemagen exits 0 and emits a header that does not compile. `schema.json`: ```json { "$ref": "#/$defs/Node", "$defs": { "Node": { "type": ["object", "null"], "additionalProperties": false, "properties": { "items": { "type": "array", "items": { "$ref": "#/$defs/Other" } } } }, "Other": { "type": "object", "additionalProperties": false, "properties": { "nodes": { "type": "array", "items": { "$ref": "#/$defs/Node" } } } } } } ``` ```sh python3 contrib/schemagen/weaseljson_schemagen.py schema.json -o gen.h --namespace ts c++ -std=c++20 -fsyntax-only -I include -I . -x c++ - <<'EOF' #include "gen.h" int main(){ ts::RootBuilder b; (void)b; } EOF ``` Generated `gen.h` (the struct is renamed to `RootInner`, but `Other.nodes` still references the old name): ```cpp struct Other; struct RootInner; struct Other { std::optional<std::vector<std::optional<Node>>> nodes; // "nodes" ... }; using Root = std::optional<RootInner>; ``` Compile error: ``` gen.h:21:43: error: 'Node' was not declared in this scope 21 | std::optional<std::vector<std::optional<Node>>> nodes; // "nodes" | ^~~~ ``` ## What is NOT affected - A non-nullable root reached via `$ref` with the same self-reference generates and compiles fine (no rename). - An inline nullable root object (not via `$ref`) that references a *separate* `$defs` entry works (the existing regression test `test_self_referential_nullable_object_accepts_nested_null` covers only this inline-root case, so the `$ref`-root case is untested). ## Impact A valid schema using only supported features cannot be processed: schemagen either aborts with a Python `KeyError` traceback, or silently produces a header that fails to compile. The rename in `emit()` needs to also rewrite `TObj.name` for every field type that still points at the renamed object (or, equivalently, rename the shared `TObj` instance in place instead of creating a new one and leaving the old one referenced).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/weaseljson#61