diff --git a/ownlang/obligations.py b/ownlang/obligations.py index c0880387..747a5e5d 100644 --- a/ownlang/obligations.py +++ b/ownlang/obligations.py @@ -61,6 +61,33 @@ # matcher vocabulary for `opens`/`closes`/`barriers`/`allow`. MATCHER_KINDS = frozenset({"assign", "call"}) +# --------------------------------------------------------------------------- +# defensive limits on externally supplied structure (spec/OwnIR.md §4.2) +# --------------------------------------------------------------------------- + +# The representable range of an OwnIR source-coordinate integer. +# +# Python integers are unbounded; a consumer's are not. Leaving the range open +# means the fact vocabulary is only *implementable* in a language with bignums, +# which is a contract accident rather than a decision — so the bound is stated +# here and enforced, instead of being discovered downstream as a port bug. +INT64_MIN = -(2 ** 63) +INT64_MAX = 2 ** 63 - 1 + +# Maximum nesting of `if`/`while` bodies in a flow body or an event tree. +# +# Chosen by measurement, from both ends: +# * the deepest nesting in any OwnIR fixture in this repository is 3 +# (`tests/fixtures/lowered/hoist_neg_nested_depth.facts.json`); +# * a JSON parser applying the common 128-level recursion cap stops accepting +# these documents at 62 levels, because each `if` costs two JSON levels. +# 32 sits an order of magnitude above anything a producer has emitted and +# roughly half way to the ceiling every consumer can still parse. +# +# The limit is on the OwnIR *domain* — nested bodies — not on JSON nesting, +# because that is the thing a frontend can reason about. +MAX_NESTING_DEPTH = 32 + class ProtocolFactsError(ValueError): """A malformed protocol/event fact. `load()` wraps this in `OwnIRError` @@ -212,6 +239,10 @@ def _opt_line(raw: dict[str, Any], ctx: str) -> int: v = raw.get("line", 0) if not isinstance(v, int) or isinstance(v, bool): raise ProtocolFactsError(f"{ctx}: 'line' must be an integer, got {v!r}") + if not INT64_MIN <= v <= INT64_MAX: + raise ProtocolFactsError( + f"{ctx}: 'line' must fit a signed 64-bit integer, got {v} " + f"(spec/OwnIR.md §4.2)") return v @@ -296,9 +327,18 @@ def parse_protocol(raw: Any) -> Protocol: methods=tuple(methods_raw), description=desc) -def parse_events(raw: Any, ctx: str) -> tuple[Event, ...]: +def parse_events(raw: Any, ctx: str, depth: int = 0) -> tuple[Event, ...]: """Parse an ordered event list (recursive over `if`/`while`), fail-loud on - an unknown `ev` — the same rule as an unknown flow op (OwnIR IR4).""" + an unknown `ev` — the same rule as an unknown flow op (OwnIR IR4). + + `depth` counts enclosing `if`/`while` bodies; the top-level list is 0. The + bound is a defensive limit on external input (spec/OwnIR.md §4.2), not a + reachable property of real code — the deepest event tree in this + repository's fixtures is 2.""" + if depth > MAX_NESTING_DEPTH: + raise ProtocolFactsError( + f"{ctx}: events nested deeper than {MAX_NESTING_DEPTH} levels " + f"(spec/OwnIR.md §4.2)") if not isinstance(raw, list): raise ProtocolFactsError(f"{ctx}: events must be an array, got {raw!r}") out: list[Event] = [] @@ -332,10 +372,11 @@ def parse_events(raw: Any, ctx: str) -> tuple[Event, ...]: out.append(ThrowEv(line=line)) elif ev == "if": out.append(IfEv(line=line, - then=parse_events(e.get("then", []), ctx), - orelse=parse_events(e.get("else", []), ctx))) + then=parse_events(e.get("then", []), ctx, depth + 1), + orelse=parse_events(e.get("else", []), ctx, depth + 1))) else: # "while" — EVENT_KINDS is closed, checked above - out.append(WhileEv(line=line, body=parse_events(e.get("body", []), ctx))) + out.append(WhileEv( + line=line, body=parse_events(e.get("body", []), ctx, depth + 1))) return tuple(out) diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 49d56ebb..02273693 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -148,6 +148,9 @@ from .effects import find_effect_storms from .evidence import code_flow, di_path_steps from .obligations import ( + INT64_MAX, + INT64_MIN, + MAX_NESTING_DEPTH, MethodEvents, Protocol, ProtocolFactsError, @@ -536,6 +539,21 @@ def build_sarif(findings: list[Finding], severity: str = "error") -> dict[str, A } +def _check_int_range(v: int, where: str, field: str = "line") -> None: + """The representable range of an OwnIR source coordinate (spec/OwnIR.md §4.2). + + Python integers are unbounded; a consumer's are not. Without this bound the + fact vocabulary is only implementable in a language with bignums, which is a + contract accident rather than a decision — and it surfaces downstream as a + port rejecting a document the reference accepted. Stated and enforced here + instead, Python-first. + """ + if not INT64_MIN <= v <= INT64_MAX: + raise OwnIRError( + f"{where} {field!r} must fit a signed 64-bit integer, got {v} " + f"(spec/OwnIR.md §4.2)") + + def _check_column(v: Any, where: str) -> None: """Fail-loud shape check for an optional source `column` (#317). @@ -554,21 +572,38 @@ def _check_column(v: Any, where: str) -> None: if isinstance(v, bool) or not isinstance(v, int) or v < 1: raise OwnIRError( f"{where} 'column' must be a 1-based integer or absent, got {v!r}") + # …and bounded above, for the same reason `line` is (spec/OwnIR.md §4.2): + # a column no consumer can represent is not a usable coordinate. + _check_int_range(v, where, "column") -def _check_flow_columns(nodes: Any, where: str) -> None: - """Validate `column` on every flow op, including inside `if`/`while` bodies. +def _check_flow_columns(nodes: Any, where: str, depth: int = 0) -> None: + """Validate `column` on every flow op, including inside `if`/`while` bodies, + and bound how deeply those bodies may nest. Recursive because a hoisted branch acquire - the path most likely to be - forgotten - lives inside a nested body, not at the top level.""" + forgotten - lives inside a nested body, not at the top level. + + `depth` counts enclosing bodies; the top-level list is 0. The bound is a + defensive limit on external input (spec/OwnIR.md §4.2), not a property real + code reaches - the deepest flow body in this repository's fixtures is 3.""" + # The early return comes FIRST. Every op is probed for `then`/`else`/`body` + # whether or not it has them, so checking depth before this would count the + # absent ones and reject a body at exactly the limit — measured, not + # reasoned: `body nesting 32 (at limit)` was rejected until this order was + # fixed. Only a list that actually exists is a level. if not isinstance(nodes, list): return + if depth > MAX_NESTING_DEPTH: + raise OwnIRError( + f"{where} nested deeper than {MAX_NESTING_DEPTH} levels " + f"(spec/OwnIR.md §4.2)") for n in nodes: if not isinstance(n, dict): continue _check_column(n.get("column"), f"{where} op {n.get('op')!r}") for key in ("then", "else", "body"): - _check_flow_columns(n.get(key), where) + _check_flow_columns(n.get(key), where, depth + 1) def load(path: str) -> dict[str, Any]: @@ -679,12 +714,14 @@ def load(path: str) -> dict[str, Any]: ln = s.get("line", 0) if not isinstance(ln, int) or isinstance(ln, bool): raise OwnIRError("service 'line' must be an integer") + _check_int_range(ln, "service") # the consuming-constructor location (optional, P-006 Q#1) is validated like file/line. if not isinstance(s.get("ctor_file", "?"), str): raise OwnIRError("service 'ctor_file' must be a string") cln = s.get("ctor_line", 0) if not isinstance(cln, int) or isinstance(cln, bool): raise OwnIRError("service 'ctor_line' must be an integer") + _check_int_range(cln, "service", "ctor_line") if not isinstance(s.get("ctor_type", ""), str): raise OwnIRError("service 'ctor_type' must be a string") # DI004 call-site metadata (optional): an array of {type, file, line} objects. @@ -697,6 +734,8 @@ def load(path: str) -> dict[str, Any]: raise OwnIRError( "service 'root_resolve_sites' must be an array of " "{type:str, file:str, line:int} objects") + for site in sites: + _check_int_range(site.get("line", 0), "service root_resolve_site") # DI005 (scope-cached captive): types resolved from a self-created scope and cached # into a field, plus their field-store sites — validated like root_resolves / its sites. scope_cached = s.get("scope_cached", []) @@ -712,6 +751,8 @@ def load(path: str) -> dict[str, Any]: raise OwnIRError( "service 'scope_cache_sites' must be an array of " "{type:str, file:str, line:int} objects") + for site in csites: + _check_int_range(site.get("line", 0), "service scope_cache_site") # Optional reactive-effect graph (EFF001 — effect storm, P-020). Additive and # optional: an older core simply ignores it. Each effect carries its render-scope # binding table; the core (ownlang/effects.py) decides identity stability. @@ -728,6 +769,7 @@ def load(path: str) -> dict[str, Any]: eln = eff.get("line", 0) if not isinstance(eln, int) or isinstance(eln, bool): raise OwnIRError("effect 'line' must be an integer") + _check_int_range(eln, "effect") binds = eff.get("bindings", []) if not isinstance(binds, list) or not all(isinstance(b, dict) for b in binds): raise OwnIRError("effect 'bindings' must be a JSON array of objects") @@ -742,6 +784,7 @@ def load(path: str) -> dict[str, Any]: bln = b.get("line", 0) if not isinstance(bln, int) or isinstance(bln, bool): raise OwnIRError("binding 'line' must be an integer") + _check_int_range(bln, "binding") # Optional per-method flow bodies (P-016 B0b/B2 — local IDisposable # acquire/use/release over a CFG). Additive/optional; an older core ignores it. fns = result.get("functions", []) @@ -777,6 +820,7 @@ def load(path: str) -> dict[str, Any]: if not isinstance(pl, int) or isinstance(pl, bool): raise OwnIRError( f"parameter 'line' must be an integer, got {pl!r}") + _check_int_range(pl, "parameter") _check_column(p.get("column"), "parameter") peff = p.get("effect") if peff is not None and peff not in _PARAM_EFFECTS: diff --git a/spec/OwnIR.md b/spec/OwnIR.md index 774b750d..3939f1d1 100644 --- a/spec/OwnIR.md +++ b/spec/OwnIR.md @@ -172,6 +172,64 @@ when a real `startLine` is present. It is what OwnAudit's `finding-occurrence/v1 physical anchor reads (Own.NET#317, PhysShell/OwnAudit#58); a finding without one is anchored line-only, which is a degradation rather than a failure. +### 4.2 Defensive limits on externally supplied structure (normative) + +`OwnIR` is untrusted input: a file a frontend wrote, that `load()` reads. Two of +its shapes were unbounded, and an unbounded contract is only *implementable* in +a language that happens to have the same capabilities the reference does. Both +are now bounded, and the bound is part of the vocabulary rather than a property +of whichever consumer reads it first. + +**Source-coordinate integers fit a signed 64-bit integer.** + +- every **validated** `line` — `services[].line`, `services[].ctor_line`, + `services[].root_resolve_sites[].line`, `services[].scope_cache_sites[].line`, + `effects[].line`, `effects[].bindings[].line`, `functions[].params[].line`, + `protocol_functions[].events[].line` — lies in `[-2^63, 2^63 - 1]`; +- every `column` (§4.1) is `1..=2^63 - 1`, or absent, or `null`. + +The word *validated* is load-bearing, and the exception is recorded rather than +papered over. Two line-bearing fields are checked **nowhere** by `load()` — not +for range, and not even for type: `components[].subscriptions[].line` and the +`line` on a flow op inside `functions[].body`. Measured, `{"line": "x"}` and +`{"line": true}` are accepted on both. That predates this section, and both +implementations agree about it — neither the reference nor the Rust port types +those fields — so it is **not** a parity gap and closing it is not part of +removing one. It is a separate contract question: whether a coordinate that no +rule reads should nevertheless be well-formed. Until it is answered, the bound +above claims exactly the fields it covers. + +Python integers are unbounded, so the reference accepted coordinates no other +consumer could represent. That is not a generosity worth keeping: a coordinate +nothing downstream can hold is not a usable coordinate, and leaving it legal +turns every port into a source of "the reference accepted this and I cannot". +The bound is stated here and enforced in `load()`. + +**Flow bodies and protocol event trees nest at most 32 levels.** + +`functions[].body` and `protocol_functions[].events` nest through `then`, +`else` and `body`. The limit counts those enclosing bodies — the top-level list +is level 0 — so a document nesting exactly 32 is accepted and 33 is rejected. + +32 is chosen from measurement at both ends: + +- the deepest nesting in any `OwnIR` fixture in this repository is **3** + (`tests/fixtures/lowered/hoist_neg_nested_depth.facts.json`); the deepest + event tree is **2**; +- a JSON parser applying the widespread 128-level recursion cap stops accepting + these documents at **62** levels, because each `if` costs two JSON levels + (an object and an array). + +So the limit sits an order of magnitude above anything a producer has emitted +and roughly half way to the ceiling a consumer can still parse. It is expressed +in the `OwnIR` domain — nested bodies — and not in JSON levels, because nested +bodies are the thing a frontend can reason about; the ratio between the two is +an encoding detail. + +Both limits are **rejections at the strict door**, not coercions. `check_facts()` +on un-validated facts keeps its existing degrade-to-absent behaviour: two entry +points, two contracts, as with `column` in §4.1. + ## 5. Flow bodies (`functions[]`) A flow function has a `name`, a `file`, and a `body`: an ordered list of flow diff --git a/spec/ownir.schema.json b/spec/ownir.schema.json index d2a21ce9..90903413 100644 --- a/spec/ownir.schema.json +++ b/spec/ownir.schema.json @@ -48,8 +48,15 @@ "$defs": { "sourceColumn": { "description": "1-based source column of the SAME node `line` anchors on. Optional and additive, so OWNIR_VERSION stays 0 (spec/OwnIR.md §2): a producer that does not report one omits it and no consumer substitutes a value — not 0, not 1, and never recovered by re-reading the source line. Rides to SARIF as region.startColumn, where the OwnAudit occurrence anchor reads it (Own.NET#317).", + "type": ["integer", "null"], + "minimum": 1, + "maximum": 9223372036854775807 + }, + "sourceLine": { + "description": "A source line. Bounded to a signed 64-bit integer (spec/OwnIR.md \u00a74.2): Python integers are unbounded and the reference used to accept coordinates no other consumer could represent, which made the vocabulary implementable only in a language with bignums. A coordinate nothing downstream can hold is not a usable coordinate. Pinned to ownlang/obligations.py::INT64_MIN/INT64_MAX.", "type": "integer", - "minimum": 1 + "minimum": -9223372036854775808, + "maximum": 9223372036854775807 }, "resourceKind": { "description": "The resource-kind discriminator (spec/OwnIR.md §4). It selects the analysis path, so a present-but-unknown value is rejected at load (fail-loud) and a new kind must bump OWNIR_VERSION. Pinned to ownlang/ownir.py::_KNOWN_RESOURCE_KINDS.", @@ -82,7 +89,7 @@ "properties": { "type": { "type": "string" }, "file": { "type": "string" }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } } }, "component": { @@ -168,7 +175,7 @@ "required": ["name"], "properties": { "name": { "type": "string" }, - "line": { "type": "integer" }, + "line": { "$ref": "#/$defs/sourceLine" }, "column": { "$ref": "#/$defs/sourceColumn" }, "effect": { "$ref": "#/$defs/paramEffect" } } @@ -295,9 +302,9 @@ "weak_deps": { "type": "array", "items": { "type": "string" } }, "root_resolves": { "type": "array", "items": { "type": "string" } }, "file": { "type": "string" }, - "line": { "type": "integer" }, + "line": { "$ref": "#/$defs/sourceLine" }, "ctor_file": { "type": "string" }, - "ctor_line": { "type": "integer" }, + "ctor_line": { "$ref": "#/$defs/sourceLine" }, "ctor_type": { "type": "string" }, "root_resolve_sites": { "type": "array", "items": { "$ref": "#/$defs/site" } }, "scope_cached": { "type": "array", "items": { "type": "string" } }, @@ -311,7 +318,7 @@ "description": "Whether the effect performs I/O (default false).", "type": "boolean" }, - "line": { "type": "integer" }, + "line": { "$ref": "#/$defs/sourceLine" }, "deps": { "description": "The effect's dependency-array names.", "type": "array", @@ -333,7 +340,7 @@ "type": "string" }, "refs": { "type": "array", "items": { "type": "string" } }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } } }, "protocolMatcher": { @@ -420,7 +427,7 @@ "ev": { "const": "assign" }, "target": { "type": "string", "minLength": 1 }, "value": { "type": ["boolean", "null"] }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } }, "required": ["ev", "target"] }, @@ -431,7 +438,7 @@ "ev": { "const": "call" }, "callee": { "type": "string", "minLength": 1 }, "arg": { "type": ["string", "null"] }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } }, "required": ["ev", "callee"] }, @@ -440,7 +447,7 @@ "description": "A normal method exit.", "properties": { "ev": { "const": "return" }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } }, "required": ["ev"] }, @@ -449,7 +456,7 @@ "description": "An exceptional method exit. Frontends thread finally bodies onto exits, like the flow lowering (§5).", "properties": { "ev": { "const": "throw" }, - "line": { "type": "integer" } + "line": { "$ref": "#/$defs/sourceLine" } }, "required": ["ev"] }, @@ -458,7 +465,7 @@ "description": "A branch with both arms lowered.", "properties": { "ev": { "const": "if" }, - "line": { "type": "integer" }, + "line": { "$ref": "#/$defs/sourceLine" }, "then": { "type": "array", "items": { "$ref": "#/$defs/protocolEvent" } }, "else": { "type": "array", "items": { "$ref": "#/$defs/protocolEvent" } } }, @@ -469,7 +476,7 @@ "description": "A loop; the checker solves the body to a local fixpoint.", "properties": { "ev": { "const": "while" }, - "line": { "type": "integer" }, + "line": { "$ref": "#/$defs/sourceLine" }, "body": { "type": "array", "items": { "$ref": "#/$defs/protocolEvent" } } }, "required": ["ev"] diff --git a/tests/test_ownir_defensive_limits.py b/tests/test_ownir_defensive_limits.py new file mode 100644 index 00000000..6d76fbd4 --- /dev/null +++ b/tests/test_ownir_defensive_limits.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""The two defensive limits on externally supplied OwnIR (spec/OwnIR.md §4.2). + +`OwnIR` is a file some frontend wrote. Two of its shapes had no bound at all, +and the reference accepted both because *Python* has no bound: integers are +arbitrary-precision, and recursion is limited only by the interpreter's stack. + +That is not generosity, it is an accident of the reference's implementation +leaking into the contract. It surfaced as a measured Python-accept/Rust-reject +pair in #259 cp1 — the Rust port refusing documents the reference took — and the +honest reading of that is not "the port is over-strict". It is that the +vocabulary was only implementable in a language with bignums and a deep stack. + +So the fix is Python-first, which is the migration's standing rule: a +Rust/Python divergence is a Rust bug **unless behaviour changes in a separate +Python-first PR**. This is that PR. + +## What is asserted here, and why the boundary and not just the failure + +Each limit is pinned at three points: below, exactly at, and one past. A test +that only checks "something far too big is rejected" cannot tell a correct limit +from one that is off by one — and the nesting limit *was* off by one when first +written, because `_check_flow_columns` probes every op for `then`/`else`/`body` +whether or not it has them, so the absent ones were counting as levels. At-limit +rejected. Only the boundary case could catch that. + +## Why 32 + +Measured from both ends rather than picked: + +* the deepest nesting in any `OwnIR` fixture in this repository is 3; +* a JSON parser with the widespread 128-level recursion cap stops accepting + these documents at 62 levels, since each `if` costs two JSON levels. + +Run: python tests/test_ownir_defensive_limits.py + python tests/run_tests.py (in the suite) +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from typing import Any + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.obligations import INT64_MAX, INT64_MIN, MAX_NESTING_DEPTH +from ownlang.ownir import OwnIRError, load + + +def _load(document: Any) -> str | None: + """Run the strict door; return None on accept, the message on reject.""" + directory = tempfile.mkdtemp() + path = os.path.join(directory, "facts.ownir.json") + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(document, f) + try: + load(path) + return None + except OwnIRError as e: + return str(e) + finally: + if os.path.exists(path): + os.unlink(path) + os.rmdir(directory) + + +def _svc(**kw: Any) -> dict[str, Any]: + base: dict[str, Any] = {"name": "S", "lifetime": "singleton"} + base.update(kw) + return base + + +def _body(depth: int, key: str = "then") -> dict[str, Any]: + """A function flow body nested `depth` levels through `key`.""" + node: dict[str, Any] = {"op": "acquire", "column": 1} + for _ in range(depth): + node = {"op": "if" if key != "body" else "while", key: [node]} + return {"ownir_version": 0, "functions": [{"body": [node]}]} + + +def _events(depth: int, key: str = "then") -> dict[str, Any]: + """A protocol event tree nested `depth` levels through `key`.""" + node: dict[str, Any] = {"ev": "return", "line": 1} + for _ in range(depth): + node = {"ev": "if" if key != "body" else "while", "line": 1, key: [node]} + return {"ownir_version": 0, + "protocol_functions": [{"name": "M", "events": [node]}]} + + +# Every path a source coordinate travels. Named as (label, builder) so a new +# coordinate field that skips the check shows up as a missing row rather than +# as nothing at all. +LINE_PATHS: list[tuple[str, Any]] = [ + ("services[].line", lambda v: {"services": [_svc(line=v)]}), + ("services[].ctor_line", lambda v: {"services": [_svc(ctor_line=v)]}), + ("services[].root_resolve_sites[].line", + lambda v: {"services": [_svc(root_resolve_sites=[{"line": v}])]}), + ("services[].scope_cache_sites[].line", + lambda v: {"services": [_svc(scope_cache_sites=[{"line": v}])]}), + ("effects[].line", lambda v: {"effects": [{"line": v}]}), + ("effects[].bindings[].line", + lambda v: {"effects": [{"bindings": [{"line": v}]}]}), + ("functions[].params[].line", + lambda v: {"functions": [{"params": [{"name": "p", "line": v}]}]}), + ("protocol_functions[].events[].line", + lambda v: {"protocol_functions": [ + {"name": "M", "events": [{"ev": "return", "line": v}]}]}), +] + +COLUMN_PATHS: list[tuple[str, Any]] = [ + ("subscriptions[].column", + lambda v: {"components": [{"subscriptions": [{"column": v}]}]}), + ("functions[].params[].column", + lambda v: {"functions": [{"params": [{"name": "p", "column": v}]}]}), + ("functions[].body[].column", + lambda v: {"functions": [{"body": [{"op": "a", "column": v}]}]}), +] + + +# The spec's numbers, written as LITERALS. +# +# The rest of this file imports the constants from the module, which means it +# moves whenever they do — so on its own it can prove the limits are enforced +# consistently and cannot prove they are the *right* limits. Measured: widening +# `INT64_MAX` to `2**64 - 1` SURVIVED mutation until these three lines existed, +# because every boundary case simply followed the constant. +# +# A parity ledger caught the same class of error one layer up in #259 cp1. It is +# worth stating plainly: a test written in terms of the value under test asserts +# self-consistency, not correctness. +SPEC_INT64_MIN = -9223372036854775808 +SPEC_INT64_MAX = 9223372036854775807 +SPEC_MAX_NESTING_DEPTH = 32 + + +def _fail(message: str) -> int: + print(f"FAIL: {message}") + return 1 + + +def run() -> int: + failures = 0 + + # The constants match spec/OwnIR.md §4.2, checked against literals so that + # changing a limit fails here rather than silently redefining every + # boundary case below. + for name, actual, expected in ( + ("INT64_MIN", INT64_MIN, SPEC_INT64_MIN), + ("INT64_MAX", INT64_MAX, SPEC_INT64_MAX), + ("MAX_NESTING_DEPTH", MAX_NESTING_DEPTH, SPEC_MAX_NESTING_DEPTH), + ): + if actual != expected: + failures += _fail( + f"{name} is {actual}, spec/OwnIR.md §4.2 says {expected}. " + f"Changing a defensive limit is a contract change: update the " + f"spec, this literal, and the Rust side together") + + # ---- the JSON schema carries the same numbers ------------------------- + # `spec/ownir.schema.json` is the contract a NON-Python consumer validates + # against. If the bounds live only in `load()`, a producer can be schema- + # valid and still refused at the door — which is the same cross-consumer + # mismatch this change exists to remove, one layer out. + schema_path = os.path.join( + os.path.dirname(__file__), "..", "spec", "ownir.schema.json") + with open(schema_path, encoding="utf-8") as f: + defs = json.load(f)["$defs"] + for name, key, expected in ( + ("sourceLine", "minimum", SPEC_INT64_MIN), + ("sourceLine", "maximum", SPEC_INT64_MAX), + ("sourceColumn", "minimum", 1), + ("sourceColumn", "maximum", SPEC_INT64_MAX), + # `column: null` is accepted by `load()`, so the schema must permit it. + # `minimum`/`maximum` only constrain numbers, so the bounds above still + # apply to real values. + ("sourceColumn", "type", ["integer", "null"]), + ): + actual = defs.get(name, {}).get(key) + if actual != expected: + failures += _fail( + f"spec/ownir.schema.json $defs.{name}.{key} is {actual!r}, " + f"expected {expected!r} — the schema and `load()` must state the " + f"same bound or a producer can satisfy one and fail the other") + + # …and the schema binds `sourceLine` on EXACTLY the paths `load()` checks. + # + # Both directions are defects and both were shipped in the first attempt at + # this file. Binding a path the loader ignores makes a producer schema- + # invalid while the door accepts it; leaving a checked path unbound makes it + # schema-valid while the door refuses it. A blanket search-and-replace over + # `"line"` did the first to `resourceRecord` and `flowOp` — the two paths + # §4.2 documents as unvalidated — and the second to `ctor_line`, whose key + # simply differs. + # + # So the map is asserted as a map, not spot-checked. + BOUND = {"service": ["line", "ctor_line"], "site": ["line"], + "effect": ["line"], "binding": ["line"], "param": ["line"], + "protocolEvent": ["line"]} + UNBOUND = {"resourceRecord": ["line"], "flowOp": ["line"]} + + # Whole subschemas, not just their `$ref`. Asserting only "the ref is not + # sourceLine" left an unbound path free to be tightened another way — + # measured: pointing `flowOp.line` at `sourceColumn`, or giving it an inline + # `maximum`, both SURVIVED until this kept the object. + def _subschemas(node: Any, key: str, out: list[Any]) -> None: + if isinstance(node, dict): + for k, v in node.items(): + if k == key and isinstance(v, dict): + out.append(v) + else: + _subschemas(v, key, out) + elif isinstance(node, list): + for v in node: + _subschemas(v, key, out) + + # Anything that would make an UNBOUND path narrower than `load()`. + NARROWING = ("$ref", "minimum", "maximum", "exclusiveMinimum", + "exclusiveMaximum", "enum", "const", "multipleOf") + + for group, expect_bound in ((BOUND, True), (UNBOUND, False)): + for def_name, keys in group.items(): + for key in keys: + found: list[Any] = [] + _subschemas(defs.get(def_name, {}), key, found) + if not found: + failures += _fail( + f"$defs.{def_name} has no {key!r} — the binding map is " + f"stale, which means it is no longer evidence") + for sub in found: + if expect_bound: + if sub.get("$ref") != "#/$defs/sourceLine": + failures += _fail( + f"$defs.{def_name}.{key} is {sub!r}, expected " + f"$ref sourceLine — `load()` checks this path, " + f"so a schema-valid document must not be able " + f"to fail at the door") + continue + narrowed = [k for k in NARROWING if k in sub] + if narrowed or sub.get("type") != "integer": + failures += _fail( + f"$defs.{def_name}.{key} is {sub!r}, expected a " + f"plain integer with no {'/'.join(NARROWING[:3])}… " + f"— `load()` does NOT check this path (§4.2), so " + f"any narrowing makes a document schema-invalid " + f"that the door accepts") + + # ---- line: the full signed-64 range, and one step outside each end ----- + for label, build in LINE_PATHS: + for value, expect_reject in ((INT64_MIN, False), (0, False), + (INT64_MAX, False), + (INT64_MIN - 1, True), (INT64_MAX + 1, True)): + err = _load(build(value)) + if expect_reject and err is None: + failures += _fail(f"{label}: {value} accepted, expected reject") + elif not expect_reject and err is not None: + failures += _fail(f"{label}: {value} rejected — {err}") + elif expect_reject and "signed 64-bit" not in (err or ""): + failures += _fail( + f"{label}: {value} rejected for the wrong reason — {err}") + + # ---- column: 1-based below, and the same upper bound ------------------ + for label, build in COLUMN_PATHS: + for value, expect_reject in ((1, False), (INT64_MAX, False), + (INT64_MAX + 1, True)): + err = _load(build(value)) + if expect_reject and err is None: + failures += _fail(f"{label}: {value} accepted, expected reject") + elif not expect_reject and err is not None: + failures += _fail(f"{label}: {value} rejected — {err}") + # The 1-based rule still fires first for a low column, so the new upper + # bound cannot have replaced it. + if "1-based" not in (_load(build(0)) or ""): + failures += _fail(f"{label}: 0 no longer reports the 1-based rule") + + # ---- nesting: below, exactly at, one past — for both trees and both + # recursive keys, because `then`/`else`/`body` are three separate call + # sites and a limit added to one of them would pass a `then`-only test. + for label, build in (("functions[].body", _body), + ("protocol_functions[].events", _events)): + for key in ("then", "else", "body"): + for depth, expect_reject in ((MAX_NESTING_DEPTH - 1, False), + (MAX_NESTING_DEPTH, False), + (MAX_NESTING_DEPTH + 1, True)): + err = _load(build(depth, key)) + if expect_reject and err is None: + failures += _fail( + f"{label} via {key!r}: depth {depth} accepted, " + f"expected reject (the limit is {MAX_NESTING_DEPTH})") + elif not expect_reject and err is not None: + failures += _fail( + f"{label} via {key!r}: depth {depth} rejected — {err}") + elif expect_reject and "nested deeper" not in (err or ""): + failures += _fail( + f"{label} via {key!r}: depth {depth} rejected for the " + f"wrong reason — {err}") + + # ---- the tolerances the limits must NOT have tightened ---------------- + # A non-list body is skipped, not rejected; the reference returns early. + # Bounding depth is the kind of change that quietly turns that into a + # rejection, so it is asserted rather than assumed. + for label, document in ( + ("non-list body", {"ownir_version": 0, "functions": [{"body": 7}]}), + ("body of scalars", + {"ownir_version": 0, "functions": [{"body": [1, "x", None]}]}), + ("absent sections", {"ownir_version": 0}), + ("null column", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": None}]}]}), + ): + err = _load(document) + if err is not None: + failures += _fail(f"{label} must still be accepted — {err}") + + if failures: + return 1 + print( + f"ownir defensive limits OK: coordinates in " + f"[{INT64_MIN}, {INT64_MAX}] over {len(LINE_PATHS)} line paths and " + f"{len(COLUMN_PATHS)} column paths; nesting <= {MAX_NESTING_DEPTH} " + f"over 2 trees x 3 keys, each pinned at limit and limit+1" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(run())