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

55
TODO.md
View File

@@ -678,9 +678,19 @@ Each was found by a test written to assert correct behavior.
`src/util.c:208` compares `s1->pitch * s1->h` bytes of `s2` without verifying `src/util.c:208` compares `s1->pitch * s1->h` bytes of `s2` without verifying
the surfaces share dimensions, pitch, or format. the surfaces share dimensions, pitch, or format.
6. **`akgl_string_initialize` overflows by four bytes when `init` is NULL.** 6. **`akgl_string_initialize` overflows by four bytes when `init` is NULL.**
`src/staticstring.c:17` does `memset(&obj->data, 0x00, sizeof(akgl_String))`, **Fixed in 0.5.0**: it zeroes `sizeof(obj->data)`. The four bytes it used to
but `data` starts four bytes into the struct (after `refcount`), so the memset run past the end of the object landed on the *next* pool slot's `refcount`,
runs four bytes past the end. which is the field the allocator reads to decide whether a slot is free --
so the overrun could hand a live string out twice.
`tests/staticstring.c` claims two adjacent slots, stamps a sentinel into the
second's refcount, and initializes the first.
Fixed alongside it, because it is the same file and the same class:
`akgl_string_copy` accepted a `count` above `AKGL_MAX_STRING_LENGTH`, which
read past the end of one pool slot and wrote past the end of another. The
header documented that as behaviour. It is `AKERR_OUTOFBOUNDS` now, and a
negative count is refused too.
7. **Savegame name lengths disagree between writer and reader.** 7. **Savegame name lengths disagree between writer and reader.**
`akgl_game_save_actors` writes spritesheet names at `akgl_game_save_actors` writes spritesheet names at
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) and character names at `AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) and character names at
@@ -819,35 +829,24 @@ without coming here first. Ordered by blast radius.
only record that a retry is wanted. Touches `src/util.c:117-121` only. only record that a retry is wanted. Touches `src/util.c:117-121` only.
16. **`akgl_sprite_load_json` does not bound the `frames` array.** 16. **`akgl_sprite_load_json` does not bound the `frames` array.**
`src/sprite.c:165` sets `obj->frames` from `json_array_size()` and **Fixed in 0.5.0.** The count is bounded against `AKGL_SPRITE_MAX_FRAMES`
`src/sprite.c:167` then writes that many entries into before anything is written, and each element is read into an `int` and
`frameids[AKGL_SPRITE_MAX_FRAMES]`, which is 16. A sprite definition with 17 narrowed deliberately rather than written through a `uint32_t *` cast of a
or more frames writes past the array into the rest of `akgl_Sprite`, and `uint8_t *`. A frame number that does not fit a `uint8_t` is refused too,
past the struct into the neighbouring pool slot. It is also written through rather than truncated into an index that names a different tile.
a `uint32_t *` cast of a `uint8_t *`, so each element write touches four
bytes; that happens to work on a little-endian machine because the following
iterations overwrite the spill and the last write lands in the struct's
alignment padding, but it is not portable and it is what makes the overflow
reach four bytes past `frames` rather than one.
Fix: refuse a `frames` array longer than `AKGL_SPRITE_MAX_FRAMES` with `tests/sprite.c` covers exactly the maximum (must load, and every id must
`AKERR_OUTOFBOUNDS`, and read into an `int` local before narrowing to arrive), one past it, and the wide frame number, against three new fixtures.
`uint8_t`. Touches `src/sprite.c:164-169`. Against the old code the middle case loads happily.
17. **Two more unbounded array loads in the tilemap loader.** 17. **Two more unbounded array loads in the tilemap loader.**
`src/tilemap.c:387-389` walks a Tiled object layer straight into **Fixed in 0.5.0**, both with the bound at the top of the loop body, the
`curlayer->objects[j]` with no check against shape `akgl_tilemap_load_layers` in the same file already used.
`AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` (128), and `src/tilemap.c:278-280`
fills `dest->tilesets[i]` with no check against `AKGL_TILEMAP_MAX_TILESETS`
(16).
Both are the same shape as item 16 and both are reachable from an ordinary
map file: 128 objects is not a large object layer. Note that
`akgl_tilemap_load_layers` *does* bound its loop and raises
`AKERR_OUTOFBOUNDS`, so the pattern to copy is already in the same file.
Fix: add the bound check at the top of each loop body, matching `tests/tilemap.c` covers both against generated fixtures: an object layer
`akgl_tilemap_load_layers`. Touches `src/tilemap.c:387` and of exactly 128 (must load) and of 129, and a map with 17 tilesets. The
`src/tilemap.c:278`. object one is the reachable half -- 128 objects is not a large object layer
and `akgl_TilemapObject` is not small.
18. **`akgl_get_json_with_default` defaults on a status the array accessors never 18. **`akgl_get_json_with_default` defaults on a status the array accessors never
raise.** `src/json_helpers.c:164-165` handles `AKERR_KEY` and `AKERR_INDEX`, raise.** `src/json_helpers.c:164-165` handles `AKERR_KEY` and `AKERR_INDEX`,

View File

@@ -40,10 +40,10 @@ typedef struct
* @return `NULL` on success, otherwise an error context owned by the caller. * @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj is `NULL`. * @throws AKERR_NULLPOINTER If @p obj is `NULL`.
* *
* @note Known defect: the `NULL` @p init path zeroes `sizeof(akgl_String)` * @note Until 0.5.0 the `NULL` @p init path zeroed `sizeof(akgl_String)` bytes
* bytes starting at `data`, which is four bytes past the end of the * starting at `data`, which is four bytes past the end of the buffer --
* buffer -- `refcount` sits in front of it. TODO.md, "Known and still * `refcount` sits in front of it, so the overrun landed on the next pool
* open" item 6. * slot's reference count.
*/ */
akerr_ErrorContext AKERR_NOIGNORE *akgl_string_initialize(akgl_String *obj, char *init); akerr_ErrorContext AKERR_NOIGNORE *akgl_string_initialize(akgl_String *obj, char *init);
/** /**
@@ -58,11 +58,14 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_string_initialize(akgl_String *obj, char
* @param count Maximum bytes to copy. 0 selects #AKGL_MAX_STRING_LENGTH, the * @param count Maximum bytes to copy. 0 selects #AKGL_MAX_STRING_LENGTH, the
* whole buffer. A @p count shorter than the source truncates * whole buffer. A @p count shorter than the source truncates
* without writing a terminator; a @p count longer than the source * without writing a terminator; a @p count longer than the source
* zero-pads the remainder, per `strncpy`. Values above * zero-pads the remainder, per `strncpy`. A negative @p count, or
* #AKGL_MAX_STRING_LENGTH overrun both buffers and are not * one above #AKGL_MAX_STRING_LENGTH, is refused -- both buffers
* rejected. * are exactly that long, so a larger count walked off the end of
* two pool slots at once.
* @return `NULL` on success, otherwise an error context owned by the caller. * @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p src or @p dest is `NULL`. * @throws AKERR_NULLPOINTER If @p src or @p dest is `NULL`.
* @throws AKERR_OUTOFBOUNDS If @p count is negative or above
* #AKGL_MAX_STRING_LENGTH.
* @throws errno Whatever `errno` holds if `strncpy` returns something other * @throws errno Whatever `errno` holds if `strncpy` returns something other
* than @p dest. In practice `strncpy` always returns its destination, so * than @p dest. In practice `strncpy` always returns its destination, so
* this path is unreachable rather than merely rare. * this path is unreachable rather than merely rare.

View File

@@ -119,6 +119,8 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
akgl_String *spritename = NULL; akgl_String *spritename = NULL;
akgl_String *filename_copy = NULL; akgl_String *filename_copy = NULL;
int i = 0; int i = 0;
int framecount = 0;
int frameid = 0;
FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename"); FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename");
ATTEMPT { 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_boolean_value((json_t *)json, "loopReverse", &obj->loopReverse));
CATCH(errctx, akgl_get_json_array_value((json_t *)json, "frames", &frames)); CATCH(errctx, akgl_get_json_array_value((json_t *)json, "frames", &frames));
obj->frames = json_array_size((json_t *)frames); // Bounded before anything is written. frameids is
for ( i = 0 ; i < obj->frames; i++ ) { // AKGL_SPRITE_MAX_FRAMES bytes, and this loop used to take its count
CATCH(errctx, akgl_get_json_array_index_integer((json_t *)frames, i, (uint32_t *)&obj->frameids[i])); // 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 { } CLEANUP {
// The sprite copies every field it wants out of the document and the // The sprite copies every field it wants out of the document and the

View File

@@ -14,7 +14,12 @@ akerr_ErrorContext *akgl_string_initialize(akgl_String *obj, char *init)
if ( init != NULL ) { if ( init != NULL ) {
strncpy((char *)&obj->data, init, AKGL_MAX_STRING_LENGTH); strncpy((char *)&obj->data, init, AKGL_MAX_STRING_LENGTH);
} else { } else {
memset(&obj->data, 0x00, sizeof(akgl_String)); // sizeof(obj->data), not sizeof(akgl_String). `data` starts after the
// `refcount` in front of it, so zeroing the size of the whole struct
// from the start of the buffer ran four bytes past the end of the
// object -- into the next pool slot's refcount, which is what makes a
// free slot look claimed or a claimed one look free.
memset(&obj->data, 0x00, sizeof(obj->data));
} }
obj->refcount = 1; obj->refcount = 1;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
@@ -28,6 +33,16 @@ akerr_ErrorContext *akgl_string_copy(akgl_String *src, akgl_String *dest, int co
if ( count == 0 ) { if ( count == 0 ) {
count = AKGL_MAX_STRING_LENGTH; count = AKGL_MAX_STRING_LENGTH;
} }
// Both buffers are exactly AKGL_MAX_STRING_LENGTH bytes, so a larger count
// walks off the end of two pool slots at once. Refused rather than
// documented, which is what it used to be.
FAIL_NONZERO_RETURN(
errctx,
((count < 0) || (count > AKGL_MAX_STRING_LENGTH)),
AKERR_OUTOFBOUNDS,
"Copy count %d is outside 0..%d",
count,
AKGL_MAX_STRING_LENGTH);
if ( (char *)dest->data != strncpy((char *)&dest->data, (char *)&src->data, count) ) { if ( (char *)dest->data != strncpy((char *)&dest->data, (char *)&src->data, count) ) {
FAIL_RETURN(errctx, errno, "strncpy"); FAIL_RETURN(errctx, errno, "strncpy");
} }

View File

@@ -238,6 +238,17 @@ akerr_ErrorContext *akgl_tilemap_load_tilesets(akgl_Tilemap *dest, json_t *root,
ATTEMPT { ATTEMPT {
CATCH(errctx, akgl_get_json_array_value(root, "tilesets", &tilesets)) CATCH(errctx, akgl_get_json_array_value(root, "tilesets", &tilesets))
for (i = 0; i < json_array_size((json_t *)tilesets); i++) { for (i = 0; i < json_array_size((json_t *)tilesets); i++) {
// The bound goes at the top of the body, matching
// akgl_tilemap_load_layers. Without it a map with seventeen
// tilesets wrote past the fixed table -- and unlike the layer
// count, nothing anywhere else was checking this one.
FAIL_NONZERO_BREAK(
errctx,
(i >= AKGL_TILEMAP_MAX_TILESETS),
AKERR_OUTOFBOUNDS,
"Map declares more than %d tilesets",
AKGL_TILEMAP_MAX_TILESETS
);
CATCH(errctx, akgl_get_json_array_index_object((json_t *)tilesets, i, &jstileset)); CATCH(errctx, akgl_get_json_array_index_object((json_t *)tilesets, i, &jstileset));
CATCH(errctx, akgl_tilemap_load_tilesets_each(jstileset, dest, i, dirname)); CATCH(errctx, akgl_tilemap_load_tilesets_each(jstileset, dest, i, dirname));
CATCH(errctx, akgl_tilemap_compute_tileset_offsets(dest, i)); CATCH(errctx, akgl_tilemap_compute_tileset_offsets(dest, i));
@@ -312,6 +323,14 @@ akerr_ErrorContext *akgl_tilemap_load_layer_objects(akgl_Tilemap *dest, json_t *
len = json_array_size((json_t *)layerdata); len = json_array_size((json_t *)layerdata);
curlayer = &dest->layers[layerid]; curlayer = &dest->layers[layerid];
for ( j = 0; j < len; j++ ) { for ( j = 0; j < len; j++ ) {
FAIL_NONZERO_RETURN(
errctx,
(j >= AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER),
AKERR_OUTOFBOUNDS,
"Object layer %d has more than %d objects",
layerid,
AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER
);
PASS(errctx, akgl_get_json_array_index_object((json_t *)layerdata, j, &layerdatavalue)); PASS(errctx, akgl_get_json_array_index_object((json_t *)layerdata, j, &layerdatavalue));
curobj = &curlayer->objects[j]; curobj = &curlayer->objects[j];
PASS(errctx, akgl_get_json_string_value((json_t *)layerdatavalue, "name", &tmpstr)); PASS(errctx, akgl_get_json_string_value((json_t *)layerdatavalue, "name", &tmpstr));

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); 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) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -211,6 +263,7 @@ int main(void)
CATCH(errctx, test_akgl_spritesheet_initialize()); CATCH(errctx, test_akgl_spritesheet_initialize());
CATCH(errctx, test_akgl_sprite_initialize()); CATCH(errctx, test_akgl_sprite_initialize());
CATCH(errctx, test_akgl_sprite_load_json()); CATCH(errctx, test_akgl_sprite_load_json());
CATCH(errctx, test_akgl_sprite_load_json_bounds_frames());
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH_NORETURN(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) int main(void)
{ {
@@ -151,6 +256,12 @@ int main(void)
reset_string_heap(); reset_string_heap();
printf("test_akgl_string_initialize....\n"); printf("test_akgl_string_initialize....\n");
test_akgl_string_initialize(); 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 { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);

View File

@@ -229,6 +229,81 @@ akerr_ErrorContext *test_akgl_tilemap_compute_tileset_offsets(void)
SUCCEED_RETURN(errctx); 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) akerr_ErrorContext *test_akgl_tilemap_load_layer_objects(void)
{ {
akgl_String *pathstr; akgl_String *pathstr;
@@ -538,6 +613,7 @@ int main(void)
CATCH(errctx, test_akgl_tilemap_load_layer_tile()); CATCH(errctx, test_akgl_tilemap_load_layer_tile());
CATCH(errctx, test_akgl_tilemap_load_layers()); CATCH(errctx, test_akgl_tilemap_load_layers());
CATCH(errctx, test_akgl_tilemap_load_tilesets()); 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_load());
//CATCH(errctx, test_akgl_tilemap_draw_tileset()); //CATCH(errctx, test_akgl_tilemap_draw_tileset());
//CATCH(errctx, test_akgl_tilemap_draw()); //CATCH(errctx, test_akgl_tilemap_draw());