Skip to content

fix(spec): bound buffer_write by size, and audit what bounds a slice write - #3431

Merged
gHashTag merged 1 commit into
masterfrom
audit/unbounded-writes
Sep 7, 2026
Merged

fix(spec): bound buffer_write by size, and audit what bounds a slice write#3431
gHashTag merged 1 commit into
masterfrom
audit/unbounded-writes

Conversation

@gHashTag

@gHashTag gHashTag commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Closes #3430 · Refs #3428

I closed #3428 saying I had not measured how widespread that shape was. This measures it — and most of what my matchers first told me was wrong.

The population

Of 4692 emitted functions: 67 take a slice parameter, 59 index into one.

Three matcher attempts, each earlier answer wrong

attempt verdict why it was wrong
1 "34 write without bounding by .len()" true and useless — most bound by an explicit length parameter, the only thing that works in C
2 "3 have a length parameter and do not use it" two of the three were false positivesternary_shift_right starts its loop at len - 1, and my regex looked for while (... len ...)
3 "24 bounded by a compile-time constant" inflated — TRIT_NEG/TRIT_POS/TRIT_ZERO are values, and "a declared constant appears in the body" swept in byte_to_trits, already hand-verified as correct

Hand-verified ten functions by eye: two matcher false positives, eight genuinely bound by something other than the buffer. I did not read all 59, so the class is somewhere between 8 and 24, and this publishes no single number for it.

Four different bounds are in use

bound example safe in C?
the buffers own .len() pipeline_run after #3429 no — C has no length
an explicit length parameter byte_to_trits, mac_parallel_multiply yes
a compile-time constant hash_insert/TABLE_SIZE, mem_store/MEM_SIZE, set_insert/SET_MAX only if the caller matches
nothing buffer_write no

cache_kv earns its own line: it writes cache_k[position * EMBED_DIM + i], so the index grows with position and nothing bounds position.

The one that is unambiguous, fixed here

buffer_write never compared head to size. The evidence that this is an oversight and not a convention is inside the same file: the sibling buffer_read guards its index — and its guard was tail == size, catching exactly one-past-the-end and letting every larger index through. Both now use >=.

The bound uses size, not buf_in.len, deliberately: a []T loses its length at the C ABI, so a .len-based guard would live in the Rust and Zig outputs and be absent from C. With the parameter, the identical check appears in both:

bool buffer_write(uint8_t* buf_in, size_t size, size_t head, uint8_t data) {
    if ((head >= size)) { return false; }
pub fn buffer_write(buf_in: &mut [u8], size: usize, head: usize, data: u8) -> bool {
    if (head >= size) { return false; }

Left as a decision

The constant-bounded set is a convention with an unstated precondition"your buffer must be at least CONST long". Coherent, enforced by nothing in any backend, and changing it means adding length parameters and changing the ABI.

All four backends generate; all three guards green — STALE 0, 936/936, 80/80, CONVERGED pairs run: 2 — and both seals for the edited spec refreshed.

…write

Closes #3430
Refs #3428

I closed #3428 saying I had not measured how widespread that shape was. Of 4692
emitted functions, 67 take a slice parameter and 59 index into one.

Classifying the bound took three matcher attempts and each earlier answer was
wrong:

  * "34 write without bounding by .len()" -- true and useless. Most bound by an
    explicit length PARAMETER, which is the only thing that can work in C.
  * "3 have a length parameter and do not use it as the bound" -- two of the
    three were false positives. ternary_shift_right starts its loop at len - 1
    and my regex looked for `while (... len ...)`.
  * "24 bounded by a compile-time constant" -- inflated. TRIT_NEG, TRIT_POS and
    TRIT_ZERO are VALUES, and matching "a declared constant appears in the body"
    swept in byte_to_trits, already hand-verified as correct.

Hand-verified ten functions by eye: two matcher false positives, eight
genuinely bound by something other than the buffer. I did not read all 59, so
the class is between 8 and 24 and this publishes no single number for it.

Four bounds are in use across the corpus: the buffer's own .len() (which C
cannot honour, having no length); an explicit length parameter; a compile-time
constant; and nothing at all. cache_kv writes cache_k[position * EMBED_DIM + i],
so its index grows with `position` and nothing bounds `position`.

Fixed here because it is unambiguous: buffer_write never compared `head` to
`size`. The evidence that this is an oversight rather than a convention is in
the same file -- the sibling buffer_read guards its index, and its guard was
`tail == size`, which catches exactly one-past-the-end and lets every larger
index through. Both now use `>=`.

The bound uses `size`, not buf_in.len, deliberately: a []T loses its length at
the C ABI, so a .len-based guard would exist in the Rust and Zig outputs and be
absent from C. With the parameter, `if ((head >= size))` appears identically in
both.

Left as a decision: the constant-bounded set -- hash_insert, hash_remove,
mem_store, mem_store_block, set_insert, cache_kv and the two gla_* -- is a
convention with an unstated precondition, enforced by nothing, and changing it
means adding length parameters and changing the ABI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gHashTag
gHashTag enabled auto-merge (squash) September 7, 2026 23:01
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-09-07 23:02:00 UTC

Summary

Status Count
Total Open PRs 16
PRs with Failing Checks 14
PRs with All Checks Green 2
READY 2
FAILING 14
PENDING 0
NO CHECKS YET 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=1d58d30ea13e != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@gHashTag
gHashTag merged commit 4a15915 into master Sep 7, 2026
28 of 31 checks passed
gHashTag added a commit that referenced this pull request Sep 7, 2026
Closes #3432
Refs #3430

One pass ago I listed cache_kv as needing "either a new parameter or .len, and
that is a decision". It was not: `const CONTEXT_LEN : usize = 81;  // Max
sequence length` is declared eleven lines above it in the same file.

Three functions share the shape, and two of them were not in that audit at all
-- they were found by reading the neighbours of the one I had flagged:

  apply_rope_qk    rope_tables.cos[position * ROPE_PAIRS + pair_idx]
                   against [CONTEXT_LEN * ROPE_PAIRS]  -- out-of-bounds READ
  cache_kv         cache_k[position * EMBED_DIM + i]
                   against // [CONTEXT_LEN][EMBED_DIM] -- out-of-bounds WRITE
  compute_scores   scores[h * CONTEXT_LEN + j], j < seq_len
                   against [NUM_HEADS * CONTEXT_LEN]   -- runs off every row

Nothing bounded `position` or `seq_len`. In the generated Rust each panics; in
C it does not, the parameter being a bare pointer.

The intent was already written down twice. The declaration carries the shape in
a comment -- `cache_k: []f32, cache_v: []f32,  // [CONTEXT_LEN][EMBED_DIM]` --
and the spec's own test allocates exactly `[0.0; EMBED_DIM * CONTEXT_LEN]`.
Stated in a comment, exercised by a test, enforced by nothing.

The guard uses the CONSTANT, not `.len`, for the same reason as #3431: a []T
loses its length at the C ABI, so a .len-based guard would exist in the Rust and
Zig outputs and be absent from C. With a constant the identical check appears in
all three. compute_scores clamps seq_len rather than returning, because a caller
asking for more rows than the buffer holds still wants the ones that exist.

rustc errors on the file are unchanged at 23; those are pre-existing and from
other causes.

The lesson is that I read the function and not the file. Twice now the thing I
called a decision was a constant already in scope.

Co-authored-by: lab <lab@example.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant