Bounds-check DijkstraMap coordinates at the Python boundary

DijkstraMap.distance, .path_from, and .step_from forwarded their
coordinate arguments straight into TCOD's dijkstra routines, which
assert and abort() the entire process on out-of-bounds input. No
recoverable Python exception; the whole interpreter dies.

Validate at the binding layer. Out-of-range coordinates raise
IndexError with a message identifying the map dimensions.

Fuzz corpus crash
tests/fuzz/crashes/pathfinding_behavior-crash-b7ea442fd31774b9b16c8ae99c728f609c8c25d8
now runs cleanly.

closes #311

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-16 23:32:19 -04:00
commit 417fc43325
2 changed files with 86 additions and 0 deletions

View file

@ -289,6 +289,21 @@ PyObject* UIGridPathfinding::DijkstraMap_repr(PyDijkstraMapObject* self) {
return PyUnicode_FromFormat("<DijkstraMap root=(%d,%d)>", root.x, root.y);
}
// #311: Reject out-of-bounds coordinates at the Python boundary. TCOD's
// dijkstra routines assert and abort() the process for invalid coords, so
// catching them here turns a fatal crash into a recoverable IndexError.
static bool dijkstra_bounds_check(DijkstraMap* dmap, int x, int y) {
int w = dmap->getWidth();
int h = dmap->getHeight();
if (x < 0 || y < 0 || x >= w || y >= h) {
PyErr_Format(PyExc_IndexError,
"coordinate (%d, %d) out of bounds for DijkstraMap of size %dx%d",
x, y, w, h);
return false;
}
return true;
}
PyObject* UIGridPathfinding::DijkstraMap_distance(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"pos", NULL};
PyObject* pos_obj = NULL;
@ -307,6 +322,8 @@ PyObject* UIGridPathfinding::DijkstraMap_distance(PyDijkstraMapObject* self, PyO
return NULL;
}
if (!dijkstra_bounds_check(self->data.get(), x, y)) return NULL;
float dist = self->data->getDistance(x, y);
if (dist < 0) {
Py_RETURN_NONE; // Unreachable
@ -333,6 +350,8 @@ PyObject* UIGridPathfinding::DijkstraMap_path_from(PyDijkstraMapObject* self, Py
return NULL;
}
if (!dijkstra_bounds_check(self->data.get(), x, y)) return NULL;
std::vector<sf::Vector2i> path = self->data->getPathFrom(x, y);
// Create an AStarPath object to return
@ -366,6 +385,8 @@ PyObject* UIGridPathfinding::DijkstraMap_step_from(PyDijkstraMapObject* self, Py
return NULL;
}
if (!dijkstra_bounds_check(self->data.get(), x, y)) return NULL;
bool valid = false;
sf::Vector2i step = self->data->stepFrom(x, y, &valid);

View file

@ -0,0 +1,65 @@
"""Regression test for issue #311.
DijkstraMap.distance, .path_from, and .step_from previously forwarded
out-of-bounds coordinates directly to TCOD's dijkstra routines, which
call abort() on assertion failure. The fuzz target fuzz_pathfinding_behavior
surfaced a specific crash input.
Fixed by bounds-checking at the Python binding layer: out-of-range coords
now raise IndexError with a message including the map dimensions.
"""
import mcrfpy
import sys
def _make_dmap(w, h):
grid = mcrfpy.Grid(grid_size=(w, h))
return grid.get_dijkstra_map((w // 2, h // 2))
def _expect_index_error(fn, label):
try:
fn()
except IndexError:
return
except Exception as exc:
print(f"FAIL: {label} raised {type(exc).__name__}: {exc} (expected IndexError)")
sys.exit(1)
print(f"FAIL: {label} did not raise")
sys.exit(1)
def main():
dmap = _make_dmap(10, 8)
# Valid in-bounds coordinates should work
_ = dmap.distance((0, 0))
_ = dmap.distance((9, 7)) # corner
_ = dmap.distance((5, 4)) # middle
# Each out-of-range call must raise IndexError
_expect_index_error(lambda: dmap.distance((-1, 0)), "distance neg x")
_expect_index_error(lambda: dmap.distance((0, -1)), "distance neg y")
_expect_index_error(lambda: dmap.distance((10, 0)), "distance x>=width")
_expect_index_error(lambda: dmap.distance((0, 8)), "distance y>=height")
_expect_index_error(lambda: dmap.distance((9999, 9999)), "distance huge")
_expect_index_error(lambda: dmap.path_from((-5, -5)), "path_from negative")
_expect_index_error(lambda: dmap.path_from((100, 100)), "path_from too large")
_expect_index_error(lambda: dmap.step_from((-1, -1)), "step_from negative")
_expect_index_error(lambda: dmap.step_from((10, 8)), "step_from on boundary")
# After a bounds violation, the dmap must still be usable (no abort, no
# torn-down TCOD state)
_ = dmap.distance((1, 1))
_ = dmap.path_from((2, 2))
_ = dmap.step_from((3, 3))
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()