Fix H2: shared reference-graph guard for standalone walkers (review #006)

Cyclic or over-deep $ref graphs stack-overflowed the three standalone
schema walkers (OffsetMap::compute, LayoutBuilder::new,
materialize_aligned) — SIGABRT on probe, parity-preserved from 0.2.0.

- New src/walk_guard.rs: check_ref_graph() — one bounded walk over the
  reachable reference graph (depth cap 128 matching the plan compilers,
  path-scoped cycle set; diamonds allowed, cycles and 201-def chains
  rejected with the plan compilers' error wording)
- All three walkers run the guard at entry, before any recursion;
  materialize_aligned's is defense-in-depth (a cyclic doc can no longer
  produce an OffsetMap, but mismatched doc/map inputs must still fail
  cleanly)
- Behavioral side effect, net-positive: the guard eagerly parses every
  reachable def, so an invalid non-root def now surfaces at
  LayoutBuilder::new instead of build() — four H3 tests updated to
  expect the same Schema error earlier
- Test family: 12 new tests (walk_guard, offset_map, layout_builder,
  materialize) covering self/two-def/composite-carrier cycles, deep
  chains, and diamond non-rejection; no stack-overflow reproducers
  in-tree per the review's Methodology warning
- Stale "walkers have no cycle guard" statements updated in
  validation.md, 030 plan, ADR-012, and the engine gate comment

Verified: 501 tests green (423 + 17 + 34 + 15 + 12 + 2 ignored),
clippy -D warnings clean, wasm32 build green, cargo doc zero warnings.
This commit is contained in:
glm-5.3-flash committed 2026-09-02 19:34:24 +00:00
1 parent 05a2a42983
commit 5e74b991ac
10 files changed
+555 -32

No files matched your search

@@ -497,11 +497,12 @@ aligned reads/writes over identical bytes.
`Schema` error instead of a stack overflow. The plan compile runs
*before* the layout build in `AlkTypeEngine::compile`, making it the
engine's reference-graph gate — `LayoutBuilder`/`OffsetMap`
struct-recursion has no cycle guard and previously could recurse
struct-recursion had no cycle guard and previously could recurse
unboundedly on such a document (a pre-existing untrusted-schema
hazard, surfaced by the phase-7 `compile_rejects_cyclic_ref_graph`
test). Hardening the layout walkers' own recursion is a separate
cleanup, not required while the gate holds in the engine path.
test). (Resolved since: review #006 H2 added the shared
`walk_guard::check_ref_graph` guard at every standalone walker entry,
so the trust boundary no longer depends on the engine path.)
- **Fingerprinting enables downstream uses.** Cross-run plan caching,
`alkcall` schema handshake, and schema-version diagnostics all
become possible without further API work — across `ReadPlan`,
+6 -5
View File
@@ -293,11 +293,12 @@ The expensive work happens once at schema load time:
2. Parse the root struct's `"endian"` annotation.
3. Compile the `ValidationPlan` — the value-domain constraint tree,
with eager `$ref` resolution. Its compile walk rejects cyclic `$ref`
graphs with a clean `Schema` error *before* the layout computation:
the layout walkers' struct/union recursion has no cycle guard, so
ordering the plan compile first is what keeps a self-referential
(malicious or accidental) document a handleable error, not a stack
overflow.
graphs with a clean `Schema` error *before* the layout computation.
(The layout walkers now also guard themselves — each standalone
entry point runs the shared reference-graph check
(`walk_guard::check_ref_graph`, review #006 H2) — so the plan-first
ordering is belt-and-suspenders at engine compile, and the trust
boundary no longer depends on the call path.)
4. Compute the layout (`LayoutBuilder` for packed, `OffsetMap` for
aligned).
5. If `json_schema` is `Some`, build the standard
+5 -3
View File
@@ -735,9 +735,11 @@ are trait derives, `fingerprint` is an inherent method).
> - **Engine:** `Arc<ValidationPlan>` built at `compile` in both modes;
> new accessor `validation_plan()`. `validate_bytes` walks the plan.
> **Bonus:** `ValidationPlan::compile` runs before the layout build and
> serves as the engine's cyclic-`$ref` gate (the layout walkers have no
> cycle guard; a cyclic doc used to be a stack-overflow hazard there —
> now a clean `Schema` error, see ADR-012 Consequences).
> serves as the engine's cyclic-`$ref` gate. (As of the review #006 H2
> fix, the layout walkers also carry their own guard —
> `walk_guard::check_ref_graph` runs at each standalone entry — so this
> ordering is now belt-and-suspenders rather than the only defense; the
> plan text below predates that fix.)
> - **Remaining phase-7 bench work** (a `validate_bytes`-stream bench
> in alktty) moves with the bench work into phase 8; a spot check
> during development measured plan-validate at ~0.2 µs/call vs ~0.6
+70 -5
View File
@@ -1,5 +1,5 @@
---
status: in-progress (H1, L1, H3, L5, L6 resolved 2026-09-02)
status: in-progress (H1, L1, H3, L5, L6, H2 resolved 2026-09-02)
last_updated: 2026-09-02
reviewed_artifacts:
- src/read_plan.rs
@@ -95,11 +95,15 @@ sub-types, `fingerprint()` methods; `Hash` on `Endian`/
| Severity | Count | Status |
|----------|------:|--------|
| High | 3 (H1, H2, H3) | H1, H3 resolved 2026-09-02 |
| High | 3 (H1, H2, H3) | H1, H2, H3 resolved 2026-09-02 |
| Medium | 4 (M1, M2, M3, M4) | open |
| Low | 6 (L1–L6) | L1, L5, L6 resolved 2026-09-02 |
| Nit | 2 (N1, N2) | open |
All three Highs are now resolved. The remaining work is Medium/Low/Nit:
M1+M2 (same file, one session), M3 (trivial), M4 (per-fix posture), and
L2/L3/L4/N1/N2 opportunistic.
The three Highs are adversarial-input crashes (H1, H2) and a
cross-consumer wire-layout convention gap (H3) — all three are the
exact class of problem AGENTS.md §3 exists for, given that `alkcall`
@@ -124,6 +128,12 @@ them became materially easier to hit with the 0.3.0 surface.
written, L5 doc added, L6 roundtrip test added. 488 tests green,
clippy `-D warnings` clean, wasm build green, `cargo doc --no-deps`
zero warnings.
- **H2 (2026-09-02):** resolved — see the "Resolution (2026-09-02)"
block at the end of the H2 finding. Shared one-shot reference-graph
guard (`walk_guard::check_ref_graph`) added and run at the entry of
all three standalone walkers; full cyclic/deep-nesting/diamond test
family added. 501 tests green, clippy `-D warnings` clean, wasm build
green, `cargo doc --no-deps` zero warnings.
---
@@ -316,6 +326,62 @@ calls require pre-validated documents — but that re-introduces the
explicitly retired for `ReadPlan`. The shared guard is the right fix;
the doc note is the minimum acceptable one.
**Resolution (2026-09-02) — shared one-shot graph guard, all three
walkers gated:**
1. **New module `src/walk_guard.rs`** (private, `pub(crate)`) with
`check_ref_graph(&BastDoc)` — a single bounded walk over the
reachable reference graph that rejects depth > 128
(`MAX_GRAPH_DEPTH`, matching the plan compilers'
`MAX_COMPILE_DEPTH`) and any `$ref` cycle, using the same
path-scoped cycle-set semantics the plan compilers use (diamond
refs compile; only genuine cycles trip). The walk covers every
carrier shape: inline structs recurse into fields, named defs are
entered via `resolve_ref`, union mappings and shared `fields` are
both checked, and arrays/records are seen through to their
element/value types (so a cycle behind an array-of-`$ref` hop is
caught — the probe-verified gap shape). Error text mirrors the plan
compilers' wording ("cyclic $ref through…", "compile depth
exceeded…") so downstream matching sees one shape.
2. **All three standalone walkers run the guard at entry**, before any
recursion: `OffsetMap::compute`, `LayoutBuilder::new`, and
`materialize_aligned` (the last as defense-in-depth — a cyclic doc
can no longer produce an `OffsetMap`, but a mismatched
doc/map pairing must still fail with a clean `Schema` error, not
overflow). `AlkTypeEngine::compile`'s ValidationPlan gate is
unchanged and now redundant-but-harmless; its doc comment is
updated to say so. The full shared-guard recommendation was taken
(not the minimum doc note).
3. **Behavioral side effect, net-positive:** `check_ref_graph`
resolves every reachable def eagerly (via `resolve_ref`), which
parses each def's full shape — so an invalid *non-root* def (e.g. a
`$defs` union that re-declares a shared field, or whose discriminator
field is missing/non-first) now surfaces at `LayoutBuilder::new`
instead of at `build()`. Four pre-existing H3 tests asserted the old
lazy-parse timing ("root parses, fails at build"); they were updated
to expect the same `Schema` error at `new()`. Earlier rejection of
the same malformed schemas — strictly better for untrusted input,
no accepted schema's behavior changed.
4. **Test family added (12 tests):** in `walk_guard.rs` (self-cycle,
two-def cycle, diamond allowed, 201-def deep chain → clean depth
error), in `offset_map.rs` (cycle rejected at compute, two-def
cycle, cycle behind inline-struct + array hop, diamond still
computes), in `layout_builder.rs` (cycle rejected at `new`, two-def
cycle, diamond still builds), and in `materialize.rs` (cyclic doc
+ unrelated map → guard fires before offset lookup, diamond still
materializes). No stack-overflow reproducers in the default suite —
the tests assert the clean-error half only (the overflow itself was
probe-verified SIGABRT in the review session; see the Methodology
warning about running it).
Verified: 501 tests green (423 crate + 17 + 34 + 15 + 12 + 2
pre-existing ignored), `cargo clippy --all-targets -- -D warnings`
clean, `cargo build --target wasm32-unknown-unknown --release` green,
`cargo doc --no-deps` zero warnings.
### H3. Field-name-discriminator unions: builder, reader, and materializer disagree on layout and on discriminator position
**Files**: `src/layout_builder.rs:485-527` (write side),
@@ -861,9 +927,8 @@ Worth recording, because the findings shouldn't eclipse it:
see the resolution block on the finding).
2. ~~**H3**~~ **resolved 2026-09-02** (with L5 + L6; see the
resolution block on the finding).
3. **H2** — shared walk guard (or the minimum doc note if the full
guard is judged too invasive for 0.3.x), plus the cyclic-schema
tests for all three walkers.
3. ~~**H2** — shared walk guard~~ **resolved 2026-09-02** (see the
resolution block on the finding).
4. **M1 + M2** — same file, same test family; do together.
5. **M3** — trivial deletion (or the feature decision, if kept).
6. **M4** — ongoing: per-fix coverage extension as recommended above;