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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
{
"tilesets": [
{
"columns": 48,
"firstgid": 1,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_0",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 1729,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_1",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 3457,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_2",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 5185,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_3",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 6913,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_4",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 8641,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_5",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 10369,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_6",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 12097,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_7",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 13825,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_8",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 15553,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_9",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 17281,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_10",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 19009,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_11",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 20737,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_12",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 22465,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_13",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 24193,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_14",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 25921,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_15",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
},
{
"columns": 48,
"firstgid": 27649,
"image": "assets/World_A1.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1_16",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
}
]
}

View File

@@ -0,0 +1,31 @@
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "testsprite_maxframes",
"width": 48,
"height": 48,
"speed": 100,
"loop": true,
"loopReverse": true,
"frames": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15
]
}

View File

@@ -0,0 +1,32 @@
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "testsprite_toomanyframes",
"width": 48,
"height": 48,
"speed": 100,
"loop": true,
"loopReverse": true,
"frames": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16
]
}

View File

@@ -0,0 +1,17 @@
{
"spritesheet": {
"filename": "spritesheet.png",
"frame_width": 48,
"frame_height": 48
},
"name": "testsprite_widecount",
"width": 48,
"height": 48,
"speed": 100,
"loop": true,
"loopReverse": true,
"frames": [
0,
256
]
}

View File

@@ -184,6 +184,58 @@ akerr_ErrorContext *test_akgl_sprite_load_json(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief A sprite definition must not be able to write past `frameids`.
*
* `frameids` is AKGL_SPRITE_MAX_FRAMES bytes and the loader took 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 next pool slot. It also
* wrote through a `uint32_t *` cast of a `uint8_t *`, four bytes at a time,
* which is why the overrun reached four bytes past the array rather than one.
*
* The neighbouring slot is claimed and stamped first, so the test fails on the
* corruption rather than on whatever the corruption happens to do later.
*/
akerr_ErrorContext *test_akgl_sprite_load_json_bounds_frames(void)
{
PREPARE_ERROR(errctx);
akgl_Sprite *loaded = NULL;
int i = 0;
ATTEMPT {
// Exactly the maximum is legal and must still load.
TEST_EXPECT_OK(errctx, akgl_sprite_load_json("assets/testsprite_maxframes.json"),
"loading a sprite with exactly AKGL_SPRITE_MAX_FRAMES frames");
loaded = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite_maxframes", NULL);
FAIL_ZERO_BREAK(errctx, loaded, AKERR_KEY, "the max-frames sprite is not in the registry");
TEST_ASSERT(errctx, loaded->frames == AKGL_SPRITE_MAX_FRAMES,
"max-frames sprite loaded %d frames, expected %d",
loaded->frames, AKGL_SPRITE_MAX_FRAMES);
for ( i = 0; i < AKGL_SPRITE_MAX_FRAMES; i++ ) {
TEST_ASSERT(errctx, loaded->frameids[i] == (uint8_t)i,
"max-frames sprite frame %d is %d, expected %d",
i, loaded->frameids[i], i);
}
// One more than the maximum is refused, and nothing is registered.
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_sprite_load_json("assets/testsprite_toomanyframes.json"),
"loading a sprite with more frames than the array holds");
TEST_ASSERT(errctx,
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite_toomanyframes", NULL) == NULL,
"a sprite with too many frames was registered anyway");
// A frame number a uint8_t cannot hold is refused rather than truncated
// to something that indexes a different tile.
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_sprite_load_json("assets/testsprite_widecount.json"),
"loading a sprite whose frame number does not fit a uint8_t");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -211,6 +263,7 @@ int main(void)
CATCH(errctx, test_akgl_spritesheet_initialize());
CATCH(errctx, test_akgl_sprite_initialize());
CATCH(errctx, test_akgl_sprite_load_json());
CATCH(errctx, test_akgl_sprite_load_json_bounds_frames());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);

View File

@@ -130,6 +130,111 @@ void reset_string_heap(void)
}
}
/**
* @brief akgl_string_initialize must not write past the buffer it is zeroing.
*
* The `NULL` init path zeroed `sizeof(akgl_String)` bytes starting at `data`.
* `data` begins after the `int refcount` in front of it, so that ran four bytes
* past the end of the object -- straight onto the *next* pool slot's refcount,
* which is the field the allocator uses to decide whether a slot is free.
*
* Claiming two adjacent slots and initializing the first is enough to catch it:
* the second's refcount goes to zero and the pool believes it is free while the
* caller is still holding it.
*/
akerr_ErrorContext *test_akgl_string_initialize_stays_in_bounds(void)
{
PREPARE_ERROR(errctx);
akgl_String *first = NULL;
akgl_String *second = NULL;
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&first));
CATCH(errctx, akgl_heap_next_string(&second));
TEST_ASSERT(errctx, second == (first + 1),
"the pool did not hand out adjacent slots; this test needs them");
// A sentinel the overrun would land on. akgl_heap_next_string has
// already set it to 1; make it something an accidental write cannot
// coincide with.
second->refcount = 0x5A;
CATCH(errctx, akgl_string_initialize(first, NULL));
TEST_ASSERT(errctx, second->refcount == 0x5A,
"initializing a string wrote past its buffer: the next slot's "
"refcount is %d, expected %d",
second->refcount, 0x5A);
TEST_ASSERT(errctx, first->refcount == 1,
"initializing a string left its own refcount at %d, expected 1",
first->refcount);
TEST_ASSERT(errctx, first->data[0] == '\0',
"initializing a string with NULL did not zero its buffer");
TEST_ASSERT(errctx, first->data[AKGL_MAX_STRING_LENGTH - 1] == '\0',
"initializing a string with NULL did not zero its last byte");
} CLEANUP {
if ( second != NULL ) {
second->refcount = 1;
IGNORE(akgl_heap_release_string(second));
}
if ( first != NULL ) {
IGNORE(akgl_heap_release_string(first));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief akgl_string_copy must refuse a count that would leave both buffers.
*
* Both slots are exactly AKGL_MAX_STRING_LENGTH bytes, so a larger count read
* past the end of one and wrote past the end of the other. The header used to
* document that as behaviour.
*/
akerr_ErrorContext *test_akgl_string_copy_bounds_its_count(void)
{
PREPARE_ERROR(errctx);
akgl_String *src = NULL;
akgl_String *dest = NULL;
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&src));
CATCH(errctx, akgl_heap_next_string(&dest));
CATCH(errctx, akgl_string_initialize(src, "bounded"));
CATCH(errctx, akgl_string_initialize(dest, NULL));
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_string_copy(src, dest, AKGL_MAX_STRING_LENGTH + 1),
"copying one byte more than a pool string holds");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_string_copy(src, dest, -1),
"copying a negative number of bytes");
// The boundary itself is legal: it is exactly the buffer.
TEST_EXPECT_OK(errctx, akgl_string_copy(src, dest, AKGL_MAX_STRING_LENGTH),
"copying exactly a pool string's length");
TEST_ASSERT(errctx, strcmp((char *)&dest->data, "bounded") == 0,
"a full-length copy did not transfer the contents");
// 0 still means "the whole buffer" rather than "nothing".
CATCH(errctx, akgl_string_initialize(dest, NULL));
TEST_EXPECT_OK(errctx, akgl_string_copy(src, dest, 0),
"copying with a count of zero");
TEST_ASSERT(errctx, strcmp((char *)&dest->data, "bounded") == 0,
"a zero-count copy did not transfer the whole buffer");
} CLEANUP {
if ( dest != NULL ) {
IGNORE(akgl_heap_release_string(dest));
}
if ( src != NULL ) {
IGNORE(akgl_heap_release_string(src));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
@@ -151,6 +256,12 @@ int main(void)
reset_string_heap();
printf("test_akgl_string_initialize....\n");
test_akgl_string_initialize();
reset_string_heap();
printf("test_akgl_string_initialize_stays_in_bounds ...\n");
CATCH(errctx, test_akgl_string_initialize_stays_in_bounds());
reset_string_heap();
printf("test_akgl_string_copy_bounds_its_count ...\n");
CATCH(errctx, test_akgl_string_copy_bounds_its_count());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);

View File

@@ -229,6 +229,81 @@ akerr_ErrorContext *test_akgl_tilemap_compute_tileset_offsets(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief The loader must refuse a map that would overrun its fixed tables.
*
* Two loops indexed straight from the document. `curlayer->objects[j]` had no
* check against AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER, and `dest->tilesets[i]`
* none against AKGL_TILEMAP_MAX_TILESETS. The object one is the reachable one:
* 128 objects is not a large object layer, and akgl_TilemapObject is big, so
* the 129th wrote well past the end of the layer.
*
* akgl_tilemap_load_layers already bounded its own loop and raised
* AKERR_OUTOFBOUNDS, so the shape to copy was in the same file.
*/
akerr_ErrorContext *test_akgl_tilemap_load_bounds_fixed_tables(void)
{
akgl_String *pathstr = NULL;
PREPARE_ERROR(errctx);
json_t *doc = NULL;
json_error_t errdata;
ATTEMPT {
akgl_gamemap = &akgl_default_gamemap;
akgl_renderer = &akgl_default_renderer;
CATCH(errctx, akgl_heap_next_string(&pathstr));
// Exactly the maximum must still load.
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
SDL_GetBasePath(), "assets/snippets/test_tilemap_max_objects.json");
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "max-objects fixture: %s", (char *)&errdata.text);
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
TEST_EXPECT_OK(errctx,
akgl_tilemap_load_layer_objects(akgl_gamemap, doc, 0, pathstr),
"loading an object layer with exactly the maximum objects");
json_decref(doc);
doc = NULL;
// One more must be refused rather than written.
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
SDL_GetBasePath(), "assets/snippets/test_tilemap_too_many_objects.json");
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "too-many-objects fixture: %s", (char *)&errdata.text);
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_tilemap_load_layer_objects(akgl_gamemap, doc, 0, pathstr),
"loading an object layer with one object too many");
json_decref(doc);
doc = NULL;
// And the tileset table.
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
SDL_GetBasePath(), "assets/snippets/test_tilemap_too_many_tilesets.json");
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "too-many-tilesets fixture: %s", (char *)&errdata.text);
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_tilemap_load_tilesets(akgl_gamemap, doc, pathstr),
"loading a map with one tileset too many");
TEST_ASSERT(errctx, akgl_gamemap->numtilesets <= AKGL_TILEMAP_MAX_TILESETS,
"numtilesets reached %d, past the %d the table holds",
akgl_gamemap->numtilesets, AKGL_TILEMAP_MAX_TILESETS);
} CLEANUP {
if ( doc != NULL ) {
json_decref(doc);
}
if ( pathstr != NULL ) {
IGNORE(akgl_heap_release_string(pathstr));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_akgl_tilemap_load_layer_objects(void)
{
akgl_String *pathstr;
@@ -538,6 +613,7 @@ int main(void)
CATCH(errctx, test_akgl_tilemap_load_layer_tile());
CATCH(errctx, test_akgl_tilemap_load_layers());
CATCH(errctx, test_akgl_tilemap_load_tilesets());
CATCH(errctx, test_akgl_tilemap_load_bounds_fixed_tables());
//CATCH(errctx, test_akgl_tilemap_load());
//CATCH(errctx, test_akgl_tilemap_draw_tileset());
//CATCH(errctx, test_akgl_tilemap_draw());