Sanitizer (ASan + UBSan) run of gunittest and pytest suites: 20 findings (2026-10-11)

Open
#8,048 3 comments 1 reaction 0 assignees View on GitHub

Maintainers usually reply within 1 day

@Jayant-kernel is already working on this.

Since Oct 19, 2026.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
c, cpp, linux, python

Research direction

Start with the verified findings in the issue, especially the named locations in include/grass/iostream/ami_sort.h, include/grass/iostream/ami_stream.h, raster/r.reclass/reclass.c, raster/r.stats/stats.c, and raster/r.univar/r.univar_main.c. Read each sanitizer report and its verification notes in the attached archive to understand the reproduction and affected test. Done means confirmed findings have focused fixes and regression coverage; this spans multiple subsystems and needs careful review.

Written by the indexing model from the issue text.

Description

bug C libraries raster raster3d vector

Summary

One-off clang ASan + UBSan pass over the GRASS gunittest (testsuite/) and pytest (tests/) tests. All entries below are reported by the sanitizers; "likely cause" notes are hypotheses from reading the code at the reported line, not confirmed bugs.
AI filtered out most of the noise already. Categorized as HIGH, MEDIUM or LOW.

Attached here are some extra files too from the run, like the raw logs of all issues: sanitizer-run-4.tar.gz

  • GRASS commit: 8f7506115d6125bf18f9edd87a39d4b7cbee59b7
  • Compiler: Ubuntu clang 18.1.3; OS: Ubuntu 24.04.5 LTS (x86_64), CMake + Ninja build, -DWITH_PDAL=OFF -DWITH_GUI=OFF -DWITH_X11=OFF -DWITH_OPENGL=OFF -DWITH_DOCS=OFF
Build flags, run configuration, dedup method
  • Build flags: CFLAGS=CXXFLAGS="-g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined -fsanitize-recover=address,undefined -fno-sanitize=function", LDFLAGS="-fsanitize=address,undefined -shared-libasan -Wl,-rpath,<clang rt dir>"

  • Run: all 318 testsuite/test*.py files (in-tree copy), 4 in parallel, each in a temporary mapset of the nc_spm_08_grass7 (basic) sample dataset, 600 s timeout per file; wall time ~31 min. 270 files exited 0, 46 exited 1 (see Not covered), 2 aborted (see Filtered out).

  • pytest run: all 172 tests/**/*_test.py and test_*.py files (one pytest process per file, 4 in parallel, pytest 9.1.1 in a venv because the repo requires >= 8.2, --timeout=600), grass from the build tree on PATH; wall time ~13 min. 164 files passed, 6 had failing/erroring tests (not sanitizer related: missing osgeo._gdal for the venv/system GDAL python bindings, g.extension needing a network/installed GRASS, r.pack running as root so the non-writable-dir test did not fail, man/g.search.modules needing the GUI tree), 2 files exited 5 (no tests collected).

  • Counts: 4601 raw reports -> 4526 in GRASS code -> 20 unique findings (6 HIGH, 6 MEDIUM, 8 LOW). 75 reports filtered as third-party only.

  • Verification: after triage, each finding was re-checked by a separate pass that read the code and reproduced it with a minimal command (scripts and notes in verify/). Each checklist item carries a [verified: ...] tag. One additional finding (H06, r.stats INT_MIN negation) was found during this step and was not seen in the test runs, so it is not counted in the raw-report numbers.
    Counts cover both runs (gunittest 4513 raw, pytest 88 raw). Findings seen in both are merged. Dedup key: sanitizer + error type + top in-GRASS frame (UBSan) or top in-GRASS frame + allocation site (ASan). The "top 3 frames" key was relaxed because template instantiations in r.terraflow and varying callers of library functions (G_ls2) split one location into many keys; stacks below show the first example.

Findings

ID Type Location Tests Hits
H01 alloc-dealloc-mismatch include/grass/iostream/ami_sort.h:133 1 18
H02 alloc-dealloc-mismatch include/grass/iostream/ami_stream.h:506 1 4071
H03 alloc-dealloc-mismatch include/grass/iostream/ami_stream.h:506 1 67
H04 alloc-dealloc-mismatch include/grass/iostream/replacementHeap.h:154 1 2
H05 heap-buffer-overflow raster/r.reclass/reclass.c:58 1 1
H06 INT_MIN negation (OOB read) raster/r.stats/stats.c:137 0 0
M01 signed-integer-overflow lib/raster3d/long.c:27 28 183
M02 float-cast-overflow raster/r.sim/simlib/hydro.c:112 1 1
M03 signed-integer-overflow raster/r.stats/stats.c:134 2 3
M04 signed-integer-overflow raster/r.stats/stats.c:134 2 3
M05 signed-integer-overflow raster/r.texture/h_measure.c:373 2 2
M06 signed-integer-overflow raster/r.univar/r.univar_main.c:516 1 3
L01 array-bounds lib/gis/aprintf.c:198 1 1
L02 nonnull-attribute lib/gis/ls.c:116 29 115
L03 nonnull-attribute lib/gis/plot.c:455 1 1
L04 pointer-overflow (null + offset) raster/r.fill.stats/main.c:595 1 4
L05 pointer-overflow (null + offset) raster/r.sun/main.c:1955 1 5
L06 pointer-overflow (null + offset) raster/r.sun/main.c:1972 1 8
L07 nonnull-attribute vector/v.extract/extract.c:35 5 19
L08 nonnull-attribute vector/v.extract/extract.c:285 5 19

Details (stack, message, repro, raw log) for each item are in the collapsed blocks under Details below.

HIGH
  • H01 alloc-dealloc-mismatch in include/grass/iostream/ami_sort.h:133 (AMI_sort()): name is returned by AMI_STREAM::name() allocated with new char[] (ami_stream.h:429) but freed with scalar delete name. [verified: confirmed]
  • H02 alloc-dealloc-mismatch in include/grass/iostream/ami_stream.h:506 (AMI_STREAM::~AMI_STREAM()): The stream buffer buf is allocated with new char[] (ami_stream.h lines 236/281) but released with scalar delete buf in the destructor. [verified: confirmed]
  • H03 alloc-dealloc-mismatch in include/grass/iostream/ami_stream.h:506 (AMI_STREAM::~AMI_STREAM()): The stream buffer buf is allocated with new char[] (ami_stream.h lines 236/281) but released with scalar delete buf in the destructor. [verified: confirmed]
  • H04 alloc-dealloc-mismatch in include/grass/iostream/replacementHeap.h:154 (ReplacementHeap::ReplacementHeap()): Same as ami_sort.h:133: name from AMI_STREAM::name() (new char[]) freed with scalar delete. [verified: confirmed]
  • H05 heap-buffer-overflow in raster/r.reclass/reclass.c:58 (compose()): In compose(), if no old category maps through the intermediate table, new->min = new->max = 0 is set but the second loop still indexes old->table[i - old->min] with i = 0; for old->min > 0 this reads before the table (ASan: 4 bytes before the region, allocated in get_reclass_table). [verified: confirmed]
  • H06 INT_MIN negation (OOB read) in raster/r.stats/stats.c:137 (update_cell_stats()): -hash of INT_MIN stays negative, so the hash table is indexed with a negative value. [verified: confirmed, real memory bug (found while verifying M04/M05; not seen in the test runs)]
MEDIUM
  • M01 signed-integer-overflow in lib/raster3d/long.c:27 (Rast3d_long_encode()): d *= 256 runs once more than needed on the last byte iteration, overflowing long (256^8); the value is not used afterwards. [verified: benign]
  • M02 float-cast-overflow in raster/r.sim/simlib/hydro.c:112 (main_loop()): gen = rwalk * si / sisum is NaN (likely sisum == 0), then (int)gen casts NaN to int. [verified: benign on x86-64]
  • M03 signed-integer-overflow in raster/r.stats/stats.c:134 (update_cell_stats()): The hash hash * HASHMOD + value intentionally wraps around for large category values; overflow of signed int is UB, though the result is only used as a bucket index. [verified: hash overflow benign; see H06]
  • M04 signed-integer-overflow in raster/r.stats/stats.c:134 (update_cell_stats()): The hash hash * HASHMOD + value intentionally wraps around for large category values; overflow of signed int is UB, though the result is only used as a bucket index. [verified: hash overflow benign; see H06]
  • M05 signed-integer-overflow in raster/r.texture/h_measure.c:373 (f10_dvar()): tmp * tmp is computed in int where tmp is the squared tone range; for wide ranges (47961^2) it overflows before the float division. [verified: output changes slightly]
  • M06 signed-integer-overflow in raster/r.univar/r.univar_main.c:516 (process_raster()): val * val is computed in int before being added to the floating-point sumsq; values above ~46340 overflow. [verified: WRONG OUTPUT]
LOW
  • L01 array-bounds in lib/gis/aprintf.c:198 (oaprintf()): char *c = CONVS - 1; forms a pointer one before the start of the string literal, then pre-increments it; the sanitizer flags the index -1. [verified: formal UB only]
  • L02 nonnull-attribute in lib/gis/ls.c:116 (G_ls2()): qsort(dir_listing, n, ...) is called with dir_listing == NULL when the directory has no (visible) entries (n = 0). [verified: formal UB only]
  • L03 nonnull-attribute in lib/gis/plot.c:455 (G_plot_polygon()): qsort(st->P, st->np, ...) with a NULL st->P when the polygon has no edge points (np = 0). [verified: formal UB only]
  • L04 pointer-overflow (null + offset) in raster/r.fill.stats/main.c:595 (interpolate_row()): err_output++ advances a pointer that is NULL when the error map is not requested (write_err false). [verified: formal UB only]
  • L05 pointer-overflow (null + offset) in raster/r.sun/main.c:1955 (calculate()): Same pattern as the main.c:1972 finding: horizonarray is NULL with a non-zero offset (call to lumcline2). [verified: formal UB only]
  • L06 pointer-overflow (null + offset) in raster/r.sun/main.c:1972 (calculate()): horizonarray + shadowoffset is evaluated while horizonarray is NULL (no horizon input) and shadowoffset is non-zero. [verified: formal UB only]
  • L07 nonnull-attribute in vector/v.extract/extract.c:35 (in_list()): bsearch(&cat, cats_array, ncats_array, ...) with NULL cats_array and zero count. [verified: formal UB only]
  • L08 nonnull-attribute in vector/v.extract/extract.c:285 (extract_line()): qsort(cats_array, ncats_array, ...) with cats_array NULL when no categories were collected. [verified: formal UB only]

Details

H01 alloc-dealloc-mismatch in include/grass/iostream/ami_sort.h:133
  • Sanitizer: ASan
  • Triggered by: raster/r.terraflow/testsuite/test_r_terraflow.py, hits: 18
  • Message: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x506000003e00
  • Allocated at: include/grass/iostream/ami_stream.h:429
  • Likely cause (hypothesis): name is returned by AMI_STREAM::name() allocated with new char[] (ami_stream.h:429) but freed with scalar delete name. Hint: delete[] name.
  • Repro (gunittest): cd raster/r.terraflow/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_terraflow.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.terraflow_testsuite_test_r_terraflow.py/ubsan.7851
  • Verification (verified: confirmed): Same family (name strings from AMI_STREAM::name()).
  • Minimal repro: verify/terraflow/

Trimmed stack:

include/grass/iostream/ami_sort.h:133 AMI_err AMI_sort<plateauType, ijCmpPlateauType>
raster/r.terraflow/sortutils.h:42 void sort<plateauType, ijCmpPlateauType>(AMI_STREAM<plateauT
raster/r.terraflow/plateau.cpp:335 detectPlateaus::removeDuplicates
raster/r.terraflow/plateau.cpp:434 findPlateaus
raster/r.terraflow/fill.cpp:195 computeFlowDirections
raster/r.terraflow/main.cpp:560 main
H02 alloc-dealloc-mismatch in include/grass/iostream/ami_stream.h:506
  • Sanitizer: ASan
  • Triggered by: raster/r.terraflow/testsuite/test_r_terraflow.py, hits: 4071
  • Message: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x7fb05057d800
  • Allocated at: include/grass/iostream/ami_stream.h:281
  • Likely cause (hypothesis): The stream buffer buf is allocated with new char[] (ami_stream.h lines 236/281) but released with scalar delete buf in the destructor. Hint: delete[] buf.
  • Repro (gunittest): cd raster/r.terraflow/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_terraflow.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.terraflow_testsuite_test_r_terraflow.py/ubsan.7851
  • Verification (verified: confirmed): One root-cause family: new char[] released with scalar delete. See the list of additional sites at the end.
  • Minimal repro: small r.terraflow run, scripts in verify/terraflow/

Trimmed stack:

include/grass/iostream/ami_stream.h:506 AMI_STREAM<float>::~AMI_STREAM
raster/r.terraflow/3scan.h:192 void scan3<float, float, detectEdgeNodata>(AMI_STREAM<float>
raster/r.terraflow/nodata.cpp:250 detectEdgeNodata::generateNodata
raster/r.terraflow/nodata.cpp:314 classifyNodata
raster/r.terraflow/fill.cpp:179 computeFlowDirections
raster/r.terraflow/main.cpp:560 main
H03 alloc-dealloc-mismatch in include/grass/iostream/ami_stream.h:506
  • Sanitizer: ASan
  • Triggered by: raster/r.terraflow/testsuite/test_r_terraflow.py, hits: 67
  • Message: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x7fb050728800
  • Allocated at: include/grass/iostream/ami_stream.h:236
  • Likely cause (hypothesis): The stream buffer buf is allocated with new char[] (ami_stream.h lines 236/281) but released with scalar delete buf in the destructor. Hint: delete[] buf.
  • Repro (gunittest): cd raster/r.terraflow/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_terraflow.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.terraflow_testsuite_test_r_terraflow.py/ubsan.7851
  • Verification (verified: confirmed): One root-cause family: new char[] released with scalar delete. See the list of additional sites at the end.
  • Minimal repro: small r.terraflow run, scripts in verify/terraflow/

Trimmed stack:

include/grass/iostream/ami_stream.h:506 AMI_STREAM<nodataType>::~AMI_STREAM
raster/r.terraflow/nodata.cpp:266 detectEdgeNodata::relabelNodata
raster/r.terraflow/nodata.cpp:331 classifyNodata
raster/r.terraflow/fill.cpp:179 computeFlowDirections
raster/r.terraflow/main.cpp:560 main
H04 alloc-dealloc-mismatch in include/grass/iostream/replacementHeap.h:154
  • Sanitizer: ASan
  • Triggered by: raster/r.terraflow/testsuite/test_r_terraflow.py, hits: 2
  • Message: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x506000003ce0
  • Allocated at: include/grass/iostream/ami_stream.h:429
  • Likely cause (hypothesis): Same as ami_sort.h:133: name from AMI_STREAM::name() (new char[]) freed with scalar delete. Hint: delete[] name.
  • Repro (gunittest): cd raster/r.terraflow/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_terraflow.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.terraflow_testsuite_test_r_terraflow.py/ubsan.7851
  • Verification (verified: confirmed): Only triggers with memory=1 on the full-resolution elevation raster.
  • Minimal repro: verify/terraflow/

Trimmed stack:

include/grass/iostream/replacementHeap.h:154 ReplacementHeap<sweepItemBaseType<int>, PrioCmpSweepItem>::R
include/grass/iostream/ami_sort_impl.h:313 AMI_STREAM<sweepItemBaseType<int>>* singleMerge<sweepItemBas
include/grass/iostream/ami_sort_impl.h:356 AMI_STREAM<sweepItemBaseType<int>>* multiMerge<sweepItemBase
include/grass/iostream/ami_sort.h:137 AMI_err AMI_sort<sweepItemBaseType<int>, PrioCmpSweepItem>
raster/r.terraflow/sortutils.h:42 void sort<sweepItemBaseType<int>, PrioCmpSweepItem>(AMI_STRE
raster/r.terraflow/flow.cpp:223 fillstr2sweepstr
H05 heap-buffer-overflow in raster/r.reclass/reclass.c:58
  • Sanitizer: ASan
  • Triggered by: lib/raster/tests/lib_raster_reclass_test.py, hits: 1
  • Message: AddressSanitizer: heap-buffer-overflow on address 0x50200000888c at pc 0x55cfd1d9359e bp 0x7fffc1002230 sp 0x7fffc1002228
  • Allocated at: lib/gis/alloc.c:121
  • Likely cause (hypothesis): In compose(), if no old category maps through the intermediate table, new->min = new->max = 0 is set but the second loop still indexes old->table[i - old->min] with i = 0; for old->min > 0 this reads before the table (ASan: 4 bytes before the region, allocated in get_reclass_table). Hint: skip the second loop when first is still set.
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 lib/raster/tests/lib_raster_reclass_test.py from the repo root (pytest environment below)
  • Raw log: raw-logs-pytest/lib_raster_tests_lib_raster_reclass_test.py/ubsan.27994
  • Verification (verified: confirmed): Reproduced with a minimal command; hypothesis confirmed exactly. Read only: the write goes to new->table[0] of a 1-element table (in bounds). The garbage value read can then be used for a second read of mid->table, so the stored category can be wrong. Trigger test is test_long_names. Without ASan it silently reads adjacent heap bytes and is usually right by luck.
  • Minimal repro: r.mapcalc expression="base = 1"; echo "1 = 5" | r.reclass input=base output=rc1 rules=-; echo "1 = 7" | r.reclass input=rc1 output=rc2 rules=- (script verify/reclass/repro.sh)

Trimmed stack:

raster/r.reclass/reclass.c:58 compose
raster/r.reclass/reclass.c:200 re_reclass
raster/r.reclass/reclass.c:224 reclass
raster/r.reclass/main.c:141 main
H06 INT_MIN negation (OOB read) in raster/r.stats/stats.c:137
  • Sanitizer: UBSan
  • Triggered by: found by verification only, hits: 0
  • Message: negation of -2147483648 cannot be represented in type 'int'
  • Likely cause (hypothesis): unclear
  • Verification (verified: confirmed, real memory bug (found while verifying M04/M05; not seen in the test runs)): if (hash < 0) hash = -hash; leaves INT_MIN unchanged, hash %= HASHSIZE is then negative (-1616), and hashtable[hash] is an out-of-bounds read; ASan reports SEGV at stats.c:142. Needs two maps whose combined hash wraps to INT_MIN (random data: about 1 in 2^32 per cell; crafted input hits it reliably).
  • Minimal repro: r.mapcalc a=1000000; r.mapcalc b=2058483648; r.stats -c a,b in a 1x1 region (verify/numeric/m04.sh)

Trimmed stack:

M01 signed-integer-overflow in lib/raster3d/long.c:27
  • Sanitizer: UBSan
  • Triggered by: temporal/t.rast3d.algebra/testsuite/test_raster3d_algebra.py, temporal/t.rast.to.rast3/testsuite/test_strds_to_rast3.py, temporal/t.rast3d.univar/testsuite/test_t_rast3d_univar.py, hits: 183 (28 test files)
  • Message: signed integer overflow: 72057594037927936 * 256 cannot be represented in type 'long'
  • Likely cause (hypothesis): d *= 256 runs once more than needed on the last byte iteration, overflowing long (256^8); the value is not used afterwards. Likely benign in practice. Hint: avoid the final multiply.
  • Repro (gunittest): cd temporal/t.rast3d.algebra/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_raster3d_algebra.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 general/g.findfile/tests/test_g_findfile.py from the repo root (pytest environment below); also seen in 8 other pytest file(s)
  • Raw log: raw-logs/temporal_t.rast3d.algebra_testsuite_test_raster3d_algebra.py/ubsan.3879
  • Verification (verified: benign): d is not read after the loop and the encoded bytes are right for positive values. Side observation (separate issue, decoder not checked): a negative long (-2) encodes as fe 00 00 ..., i.e. it is not sign-extended.
  • Minimal repro: verify/numeric/m02.c
  • Also seen in pytest run: raw-logs-pytest/general_g.findfile_tests_test_g_findfile.py/ubsan.25033

Trimmed stack:

lib/raster3d/long.c:27 Rast3d_long_encode
lib/raster3d/index.c:107 Rast3d_flush_index
lib/raster3d/close.c:82 close_cell_new
lib/raster3d/close.c:153 Rast3d_close
raster/r.mapcalc/map3.c:639 close_output_map
raster/r.mapcalc/evaluate.c:514 execute
M02 float-cast-overflow in raster/r.sim/simlib/hydro.c:112
  • Sanitizer: UBSan
  • Triggered by: raster/r.sim/r.sim.water/tests/r_sim_water_test.py, hits: 1
  • Message: -nan is outside the range of representable values of type 'int'
  • Likely cause (hypothesis): gen = rwalk * si / sisum is NaN (likely sisum == 0), then (int)gen casts NaN to int. Hint: guard against a zero/NaN sisum.
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 raster/r.sim/r.sim.water/tests/r_sim_water_test.py from the repo root (pytest environment below)
  • Raw log: raw-logs-pytest/raster_r.sim_r.sim.water_tests_r_sim_water_test.py/ubsan.28296
  • Verification (verified: benign on x86-64): Trigger is rain_value=0: sisum is 0, gen is NaN, (int)NaN is INT_MIN on x86-64, the walker loop runs zero times and depth is all 0 (the expected result). Relies on x86 behaviour.
  • Minimal repro: verify/numeric/m03.sh

Trimmed stack:

raster/r.sim/simlib/hydro.c:112 main_loop
raster/r.sim/r.sim.water/main.c:572 main
M03 signed-integer-overflow in raster/r.stats/stats.c:134
  • Sanitizer: UBSan
  • Triggered by: raster/r.kappa/testsuite/test_r_kappa.py, raster/r.coin/tests/test_coin.py, hits: 3 (2 test files)
  • Message: signed integer overflow: -2147483647 * 89 cannot be represented in type 'int'
  • Likely cause (hypothesis): The hash hash * HASHMOD + value intentionally wraps around for large category values; overflow of signed int is UB, though the result is only used as a bucket index. Hint: use unsigned arithmetic.
  • Repro (gunittest): cd raster/r.kappa/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_kappa.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 raster/r.coin/tests/test_coin.py from the repo root (pytest environment below)
  • Raw log: raw-logs/raster_r.kappa_testsuite_test_r_kappa.py/ubsan.29938
  • Verification (verified: hash overflow benign; see H06): The hash only needs to be deterministic; the tree comparison uses values[]. The INT_MIN case is a real bug, listed as H06.
  • Minimal repro: verify/numeric/m04.sh
  • Also seen in pytest run: raw-logs-pytest/raster_r.coin_tests_test_coin.py/ubsan.18019

Trimmed stack:

raster/r.stats/stats.c:134 update_cell_stats
raster/r.stats/cell_stats.c:63 cell_stats
raster/r.stats/main.c:417 main
M04 signed-integer-overflow in raster/r.stats/stats.c:134
  • Sanitizer: UBSan
  • Triggered by: raster/r.kappa/testsuite/test_r_kappa.py, raster/r.coin/tests/test_coin.py, hits: 3 (2 test files)
  • Message: signed integer overflow: -2147483559 + -2147483647 cannot be represented in type 'int'
  • Likely cause (hypothesis): The hash hash * HASHMOD + value intentionally wraps around for large category values; overflow of signed int is UB, though the result is only used as a bucket index. Hint: use unsigned arithmetic.
  • Repro (gunittest): cd raster/r.kappa/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_kappa.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 raster/r.coin/tests/test_coin.py from the repo root (pytest environment below)
  • Raw log: raw-logs/raster_r.kappa_testsuite_test_r_kappa.py/ubsan.29938
  • Verification (verified: hash overflow benign; see H06): The hash only needs to be deterministic; the tree comparison uses values[]. The INT_MIN case is a real bug, listed as H06.
  • Minimal repro: verify/numeric/m04.sh
  • Also seen in pytest run: raw-logs-pytest/raster_r.coin_tests_test_coin.py/ubsan.18019

Trimmed stack:

raster/r.stats/stats.c:134 update_cell_stats
raster/r.stats/cell_stats.c:63 cell_stats
raster/r.stats/main.c:417 main
M05 signed-integer-overflow in raster/r.texture/h_measure.c:373
  • Sanitizer: UBSan
  • Triggered by: raster/r.texture/testsuite/test_rtexture_parallel.py, raster/r.texture/testsuite/test_rtexture.py, hits: 2 (2 test files)
  • Message: signed integer overflow: 47961 * 47961 cannot be represented in type 'int'
  • Likely cause (hypothesis): tmp * tmp is computed in int where tmp is the squared tone range; for wide ranges (47961^2) it overflows before the float division. Hint: use double/float for tmp.
  • Repro (gunittest): cd raster/r.texture/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_rtexture_parallel.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.texture_testsuite_test_rtexture_parallel.py/ubsan.9006
  • Verification (verified: output changes slightly): With a double divisor (patched copy in verify/numeric/rt/) 1401 of 331906 cells differ (0.42%) on lsat7_2002_80, size=3, method=dv; max |diff| 5.5e-5. Only windows with tone range >= 216 are affected.
  • Minimal repro: verify/numeric/m06.sh, m06b.sh

Trimmed stack:

raster/r.texture/h_measure.c:373 f10_dvar
raster/r.texture/execute.c:132 execute_texture
raster/r.texture/main.c:318 main
M06 signed-integer-overflow in raster/r.univar/r.univar_main.c:516
  • Sanitizer: UBSan
  • Triggered by: general/g.rename/testsuite/test_overwrite.py, hits: 3
  • Message: signed integer overflow: 50000 * 50000 cannot be represented in type 'int'
  • Likely cause (hypothesis): val * val is computed in int before being added to the floating-point sumsq; values above ~46340 overflow. Cast to double before multiplying.
  • Repro (gunittest): cd general/g.rename/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_overwrite.py (gunittest environment below)
  • Raw log: raw-logs/general_g.rename_testsuite_test_overwrite.py/ubsan.3942
  • Verification (verified: WRONG OUTPUT): For CELL maps any |value| >= 46341 wraps, so sumsq, variance, stddev and coeff_var are wrong (mean, sum, min, max are fine). Example: 100 cells, five rows of 10 cells = 50000, rest 3: r.univar -g gives variance=0 on the CELL map vs 624925002.25 on its double copy. r3.univar is not affected.
  • Minimal repro: verify/numeric/m07.sh

Trimmed stack:

raster/r.univar/r.univar_main.c:516 process_raster.omp_outlined_debug__
raster/r.univar/r.univar_main.c:399 process_raster.omp_outlined
raster/r.univar/r.univar_main.c:399 process_raster
raster/r.univar/r.univar_main.c:321 main
L01 array-bounds in lib/gis/aprintf.c:198
  • Sanitizer: UBSan
  • Triggered by: vector/v.info/testsuite/test_vinfo.py, hits: 1
  • Message: index -1 out of bounds for type 'char[23]'
  • Likely cause (hypothesis): char *c = CONVS - 1; forms a pointer one before the start of the string literal, then pre-increments it; the sanitizer flags the index -1. Probably benign on common platforms. Hint: restructure the loop to avoid the out-of-range pointer.
  • Repro (gunittest): cd vector/v.info/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_vinfo.py (gunittest environment below)
  • Raw log: raw-logs/vector_v.info_testsuite_test_vinfo.py/ubsan.19755
  • Verification (verified: formal UB only): CONVS - 1 is out-of-bounds pointer arithmetic, but it is pre-incremented before the first read, so no memory outside the string is accessed. Severity lowered from MEDIUM to LOW.
  • Minimal repro: v.info on any vector (see verify/terraflow/v.out)

Trimmed stack:

lib/gis/aprintf.c:198 oaprintf
lib/gis/aprintf.c:427 G_vsaprintf
lib/gis/aprintf.c:519 G_saprintf
vector/v.info/print.c:626 print_info
vector/v.info/main.c:97 main
L02 nonnull-attribute in lib/gis/ls.c:116
  • Sanitizer: UBSan
  • Triggered by: temporal/t.rast.to.rast3/testsuite/test_strds_to_rast3.py, scripts/db.in.ogr/testsuite/test_db_in_ogr.py, vector/v.out.ogr/testsuite/test_v_out_ogr.py, hits: 115 (29 test files)
  • Message: null pointer passed as argument 1, which is declared to never be null
  • Likely cause (hypothesis): qsort(dir_listing, n, ...) is called with dir_listing == NULL when the directory has no (visible) entries (n = 0). Passing NULL to qsort is UB even with a zero count. Hint: skip qsort when n == 0.
  • Repro (gunittest): cd temporal/t.rast.to.rast3/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_strds_to_rast3.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 python/grass/tools/tests/grass_tools_session_tools_pack_test.py from the repo root (pytest environment below); also seen in 4 other pytest file(s)
  • Raw log: raw-logs/temporal_t.rast.to.rast3_testsuite_test_strds_to_rast3.py/ubsan.6308
  • Verification (verified: formal UB only): G_ls2 on an existing empty directory calls qsort(NULL, 0). All callers checked tolerate the NULL return.
  • Minimal repro: g.remove -f type=vector pattern='*' in a mapset with an empty vector/ directory (verify/nullptr/l01.sh)
  • Also seen in pytest run: raw-logs-pytest/python_grass_tools_tests_grass_tools_session_tools_pack_test.py/ubsan.11965

Trimmed stack:

lib/gis/ls.c:116 G_ls2
general/g.remove/main.c:239 main
L03 nonnull-attribute in lib/gis/plot.c:455
  • Sanitizer: UBSan
  • Triggered by: scripts/v.rast.stats/testsuite/test_v_rast_stats.py, hits: 1
  • Message: null pointer passed as argument 1, which is declared to never be null
  • Likely cause (hypothesis): qsort(st->P, st->np, ...) with a NULL st->P when the polygon has no edge points (np = 0). Hint: skip qsort when np == 0.
  • Repro (gunittest): cd scripts/v.rast.stats/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_v_rast_stats.py (gunittest environment below)
  • Raw log: raw-logs/scripts_v.rast.stats_testsuite_test_v_rast_stats.py/ubsan.25680
  • Verification (verified: formal UB only): An area that crosses no pixel-row centre gives np == 0, so st->P was never allocated. The odd-point check does not matter.
  • Minimal repro: 2x1 m polygon at 10 m resolution then v.to.rast (verify/nullptr/l02.sh)

Trimmed stack:

lib/gis/plot.c:455 G_plot_polygon
vector/v.to.rast/do_areas.c:81 do_areas
vector/v.to.rast/vect2rast.c:189 vect_to_rast
vector/v.to.rast/main.c:162 main
L04 pointer-overflow (null + offset) in raster/r.fill.stats/main.c:595
  • Sanitizer: UBSan
  • Triggered by: raster/r.fill.stats/testsuite/test_r_fill_stats.py, hits: 4
  • Message: applying non-zero offset 4 to null pointer
  • Likely cause (hypothesis): err_output++ advances a pointer that is NULL when the error map is not requested (write_err false). Advance only when write_err is set.
  • Repro (gunittest): cd raster/r.fill.stats/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_r_fill_stats.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.fill.stats_testsuite_test_r_fill_stats.py/ubsan.26050
  • Verification (verified: formal UB only): Without error=, err_output++ runs on NULL; every dereference is under write_err.
  • Minimal repro: r.fill.stats input=inp output=out1 (verify/nullptr/l03.sh)
  • Possibly related: #4472 (GCC nonnull in r.fill.stats; not the same line)

Trimmed stack:

raster/r.fill.stats/main.c:595 interpolate_row
raster/r.fill.stats/main.c:1266 main
L05 pointer-overflow (null + offset) in raster/r.sun/main.c:1955
  • Sanitizer: UBSan
  • Triggered by: raster/r.sun/testsuite/test_rsun.py, hits: 5
  • Message: applying non-zero offset 501 to null pointer
  • Likely cause (hypothesis): Same pattern as the main.c:1972 finding: horizonarray is NULL with a non-zero offset (call to lumcline2). Hint: same as above.
  • Repro (gunittest): cd raster/r.sun/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_rsun.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.sun_testsuite_test_rsun.py/ubsan.7394
  • Verification (verified: formal UB only): Pointer is only formed, read only under useHorizonData() at rsunlib.c:342.
  • Minimal repro: r.sun ... incidout=inc nprocs=2 without horizon input (verify/nullptr/l04d.sh)

Trimmed stack:

raster/r.sun/main.c:1955 calculate.omp_outlined_debug__
raster/r.sun/main.c:1801 calculate.omp_outlined
raster/r.sun/main.c:1801 calculate
raster/r.sun/main.c:819 main
L06 pointer-overflow (null + offset) in raster/r.sun/main.c:1972
  • Sanitizer: UBSan
  • Triggered by: raster/r.sun/testsuite/test_rsun.py, hits: 8
  • Message: applying non-zero offset 301 to null pointer
  • Likely cause (hypothesis): horizonarray + shadowoffset is evaluated while horizonarray is NULL (no horizon input) and shadowoffset is non-zero. Hint: only form the pointer when horizon data is used.
  • Repro (gunittest): cd raster/r.sun/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_rsun.py (gunittest environment below)
  • Raw log: raw-logs/raster_r.sun_testsuite_test_rsun.py/ubsan.6950
  • Verification (verified: formal UB only): Same as :1955.
  • Minimal repro: r.sun ... beam_rad=br nprocs=2 without horizon input (verify/nullptr/l04d.sh)

Trimmed stack:

raster/r.sun/main.c:1972 calculate.omp_outlined_debug__
raster/r.sun/main.c:1801 calculate.omp_outlined
raster/r.sun/main.c:1801 calculate
raster/r.sun/main.c:819 main
L07 nonnull-attribute in vector/v.extract/extract.c:35
  • Sanitizer: UBSan
  • Triggered by: scripts/v.dissolve/testsuite/test_v_dissolve.py, scripts/v.dissolve/tests/v_dissolve_layers_test.py, scripts/v.dissolve/tests/v_dissolve_test.py, hits: 19 (5 test files)
  • Message: null pointer passed as argument 2, which is declared to never be null
  • Likely cause (hypothesis): bsearch(&cat, cats_array, ncats_array, ...) with NULL cats_array and zero count. Hint: return 0 early when ncats_array == 0.
  • Repro (gunittest): cd scripts/v.dissolve/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_v_dissolve.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 scripts/v.dissolve/tests/v_dissolve_layers_test.py from the repo root (pytest environment below); also seen in 3 other pytest file(s)
  • Raw log: raw-logs/scripts_v.dissolve_testsuite_test_v_dissolve.py/ubsan.23538
  • Verification (verified: formal UB only): Category array is NULL when the category list is empty; called with nmemb == 0.
  • Minimal repro: v.extract input=soils_wake output=e1 where="1=0" (verify/nullptr/l06.sh)
  • Also seen in pytest run: raw-logs-pytest/scripts_v.dissolve_tests_v_dissolve_layers_test.py/ubsan.1292

Trimmed stack:

vector/v.extract/extract.c:35 in_list
vector/v.extract/extract.c:349 extract_line
vector/v.extract/main.c:422 main
L08 nonnull-attribute in vector/v.extract/extract.c:285
  • Sanitizer: UBSan
  • Triggered by: scripts/v.dissolve/testsuite/test_v_dissolve.py, scripts/v.dissolve/tests/v_dissolve_layers_test.py, scripts/v.dissolve/tests/v_dissolve_test.py, hits: 19 (5 test files)
  • Message: null pointer passed as argument 1, which is declared to never be null
  • Likely cause (hypothesis): qsort(cats_array, ncats_array, ...) with cats_array NULL when no categories were collected. Hint: skip when ncats_array == 0.
  • Repro (gunittest): cd scripts/v.dissolve/testsuite && grass --tmp-mapset <nc_spm_08_grass7> --exec python test_v_dissolve.py (gunittest environment below)
  • Repro (pytest): v/bin/python -m pytest -p no:cacheprovider --timeout=600 scripts/v.dissolve/tests/v_dissolve_layers_test.py from the repo root (pytest environment below); also seen in 3 other pytest file(s)
  • Raw log: raw-logs/scripts_v.dissolve_testsuite_test_v_dissolve.py/ubsan.23538
  • Verification (verified: formal UB only): Same as :35.
  • Minimal repro: v.extract input=soils_wake output=e4 type=area -r (verify/nullptr/l06.sh)
  • Also seen in pytest run: raw-logs-pytest/scripts_v.dissolve_tests_v_dissolve_layers_test.py/ubsan.1292

Trimmed stack:

vector/v.extract/extract.c:285 extract_line
vector/v.extract/main.c:422 main

Additional new[]/delete mismatches (found by reading, not exercised)

Same root family as H01-H04 (new char[] released with scalar delete), found by reading include/grass/iostream/*.h and raster/r.terraflow/. No delete on strdup/malloc memory was found. Several sites are dead debug code (if (0)).

  • ami_sort.h:107 (inside SORT_DEBUG, dead)
  • replacementHeap.h:127 (operator<<, debug printing)
  • replacementHeap.h:202 (RHEAP_DEBUG, dead)
  • replacementHeapBlock.h:129 (operator<<) and :204 (RBHEAP_DEBUG, dead)
  • embuffer.h:494, 516, 769, 862 (delete name[i];, SAVE_MEMORY is defined, not exercised by r.terraflow)
  • embuffer.h:555 (delete fooname;, only under EMBUF_CHECK_NAME, commented out)

Related, not sanitizer findings (leaks, detection disabled): name strings never freed at ami_sort.h:122, r.terraflow/grass2str.h:48,164,247,321,411 and nodata.cpp:320.

Filtered out

  • Third-party only (no GRASS frame in first 5): 75
  • Test-only helper code: 0
  • Duplicate hits of listed findings: 4506
  • Leak reports: 0 (leak detection disabled)
  • Sanitizer/LD_PRELOAD artifacts: 2 aborted test files (lib/imagery/testsuite/test_imagery_sigfile.py, test_imagery_sigsetfile.py): the tests call ctypes libc.free() on memory from GRASS code that ASan allocated, giving glibc munmap_chunk(): invalid pointer; this is an artifact of the preload setup and the remaining tests in those files did not run.

Not covered

  • pytest tests that failed or were skipped for environment reasons (see Summary) only ran partially; tests marked slow were not deselected. pygrass/tests are ignored by the repo pytest config.
  • Dataset is the basic nc_spm_08_grass7 sample, not the full one; several tests failed for missing maps (e.g. zipcodes, streets, landuse, schools) and are weaker coverage. 46 files exited 1 (some are intentional failures from gunittest sample code, some need missing data or the GUI/wx, PDAL/LAS or network access).
  • Not built: GUI (wxPython), PDAL/LAS modules (v.in.pdal, v.in.lidar, r.in.pdal, ...), OpenGL, X11 drivers, docs.
  • Disabled checks: leak detection (detect_leaks=0), -fno-sanitize=function. No TSan/MSan.
  • Python-side code was run under the preloaded ASan runtime but was not itself instrumented.
  • ASan reports were written to ubsan.<pid> files rather than asan.<pid> (the combined runtime honours one log path); the parser reads both.

Reproduce this run

pytest (after the build below; the repo's pyproject.toml needs pytest >= 8.2, so use a venv):

python -m venv --system-site-packages v && v/bin/pip install 'pytest>=8.2' pytest-timeout
export PATH=$PWD/build/output/bin:$PATH GRASS_CONFIG_DIR=$PWD/gconf LOGDIR=$PWD/ptlogs/<test-id>
mkdir -p $GRASS_CONFIG_DIR $LOGDIR
export ASAN_OPTIONS=detect_leaks=0:halt_on_error=0:detect_stack_use_after_return=1:log_path=$LOGDIR/asan
export UBSAN_OPTIONS=print_stacktrace=1:log_path=$LOGDIR/ubsan
export LD_PRELOAD=$(clang -print-file-name=libclang_rt.asan-x86_64.so)
export PYTHONPATH="$(grass --config python_path):$PYTHONPATH" LD_LIBRARY_PATH="$(grass --config path)/lib:$LD_LIBRARY_PATH"
cd grass   # repo root
v/bin/python -m pytest -p no:cacheprovider --timeout=600 <path/to/*_test.py>

gunittest:

# Build (throwaway dir, nothing installed)
apt install clang libclang-rt-18-dev libomp-18-dev libgdal-dev libproj-dev libgeos-dev libfftw3-dev liblapacke-dev libopenblas-dev libnetcdf-dev libpq-dev libzstd-dev libtiff-dev libcairo2-dev libfreetype-dev gettext flex bison ninja-build
export ASAN_OPTIONS=detect_leaks=0 CC=clang CXX=clang++
export CFLAGS="-g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined -fsanitize-recover=address,undefined -fno-sanitize=function" CXXFLAGS="$CFLAGS"
export LDFLAGS="-fsanitize=address,undefined -shared-libasan -Wl,-rpath,$(dirname $(clang -print-file-name=libclang_rt.asan-x86_64.so))"
cmake -S grass -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DWITH_PDAL=OFF -DWITH_GUI=OFF -DWITH_X11=OFF -DWITH_OPENGL=OFF -DWITH_DOCS=OFF
ninja -C build

# Run one test file (repeat for each; 4 in parallel)
export PATH=$PWD/build/output/bin:$PATH GRASS_CONFIG_DIR=$PWD/gconf LOGDIR=$PWD/logs/<test-id>
mkdir -p $GRASS_CONFIG_DIR $LOGDIR
export ASAN_OPTIONS=detect_leaks=0:halt_on_error=0:detect_stack_use_after_return=1:log_path=$LOGDIR/asan
export UBSAN_OPTIONS=print_stacktrace=1:log_path=$LOGDIR/ubsan
export LD_PRELOAD=$(clang -print-file-name=libclang_rt.asan-x86_64.so)
cd <dir>/testsuite   # copy of the repo testsuite dir
timeout 600 grass --tmp-mapset ~/grassdata/nc_spm_08_grass7 --exec python test_xxx.py

Build invocation changes needed (no source changes): clang needs -shared-libasan at link time so the LD_PRELOADed runtime is not a second ASan copy, plus an rpath to the clang runtime directory for build-time helper tools; libomp-18-dev for OpenMP; GUI/PDAL disabled.

Dominant language
C
Stars
1.2k
Forks
447
Avg merge
1d 19h
Merged PRs (30d)
62

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from OSGeo/grass

All issues in OSGeo/grass

Similar issues

More C issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.