Skip to content

ENH: Add regression tests for the bug fixes of the past two days - #123

Open
hjmjohnson wants to merge 42 commits into
masterfrom
stack/test-regression-coverage
Open

hjmjohnson wants to merge 42 commits into
masterfrom
stack/test-regression-coverage

Conversation

@hjmjohnson

Copy link
Copy Markdown
Member

Re-submission of #80, reverted from master on 2026-09-24. Content is
unchanged from the original.

Position 1 of 11 in the deep stack. Base: master.

This one carries its prerequisites. It depends on changes that are
still open as separate pull requests, and GitHub can only express that
by including them, so the diff shows 42 commits rather than just its
own. Once the independent pull requests in #84 have merged, this can be
rebased onto master and the diff will shrink to its own commits. Ask
and that rebase will be done.

Stack order

# branch base
1 stack/test-regression-coverage <- this PR master
2 stack/pr-fix-alloc-null-checks stack/test-regression-coverage
3 stack/fix-axml-skip-depth stack/pr-fix-alloc-null-checks
4 stack/pr-fix-analyzer-leaks stack/fix-axml-skip-depth
5 stack/pr-fix-sign-conversion stack/pr-fix-analyzer-leaks
6 stack/fix-fslio-64bit-arithmetic stack/pr-fix-sign-conversion
7 stack/fix-cifti-null-stream stack/fix-fslio-64bit-arithmetic
8 stack/pr-fix-calloc-transposed-args stack/fix-cifti-null-stream
9 stack/pr-fix-shorten-64-to-32 stack/pr-fix-calloc-transposed-args
10 stack/fix-image-read-complex-check stack/pr-fix-shorten-64-to-32
11 stack/pr-fix-xml-read-errors stack/fix-image-read-complex-check

The order is the order these changes sat on master before the revert, so
it builds and tests at every step.

Commits introduced by this PR
  • BUG: Check the header converted to nifti_image before reading its datatype
  • ENH: Cover the NIFTI-2 dim[0] range check
  • ENH: Cover the swap width of a byte-swapped nifti_1_header
  • ENH: Cover the swap width of the tool's own header copies
  • ENH: Cover the voxel-count and volume-size overflow guards
  • ENH: Cover the voxel-count guards in the NIFTI-1 library
  • ENH: Cover the repeated-attribute path in the ASCII header reader
  • ENH: Cover the loc_strnlen bound on an unterminated extension
  • ENH: Cover the ambiguous-filename path in nifti_findhdrname
  • ENH: Reach nifti_tool's header-modification failure paths
  • ENH: Self-test the NIfTI content comparison helper

Ordering for all the re-submitted work is tracked in #84.

gdevenyi and others added 30 commits September 24, 2026 07:12
The test hard-codes the path to the downstream example project:

    cmake ... ../../nifti_clib/real_easy/minimal_example_of_downstream_usage

That only resolves when the build directory happens to be a sibling of a
source tree named exactly "nifti_clib".  It fails for an in-tree build,
for a build directory named anything else, and on CI, where the checkout
lives at <workspace>/nifti_clib and the path resolves to
<workspace>/nifti_clib/nifti_clib/real_easy/...

CMake now passes CMAKE_SOURCE_DIR to the script, the way the other test
scripts in that directory already receive their arguments.

(cherry picked from commit 6458714)
cmake-multi-platform.yml ran ctest without --output-on-failure, so a red
build reported which test failed and nothing about why.

(cherry picked from commit 919dcea)
CMakeLists.txt sets policy CMP0169 unconditionally, to keep using the
deprecated FetchContent_Populate.  CMP0169 was introduced in CMake 3.30,
and cmake_policy(SET) on an unknown policy is a hard error, so any CMake
older than that fails to configure at all:

    CMake Error at CMakeLists.txt:175 (cmake_policy):
      Policy "CMP0169" is not known to this version of CMake.

That includes the CMake 3.28 shipped by Ubuntu 24.04, which is what the
CI runners and the project's own Dockerfile use, and it sits well above
the cmake_minimum_required(VERSION 3.10.2) the project advertises.

Wrapping it in if(POLICY CMP0169) is the standard idiom.  Verified
configuring with both CMake 3.28.3 and 4.4.2.

(cherry picked from commit 3aefe1b)
nifti_c22_copy_image has been failing on this machine since before any of
this work started.  It converts an image i16 -> i64 -> i16 and asserts
the result matches the original, using

    cmp out.c22.0.i16.nii.gz out.c22.2.0.i16.nii.gz

That compares gzip output, which is not reproducible across zlib
implementations.  The system zlib here is zlib-ng 1.3.1, which Arch,
CachyOS and a growing number of distributions ship in place of stock
zlib; it encodes the same input differently.  Both files come out at
exactly 642454 bytes and differ from byte 321594 on.

The conversion itself is fine.  Decompressed, the two files are
byte-identical at 1114768 bytes each, so the round-trip through int64 and
back preserves the data exactly, which is what the test set out to check.
Confirmed the other way too: in an Ubuntu 24.04 container with stock
zlib 1.3, the test passes unmodified.

The test now decompresses before comparing, via a small nii_cmp helper
that falls back to plain cmp for uncompressed files.  With this the suite
is 92 of 92 on both zlib-ng and stock zlib; previously it was 91 of 92
on any zlib-ng system.

(cherry picked from commit 7d92f16)
cmake-multi-platform.yml is the workflow that runs on every pull
request, and it passes no options at all.  With the defaults that means
cifti/ and fsliolib/ are never compiled, and neither are the blocks
behind FSLSTYLE, PIGZ and REJECT_COMPLEX.

  USE_CIFTI_CODE          OFF
  USE_FSL_CODE            OFF
  FSLSTYLE_NAME_CONFLICTS OFF   -> -DFSLSTYLE
  FSLSTYLE_PIGZ_SUPPORT   OFF   -> -DPIGZ
  FSLSTYLE_REJECT_COMPLEX OFF   -> -DREJECT_COMPLEX

So a PR that changes any of those files collects four green checks that
never built it.  Several open PRs are in exactly that position.

The matrix gains an 'options' axis: 'default' is what a consumer gets
with no arguments, 'all' turns the optional libraries and the FSL parity
defines on.  Both are kept, because the default build is what ships and
a change can break it while the fuller one still compiles.  Four jobs
become eight, each about half a minute.

expat is installed for the Linux 'all' jobs; cifti needs it and the
default jobs do not.

Verified on master before writing this, with the test suite:

  default                          build OK, tests pass
  +cifti +fsl                      build OK, tests pass
  +cifti +fsl +FSLSTYLE            build OK, tests pass, doPigz/doPigz2
                                   present in nifti2_io.c.o

COMPILE_NIFTIUNUSED_CODE is deliberately left out.  It guards code the
project itself labels unused, so compiling it in CI would commit to
keeping it working; whether that code should exist at all is a separate
question.

(cherry picked from commit b6531f0)
The .clang-tidy file has been in the tree since 2025 but nothing has ever
run it, and it shows: two of its entries name clang-analyzer checks that
upstream renamed some releases ago, so --verify-config warned about them.
Run over the whole tree it produced 1697 findings, of which the great
majority came from three style checks.  At that ratio nobody reads the
output.

Retuned for what this project actually is: C11, with a shipped ABI.
Measured with clang-tidy 22.1.8, USE_FSL_CODE=ON and USE_CIFTI_CODE=ON,
it now gives 331 findings across 15 checks, every one a defect class:

  170  bugprone-macro-parentheses
   44  bugprone-unchecked-string-to-number-conversion
   25  bugprone-multi-level-implicit-pointer-conversion
   19  readability-use-concise-preprocessor-directives
   16  bugprone-switch-missing-default-case
   13  readability-redundant-casting
    5  bugprone-suspicious-string-compare
    4  bugprone-suspicious-realloc-usage
    3  readability-redundant-control-flow
    2  readability-suspicious-call-argument
    2  bugprone-misplaced-widening-cast
    1  each: readability-implicit-bool-conversion,
         readability-function-size, misc-redundant-expression,
         bugprone-float-loop-counter

bugprone-macro-parentheses is re-enabled.  It had been switched off, but
it is the check that catches the real precedence bugs in the NT_DT_*,
QSTR and NT_FILL macro families.

misc-use-internal-linkage is switched off permanently, with the reason
recorded in the file.  It flags 29 functions, 13 of which are symbols
currently exported by libniftiio, libfslio and libcifti.  Applying its
fix-it would delete them from the shared libraries.  That it looks like a
tidy-up is exactly what makes it dangerous, so the file says so next to
the check rather than leaving the next person to rediscover it.

Every disabled check carries its finding count and the reason, so the
list can be re-argued from evidence instead of taste.

(cherry picked from commit 159d11d)
Add cmake/nifti_warnings.cmake with the warning flags the tree is
already at zero under, so that a warning means a new defect rather
than more noise.

Fourteen flags are enabled: thirteen common to GCC and Clang, plus
-Wcomma on Clang. Verified with NIFTI_WARNINGS_AS_ERRORS=ON in both
the default and the USE_CIFTI_CODE/USE_FSL_CODE/FSLSTYLE
configurations: zero compiler warnings.

The flags that are wanted but not yet earned are recorded in a
FUTURE SET comment block with their measured hit counts and the
census command that produces them. They are promoted one at a time,
each only after the change that fixes its warnings has landed, so CI
stays green at every step.

The GCC-only and MSVC branches are left empty: the GCC block has
never been enabled anywhere so its counts are unknown, and no
workflow builds on Windows at all.

Co-Authored-By: Gabriel A. Devenyi <3001850+gdevenyi@users.noreply.github.com>
(cherry picked from commit 596441b)
Run the Build and Test workflow on this repository's branch. Its
trigger named "main" while the branch is "master", so it had never
executed on master and did not execute on pull requests; valgrind,
AddressSanitizer, UndefinedBehaviorSanitizer, scan-build, gcov
coverage, and the only shared-library configuration were all
unexercised. Also move its macOS entry off the retired macos-11 image.

Restructure the per-PR matrix around the axes that change what is
compiled:

  shared     BUILD_SHARED_LIBS gates TEST_INSTALL, so the ON legs are
             the first to run install_linking and cover the install
             and export path.
  fslstyle   -DFSLSTYLE is not additive.  It rewrites behaviour in
             niftilib and nifti2 through global -DFSLSTYLE, -DPIGZ and
             -DREJECT_COMPLEX, and one site has an #else that changes
             the pixdim[0] value written to disk.  Neither value
             subsumes the other.
  build_type One static Debug leg, which is what makes assert() live.

Drop both macos gcc legs: /usr/bin/gcc there is a clang shim reporting
AppleClang, so they duplicated the macos clang legs exactly. The
optional libraries are built everywhere rather than forming an axis,
since they are additive.

Add three jobs. The minimal configuration builds znzlib and niftilib
alone, which is what a downstream project vendoring the core reader
selects and which nothing else configures. The oldest supported CMake
job guards cmake_minimum_required, invisible to runners that all carry
a recent CMake. The exported symbol baseline diffs the dynamic symbol
set against a committed file, so a change to the ABI has to be updated
in the same commit rather than landing unnoticed.

Verified on Ubuntu 24.04 with GCC 13.3.0, the compiler the Linux
runners use:

  shared, Release, FSLSTYLE=OFF   345/345, install_linking passed
  static, Debug,   FSLSTYLE=ON    344/344, no assertion aborts
  minimal                          configures and builds, no tests
  CMake 3.28.3                     344/344

The baseline holds 448 symbols across six libraries, which is the
figure the linkage changes have been asserting without a way to check
it.

The workflow steps were exercised by running them directly in the
runner container image rather than through act, whose image omits
cmake.

(cherry picked from commit 77b7418)
The dashboard script restates the CTestConfig.cmake settings because
CTest's delayed initialization does not pick them up, and the restated
copy had drifted from the original.

Submit over https rather than http. my.cdash.org serves plain http
without redirecting, so submissions were going unencrypted.

Use the 00:00:00 EST nightly start time that CTestConfig.cmake
declares, rather than 01:00:00 UTC. The two differ by four hours, so
nightly builds were filed under the wrong day on the dashboard.

(cherry picked from commit 063e838)
The 2025 conversion to GitHub Actions replaced .travis.yml with two
workflows but left the dashboard script untouched, so it still
required a Travis environment. The workflow that drives it named the
wrong branch and never ran, which is why nothing surfaced the
mismatch.

Rename travis_dashboard.cmake to github_dashboard.cmake and take its
inputs from the runner that actually exists. CTEST_SITE now comes from
RUNNER_OS rather than the required TRAVIS_APP_HOST, whose absence
aborted the script before it configured anything, and the build name
carries RUNNER_OS in place of TRAVIS_OS_NAME.

Compare the branch name against its value rather than against the
literal string "ENV{BUILD_SOURCEBRANCHNAME}", so the Continuous and
Nightly models can be selected at all, and give the workflow a branch
name on push events as well as on pull requests.

Apply the same corrections to local_dashboard.cmake.

(cherry picked from commit f578e03)
FslGetHdrImgNames and FslSetIntensityScaling are defined here but
declared nowhere, while FslGetIntensityScaling and FslInit are already
published.  axml_recur_find_xml sits beside axml_recur in afni_xml.h
the same way.

The upstream fslio was deleted in 2015 in favour of a C++ replacement,
so this copy is the surviving one and its header is ours to correct.

Additive; no symbol changes.

(cherry picked from commit 4673c40)
Nine functions in fslio.c have external linkage and no declaration in
any header.  Nothing in the tree calls them across a translation unit,
AFNI's vendored copy never calls them, and no public source outside a
vendored copy of this file references them, so a caller would have had
to declare them itself.

Collect their prototypes in one block so the boundary between internal
and published is visible in one place.

FslSetVoxUnits and FslGetVoxUnits have no caller at all and are left
under #if 0 rather than deleted; FslSetTimeUnits and FslGetTimeUnits
are published, so the asymmetry is worth keeping visible.

Removes nine symbols from libfslio, which the baseline records.

(cherry picked from commit 8cdcf1a)
nifti1_io.c and nifti2_io.c each define nifti_fileexists with external
linkage, so libniftiio and libnifti2 export the same name and ELF link
order decides which one a caller linking both resolves to.

Declaring it locally silences the warning without choosing between
publishing it and making it static; that choice needs the duplication
settled first, and it covers a hundred names, not this one.

(cherry picked from commit 54e74e1)
Keeps FslGetAuxFile and FslSetAuxFile deriving the same length so the
pair cannot drift if the field width changes.  sizeof is 24, so the
copy still writes at most 23 bytes.

(cherry picked from commit 0c0e66c)
strncpy writes no terminator when the source fills the destination, so
the copy is safe only because of the line that follows it, which is
what -Wstringop-truncation reports.

Copying at most sizeof(dest) - 1 makes the call safe on its own and
takes the size from the buffer rather than a literal repeated twice.

(cherry picked from commit 5c1665c)
act_add_exts() in both nifti_tool and nifti1_tool reads the extension
data from a file into edata and, if nifti_add_extension() then fails,
returns without freeing it.  The success path a few lines below frees
it already.

Found by clang's static analyzer, on a path the test suite does not
reach.

(cherry picked from commit ab2d71c)
…ibute

nifti_image_from_ascii() walks the attributes of an ASCII header and
assigns the two filename fields with

    nim->fname = nifti_strdup(rhs) ;
    nim->iname = nifti_strdup(rhs) ;

Nothing stops a header from carrying header_filename or image_filename
twice, and nothing rejects the repeat, so the second assignment drops
the first string.  The input comes from a file, so the leak is
attacker-controlled in size and count: one copy per repetition.

    Direct leak of 5 byte(s) in 1 object(s) allocated from:
        #0 malloc
        #1 nifti_strdup nifti2_io.c:1301
        #2 nifti_image_from_ascii nifti2_io.c:8874

Freeing before the assignment costs one call and keeps the last value,
which is what the function already documented by overwriting.  The
fields are NULL until the first assignment, and free(NULL) is defined,
so no other path changes.

The same two lines exist in nifti1_io.c and are fixed there too.

Found by fuzzing nifti_image_from_ascii() with clang's libFuzzer under
AddressSanitizer.

(cherry picked from commit b4c6894)
act_mod_hdrs(), act_mod_hdr2s() and act_swap_hdrs() -- five functions
across the two tools -- read a header, then, when -prefix is given,
duplicate the dataset before writing the modified header back.  Each of
the three ways that duplication can fail returns without freeing the
header, and the last of them also loses the strdup'd duplicate name:

    nhdr = nt_read_header(fname, &nver, &swap, 0, ...);
    ...
    if( opts->prefix ) {
       nim = nt_image_read(opts, fname, 1, 1);
       if( !nim ) { fprintf(...); return 1; }              /* nhdr */
       if( nifti_set_filenames(nim, opts->prefix, 1, 1) ) {
          nifti_image_free(nim); return 1;                 /* nhdr */
       }
       dupname = nifti_strdup(nim->fname);
       if( nifti_image_write_status(nim) ) {
          nifti_image_free(nim); return 1;                 /* nhdr, dupname */
       }
    }
    ...
    free(dupname);
    free(nhdr);

The normal path frees both.  Reproduced by pointing -prefix at a
directory that cannot be written:

    nifti_tool -mod_hdr -prefix <read-only dir>/anat1 -infiles anat0.nii \
               -mod_field qoffset_x -17.325

  before:  definitely lost: 348 bytes in 1 blocks
  after:   ERROR SUMMARY: 0 errors from 0 contexts

Separately, act_diff_nims() in both tools releases the first image with
free(nim0) when reading the second one fails.  That is a shallow free: it
loses nim0's fname, iname and any data or extensions.  It now calls
nifti_image_free() like the success path six lines below.

Found by running the test suite under valgrind.  On the normal paths the
suite is clean -- 484 traced processes, no invalid access, no
uninitialised value and no leak in any nifti binary -- so these are
error-path defects that the tests do not otherwise reach.

(cherry picked from commit 40009a9)
Three -Wsign-compare warnings, each comparing a signed value against an
unsigned one, where the signed operand is silently converted and a
negative value would compare as enormous.

  nifti1_tool.c, nifti_tool.c, fill_cmd_string()
      `len < 0 || len >= remain`, len an int, remain a size_t.  The
      `len < 0` test short-circuits first, so the conversion could not
      actually misfire, but the comparison relies on that ordering to be
      correct.  Made explicit, matching the idiom used a few lines above.

  nifti_tool.c, read_file_text()
      `bytes != len64`, size_t against int64_t.  len64 is validated as
      > 0 and <= INT_MAX immediately above, so the cast is lossless.

(cherry picked from commit 7b37e32)
Two of the five Build and Test jobs have never run to completion.

sanitize-clang-linux invokes scan-build, which lives in clang-tools and
was not installed, so the job exits 127 before configuring.

rel-clang-macos asks brew for "sed", which is not a formula; brew fails
the step and the job exits 1 before configuring. The GNU sed the
dashboard scripts expect is gnu-sed.

Both predate the workflow's first successful run, so neither has
regressed; the trigger named the wrong branch until recently and the
jobs never executed.

(cherry picked from commit 3147438)
loc_strnlen measures a string that need not be terminated, but it
dereferences before testing the bound, so when no NUL appears in the
first maxlen bytes the last iteration reads str[maxlen].

Both callers pass an unterminated buffer: axml_read_buf takes the
caller's buffer and its length, and axml_read_file measures what
fread returned, which fills the buffer for any larger file.

AddressSanitizer on a buffer sized to its content reports a
heap-buffer-overflow read 0 bytes after a 324-byte region. The
returned length is unchanged wherever the old order was in bounds.

(cherry picked from commit 7476a12)
The only file in the tree without a trailing newline, and the only
-Wnewline-eof warning.  C11 5.1.1.2p1 requires a non-empty source file to
end in a newline not immediately preceded by a backslash, so this is
undefined behaviour rather than only a diff annoyance.

(cherry picked from commit 91d1bb9)
…rter

nifti_convert_n2hdr2nim() uses nhdr.dim[0], the number of dimensions,
as a loop bound over the eight-element dim[] array:

    for( ii=2 ; ii <= nhdr.dim[0] ; ii++ )        ...
    for( ii=nhdr.dim[0]+1 ; ii <= 7 ; ii++ )      ...
    for( ii=1 ; ii <= nhdr.dim[0] ; ii++ )        ...

It never checks that dim[0] is in [0,7].  The NIFTI-1 converter gets
that check for free, because need_nhdr_swap() rejects a dim[0] outside
[1,7] in either byte order, but the NIFTI-2 path decides swapping from
sizeof_hdr and reaches the loops with whatever the file said.

A NIFTI-2 header with a large negative dim[0] therefore starts the
second loop at a wild negative index, reads far outside the header and
segfaults.  This is not confined to the header API: nifti_image_read()
gets there for any file with a valid 540 byte NIFTI-2 header, so a
604 byte file crashes nifti_tool:

    $ nifti_tool -disp_nim -infiles bad_n2_dim0.nii
    nifti2_io.c:5079: runtime error: index -6727636073941130588 out of
        bounds for type 'int64_t[8]'
    AddressSanitizer: SEGV ... in nifti_convert_n2hdr2nim

dim[0] is now range checked in the same place, and in the same style,
as the dim[1] check just below it.  Zero stays acceptable, as it is on
the NIFTI-1 side.  Valid headers are unaffected: dim[0] outside [0,7]
has no meaning in either format.

Found by fuzzing nifti_convert_n2hdr2nim() with clang's libFuzzer under
AddressSanitizer.

(cherry picked from commit 5e32002)
Both header converters multiply the dimensions into nim->nvox without
checking the product:

    for( ii=1, nim->nvox=1; ii <= nhdr.dim[0]; ii++ )
       nim->nvox *= nhdr.dim[ii];

Seven NIFTI-1 dimensions of 32767 are enough to overflow int64_t, and a
NIFTI-2 header needs only two dimensions to do it:

    nifti2_io.c:4838: runtime error: signed integer overflow:
        1152780773560811521 * 32767 cannot be represented in type 'int64_t'

Signed overflow is undefined, and what the compiler does produce is a
voxel count that no longer describes the file.  nvox then goes on to
nifti_get_volsize(), which multiplies it by nbyper for another
unchecked product, and that result is used as an allocation size and a
read length.

Both products are now checked before they are made, in the way the
surrounding code already reports a bad header.  A header that overflows
is rejected with a message instead of producing a silently wrong image.
No valid image is affected: an int64_t voxel count is more than any
file can hold.

Found by fuzzing the header converters with clang's libFuzzer under
UndefinedBehaviorSanitizer.

(cherry picked from commit 66c76c2)
The first commit guards both converters in nifti2/nifti2_io.c.
niftilib/nifti1_io.c has the same loop in nifti_convert_nhdr2nim(), on
the same untrusted path, and was left unguarded.

  $ nifti1_tool -disp_nim -infiles huge.nii   # dim[0]=7, dim[1..7]=32767
    dim     32  8  7 32767 32767 32767 32767 32767 32767 32767
    nvox    64  1  -1073512449

The wrapped count then becomes the data allocation size and the result
of nifti_get_volsize().  With the guard the header is refused:

  ** ERROR: nifti_convert_nhdr2nim: dim[] overflows the voxel count

nifti_image.nvox is a size_t in this library and an int64_t in the
NIFTI-2 one, so the bound here is SIZE_MAX rather than INT64_MAX.
nifti_get_volsize() is size_t * size_t to match.  dim[0] is already
bounded by need_nhdr_swap(), and the loop that raises every dim to at
least 1 still runs first, so the dim[ii] > 0 test never has to reject.

Two more nvox loops remain in each library, in
nifti_update_dims_from_array() and update_nifti_image_for_brick_list().
Both take dims that a caller has already set rather than dims read from
a file, and reach them from nifti_tool's command line, so they are left
for a separate change.

(cherry picked from commit 147a07a)
…agic claims

swap_nifti_header() takes a void pointer and a version number, and picks
the struct to swap from the version:

    if     ( ni_ver == 0 ) nifti_swap_as_analyze((nifti_analyze75 *)hdr);
    else if( ni_ver == 1 ) nifti_swap_as_nifti1((nifti_1_header *)hdr);
    else if( ni_ver == 2 ) nifti_swap_as_nifti2((nifti_2_header *)hdr);

Two callers pass it a nifti_1_header, 348 bytes, together with the
version taken from that header's own magic string by NIFTI_VERSION().
A file whose magic is "n+2" therefore has 540 bytes swapped in place:
192 bytes of read and write past the end of the caller's stack object.

nifti_read_n1_hdr() reads the header straight from a file, so the bytes
that decide this come from the file being read.  A 348 byte file with

    sizeof_hdr = 348, dim[0] byte-swapped, magic = "n+2"

is enough; need_nhdr_swap() then reports that swapping is needed and the
overflow happens before any validity check.  AddressSanitizer:

    ERROR: AddressSanitizer: stack-buffer-overflow
    READ of size 1 ...
        #0 nifti_swap_4bytes nifti2_io.c:3051
        #1 nifti_swap_as_nifti2 nifti2_io.c:3194
        #2 nifti_read_n1_hdr nifti2_io.c:5394
    Address ... is located in stack of thread T0 at offset 412 in frame
        [64, 412) 'nhdr' (line 5339) <== Memory access ... overflows

nifti_convert_n1hdr2nim() has the same line and takes its header from
the caller, so any application that fills a nifti_1_header itself can
reach it too.

Both call sites know the struct they hold, so both now ask for the swap
that struct supports: analyze when the magic is absent, NIFTI-1
otherwise.  Headers claiming versions 3 to 9, which swap_nifti_header()
previously refused with a message and left unswapped, are now swapped as
NIFTI-1 as well, which is the only interpretation 348 bytes allow.

nifti1_io.c is not affected: its swap_nifti_header() takes a
nifti_1_header * and a flag, so it cannot choose a wider struct.

Found by fuzzing nifti_convert_n1hdr2nim() with clang's libFuzzer under
AddressSanitizer.

(cherry picked from commit 16a6b67)
nifti_tool.c has the same defect this branch fixes in the library: it
passes NIFTI_VERSION(*nhdr), read from the magic string, to
swap_nifti_header(), which selects the struct to swap by that number.

act_mod_hdrs() and act_swap_hdrs() hold a nifti_1_header of 348 bytes.
A magic of "n+2" makes NIFTI_VERSION() return 2, so swap_nifti_header()
treats the allocation as a 540 byte nifti_2_header and reads and writes
past its end.  Both functions refuse a header that is valid NIFTI-2, but
a header that is valid as neither version reaches the call.

  $ printf ... > evil.nii      # 348 bytes, sizeof_hdr byte swapped,
                               # magic "n+2"
  $ nifti_tool -mod_hdr -mod_field descrip hello -overwrite \
               -infiles evil.nii
  ==2747364==ERROR: AddressSanitizer: heap-buffer-overflow
      #0 nifti_swap_4bytes      nifti2_io.c:3029
      #1 nifti_swap_as_nifti2   nifti2_io.c:3172
      #2 swap_nifti_header      nifti2_io.c:3131
      #3 act_mod_hdrs           nifti_tool.c:3389
    0 bytes after 348-byte region allocated in nifti_read_n1_hdr()

Both now pass 1, or 0 for ANALYZE, which are the two 348 byte layouts.
act_mod_hdr2s() holds a nifti_2_header and gets the explicit 2, as
act_swap_hdrs() already does for its own NIFTI-2 display path.

old_swap_nifti_header() takes a nifti_1_header and a boolean, so the
calls beside these need no change.

(cherry picked from commit 522e624)
Under FSLSTYLE the block that reports "Multiple possible filenames" has
three defects, and they cannot be separated.

  free(basename);                                  <- freed here
  char *gzname = calloc(...);                      <- unchecked
  strcpy(gzname, hdrname);                         <- used at once
  ...
    fprintf(stderr,"... %s\n", basename);          <- read after free
    exit(134);                                     <- kills the caller

basename is read by the message, so it cannot be freed at the top.
gzname goes straight into strcpy(), so it has to be checked.  And once
the function returns NULL instead of calling exit(), every path out has
to release what it holds, which is what ties the three together: moving
the free of basename decides what the error paths must free, and there
are no error paths to speak of until exit() is gone.

FSLSTYLE is off by default but is a supported configuration:
-DFSLSTYLE:BOOL=ON turns it on, and nifti2/Makefile defines it always.
Built and tested with -DFSLSTYLE=ON.

The nifti_image_read() half of the original change is a separate commit.

(cherry picked from commit 0ddf192)
    mind = get_map_index(xt->xchild[kid]);
    if( kid >= 0 ) MIM_disp_funcs[mind](ofp, xt->xchild[kid], verb);

The guard tests kid, the loop counter, which is never negative, so it is
always true.  The index actually used is mind, and get_map_index()
returns -1 for any element name not in MIM_kids[].  A CIFTI file
containing an unrecognised element under MatrixIndicesMap therefore reads
a function pointer from before the start of MIM_disp_funcs and calls it.

Reported by the clang static analyzer as security.ArrayBound, "Out of
bound access to memory preceding 'MIM_disp_funcs'".

(cherry picked from commit b8d5246)
Drives cifti_tool with -eval_type show_summary over two XML fixtures.
The first holds a MatrixIndicesMap child whose name get_map_index()
does not resolve, and fails without the preceding commit: the sanitizer
legs report a global-buffer-overflow, an 8-byte read 8 bytes before
MIM_disp_funcs.

The second holds a BrainModel and asserts it is still displayed, so
that skipping the unresolved name cannot pass by skipping every name.

These are the first tests for the cifti library. The fixtures are small
enough to keep in tree, so neither needs the external testing data.

(cherry picked from commit 06cb5a6)
process() returned without releasing either the afni_xml_t it parsed or
the nifti_image the non-cext path fills in, so every run leaked the whole
tree. Both free routines already accept NULL, so neither path needs a
guard.

LeakSanitizer is on by default under AddressSanitizer on Linux but not on
Apple, so the leak ended the process with a non-zero status on the
sanitizer job alone. That also discarded the buffered standard output,
which is why the summary text went missing there rather than merely
being followed by a leak report.

(cherry picked from commit e96d1e9)
…atype

The REJECT_COMPLEX block reads nim->datatype, and the test for nim being
NULL sits immediately below it, so a header the converter refuses crashes
the process in builds that define REJECT_COMPLEX. The refusal itself is
correct and already reported; only the order is wrong.

Found by the malformed-header tests added in this branch: the guards make
the converter return NULL for exactly the inputs they cover, which is the
case this block never handled.

(cherry picked from commit f79d234)
A dim[0] outside 1..7 is used to index dim[] in nifti_convert_n2hdr2nim,
so a header carrying a large negative value reads far outside the struct.
The fixture is a 604-byte NIFTI-2 header holding such a value; without the
check the tool segfaults in an ordinary Release build, so this test guards
on every CI job rather than only the sanitizer one.

(cherry picked from commit 2f6742c)
nifti_read_n1_hdr reads 348 bytes, so swapping the buffer as whatever
width its magic names reaches past the end when the magic says n+2. The
fixture is a big-endian NIFTI-1 header wearing an n+2 magic, which the
sanitizer legs report as a stack-buffer-overflow without the fix.

-check_hdr is the seam that reaches it; -disp_hdr does not.

(cherry picked from commit e3990de)
act_mod_hdrs() and act_swap_hdrs() hold a 348-byte nifti_1_header and
swapped it at the width its magic named, reading past the end for an n+2
magic. Both reuse the fixture added for the library-side check, and both
rewrite their input, so each test runs against its own copy.

(cherry picked from commit 3deb735)
Three fixtures, because the two guards are reached separately: a product
that wraps nvox, the NIFTI-2 form of the same, and one whose nvox fits
while nvox * nbyper does not. Without the guards the tool accepts all
three and reports a negative nvox.

The regex pins which refusal fired; an exit-code test would not tell
this rejection apart from any other error.

(cherry picked from commit 01ce2fa)
nifti_convert_nhdr2nim carries its own copy of the guards, so it needs
its own tests; reverting the NIFTI-1 half alone leaves the nifti2 tests
green and turns exactly these two red.

(cherry picked from commit 329e869)
nifti_image_from_ascii overwrote fname and iname without releasing what
they already held, so an ASCII header naming either attribute twice leaked
the earlier copy. One fixture drives both libraries.

The regex asserts the last value survives, so a fix that ignored the
repeat instead of freeing the previous value could not pass.

(cherry picked from commit d0b9413)
loc_strnlen read one byte past the buffer it was given. The reachable
caller is axml_read_buf, where the buffer is an extension's edata,
allocated esize-8 and filled exactly; axml_read_file allocates one spare
byte and absorbs the overread, so it is not the seam.

The fixture is a NIFTI-1 image carrying a CIFTI extension whose payload
is space-padded to hold no NUL. The overread is reported by the sanitizer
legs; the build stays green without them.

(cherry picked from commit 457f8bf)
The tool writes both members of the pair itself, so no data is needed.
The driver asserts exit 1 and the diagnostic, because a library reports
the clash to its caller rather than ending the process, and a plain
add_test cannot tell that exit from the abort it replaced.

It then removes the .gz and repeats, so an unconditional NULL return
could not pass. Guarded on FSLSTYLE_NAME_CONFLICTS, which is off by
default, so the test is registered only where the code is compiled.

(cherry picked from commit 7a0b98b)
Two paths returned without releasing the header, and one without
releasing the duplicated name: a write into a directory that refuses it,
and a duplication that fails because the input is the other NIfTI
version. Both are reachable from the shipped tools with no data beyond
what -make_im writes.

The script asserts only that the paths are reached and reported; the
leak is what the memcheck and sanitizer legs see. It skips itself for
root, whom a read-only directory does not stop.

(cherry picked from commit c051fd8)
c22_copy_image compares by content because gzip output is not
reproducible across zlib implementations, but that property was only
exercised where the system zlib is zlib-ng. Every CI leg ships stock
zlib, so the comparison could revert to comparing compressed bytes and
the suite would stay green.

Extracting the helper lets it be tested directly. gzip -1 against -9
manufactures the byte difference on any zlib, and the two negative
assertions pin that differing content is still reported as different and
that the uncompressed path still works.

(cherry picked from commit b5023c5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants