From ababd3a8fd9bcfa5057fbe88eb69141965617f1c Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Tue, 30 Jun 2026 12:29:37 -0400 Subject: [PATCH] schemagen: reject self-referential array $ref cycles instead of crashing Cyclic array definitions (directly or through a chain of array $defs) created a self-referential TArr, which then caused infinite recursion in base_cpp, storage_cpp, and _walk_arrays. Object-only cycles are already broken with std::unique_ptr, but array-only cycles have no object edge for break_cycles to cut. Detect them after building an array's items by following TArr.elem links and raise a clear GenError so generation fails gracefully rather than overflowing the Python stack. Closes #33 --- contrib/schemagen/test_schemagen.py | 79 +++++++++++++++++++++++ contrib/schemagen/weaseljson_schemagen.py | 24 +++++++ 2 files changed, 103 insertions(+) diff --git a/contrib/schemagen/test_schemagen.py b/contrib/schemagen/test_schemagen.py index 4fdd0ae..55f7552 100644 --- a/contrib/schemagen/test_schemagen.py +++ b/contrib/schemagen/test_schemagen.py @@ -274,6 +274,85 @@ class SchemagenAdditionalPropertiesTest(unittest.TestCase): self.assertEqual(rc, 0, msg=stderr) +class SchemagenCyclicArrayTest(unittest.TestCase): + """Regression tests for issue #33: cyclic array $ref targets.""" + + def run_schemagen(self, schema, args=None): + """Run schemagen on a schema dict. Returns (returncode, stdout, stderr).""" + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp: + json.dump(schema, fp) + schema_path = fp.name + try: + cmd = [sys.executable, SCRIPT, schema_path] + if args: + cmd.extend(args) + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + return result.returncode, result.stdout, result.stderr + finally: + os.unlink(schema_path) + + def test_direct_self_referential_array_rejected(self): + schema = { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + "$defs": { + "Node": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + } + }, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertNotEqual(rc, 0) + self.assertIn("recursive array type is not supported", stderr) + self.assertIn("Node", stderr) + + def test_object_field_to_self_referential_array_rejected(self): + schema = { + "type": "object", + "properties": {"items": {"$ref": "#/$defs/Items"}}, + "$defs": { + "Items": { + "type": "array", + "items": {"$ref": "#/$defs/Items"}, + } + }, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertNotEqual(rc, 0) + self.assertIn("recursive array type is not supported", stderr) + self.assertIn("Items", stderr) + + def test_chain_of_array_refs_rejected(self): + schema = { + "type": "object", + "properties": {"x": {"$ref": "#/$defs/A"}}, + "$defs": { + "A": {"type": "array", "items": {"$ref": "#/$defs/B"}}, + "B": {"type": "array", "items": {"$ref": "#/$defs/A"}}, + }, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertNotEqual(rc, 0) + self.assertIn("recursive array type is not supported", stderr) + + def test_non_recursive_array_refs_still_allowed(self): + schema = { + "type": "object", + "properties": {"roles": {"$ref": "#/$defs/Roles"}}, + "$defs": { + "Role": {"enum": ["admin", "user"]}, + "Roles": { + "type": "array", + "items": {"$ref": "#/$defs/Role"}, + }, + }, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertEqual(rc, 0, msg=stderr) + self.assertIn("std::optional> roles;", stdout) + + class SchemagenIntegerBoundaryTest(unittest.TestCase): """Regression tests for issue #19: integer slot parsing near int64 boundaries.""" diff --git a/contrib/schemagen/weaseljson_schemagen.py b/contrib/schemagen/weaseljson_schemagen.py index c64e514..1f71659 100644 --- a/contrib/schemagen/weaseljson_schemagen.py +++ b/contrib/schemagen/weaseljson_schemagen.py @@ -235,6 +235,24 @@ class Builder: """Names generated internally that must not collide with user types.""" return name in ("Root", "RootScalar") + @staticmethod + def _array_reaches(target, ty): + """Return True if `target` can be reached from `ty` by following + TArr element types. This detects self-referential array cycles that + cannot be expressed as C++ structs.""" + seen = set() + stack = [ty] + while stack: + cur = stack.pop() + if cur is target: + return True + if id(cur) in seen: + continue + seen.add(id(cur)) + if isinstance(cur, TArr): + stack.append(cur.elem) + return False + def ref_name(self, ref): if not ref.startswith("#/"): raise GenError(f"only local $ref supported, got: {ref}") @@ -322,6 +340,12 @@ class Builder: ) t.elem = elem t.elem_nullable = elem_nullable + # Self-referential array cycles (directly or through a chain of + # array definitions) cannot be represented as a C++ value type. + if self._array_reaches(t, elem): + raise GenError( + "recursive array type is not supported: " f"{defname or hint!r}" + ) return result scalar = {