Bound every array a data file can index

Closes Defects items 16 and 17 and Known-and-still-open item 6. All three let
an asset file, or a caller's argument, write past a fixed array.

akgl_sprite_load_json took its frame count straight from the document and wrote
that many entries into a 16-byte frameids -- through a uint32_t * cast of a
uint8_t *, so each write touched four bytes and the overrun reached four bytes
past the array, into the rest of akgl_Sprite and then the next pool slot. The
count is checked first now, each id is read into an int and narrowed
deliberately, and a frame number too large for a uint8_t is refused rather than
truncated into an index for a different tile.

The tilemap loader had the same shape twice: objects[j] with no check against
AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER and tilesets[i] with none against
AKGL_TILEMAP_MAX_TILESETS. akgl_tilemap_load_layers already bounded its own
loop, so the pattern was in the same file. The object one is the reachable
half -- 128 objects is not a large object layer.

akgl_string_initialize zeroed sizeof(akgl_String) starting at `data`, which
begins after the refcount in front of it, so it ran four bytes past the end of
the object and onto the *next* slot's refcount -- the field the allocator reads
to decide whether a slot is free. Same file, same class, fixed with it:
akgl_string_copy accepted a count larger than the buffers, reading past one
pool slot and writing past another, which the header documented as behaviour.

Every case has a test that fails against the old code, with five new fixtures.
Exactly-the-maximum is asserted alongside one-past in each, so the bound cannot
be fixed by making the limit off by one.

25/25 pass, memcheck clean, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:24:35 -04:00
parent 6b1cf437d3
commit 230278d303
14 changed files with 3763 additions and 39 deletions

View File

@@ -119,6 +119,8 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
akgl_String *spritename = NULL;
akgl_String *filename_copy = NULL;
int i = 0;
int framecount = 0;
int frameid = 0;
FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename");
ATTEMPT {
@@ -161,9 +163,40 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
CATCH(errctx, akgl_get_json_boolean_value((json_t *)json, "loopReverse", &obj->loopReverse));
CATCH(errctx, akgl_get_json_array_value((json_t *)json, "frames", &frames));
obj->frames = json_array_size((json_t *)frames);
for ( i = 0 ; i < obj->frames; i++ ) {
CATCH(errctx, akgl_get_json_array_index_integer((json_t *)frames, i, (uint32_t *)&obj->frameids[i]));
// Bounded before anything is written. frameids is
// AKGL_SPRITE_MAX_FRAMES bytes, and this loop used to take its count
// straight from the document -- so a definition with seventeen frames
// wrote past the array, past the rest of akgl_Sprite, and into the
// neighbouring pool slot.
framecount = (int)json_array_size((json_t *)frames);
FAIL_NONZERO_BREAK(
errctx,
(framecount > AKGL_SPRITE_MAX_FRAMES),
AKERR_OUTOFBOUNDS,
"Sprite %s declares %d frames; the maximum is %d",
(char *)&obj->name,
framecount,
AKGL_SPRITE_MAX_FRAMES
);
obj->frames = framecount;
for ( i = 0 ; i < framecount; i++ ) {
// Read into an int and narrow deliberately. The old form wrote
// through a uint32_t * cast of a uint8_t *, so every element write
// touched four bytes; it only appeared to work because the next
// iteration overwrote the spill and the last one landed in the
// struct's alignment padding.
CATCH(errctx, akgl_get_json_array_index_integer((json_t *)frames, i, &frameid));
FAIL_NONZERO_BREAK(
errctx,
((frameid < 0) || (frameid > UINT8_MAX)),
AKERR_OUTOFBOUNDS,
"Sprite %s frame %d is %d; frame numbers are 0..%d",
(char *)&obj->name,
i,
frameid,
UINT8_MAX
);
obj->frameids[i] = (uint8_t)frameid;
}
} CLEANUP {
// The sprite copies every field it wants out of the document and the