Skip to content

Modernize: uniform style, C11, a warning set held at zero, and the memory-safety defects that fell out - #26

Closed
gdevenyi wants to merge 42 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:ci/enable-werror
Closed

gdevenyi wants to merge 42 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:ci/enable-werror

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 15, 2026 •

Copy link
Copy Markdown

Modernizes the codebase: a uniform code style enforced by CI, an ISO C11 build, a warning set the tree holds at zero under both gcc and clang, and the memory-safety defects that fell out of turning all that on.

It is 40 commits, one change type each, meant to be read commit by commit. Each one is also a separate PR in my fork if that is easier to review in slices: gdevenyi#1 has the index. GitHub cannot stack PRs across repositories, so upstream it has to arrive as one.

What it does

Commits
1 removes nifticdf (see the caveat below)
2 fixes the CI trigger
3–5 .clang-format, the reformat, .git-blame-ignore-revs
6–8 C11, a shared warning set, a reworked .clang-tidy
9–31 one warning or defect class per commit
32 CI jobs for clang-tidy and -Werror

Real defects fixed, not just warnings silenced

  • MIM_disp_funcs[mind] guarded by if (kid >= 0) in axio_show_mim_summary(). kid is the loop counter and never negative, so the guard is always true; get_map_index() returns -1 for any element name not in MIM_kids[]. A CIFTI file with an unrecognised element under MatrixIndicesMap reads a function pointer from before the table and calls it.
  • int offset in FslSeekVolume() holds a byte position into the image file. Any volume past 2GB overflows it, and znzseek() takes a 64-bit offset.
  • Use-after-free in nifti_findhdrname() under -DFSLSTYLE: basename is freed, then read by the fprintf that reports the ambiguous-filename error. Same block had an unchecked calloc, a leak of hdrname, and exit(134).
  • nifti_image_read() under -DREJECT_COMPLEX tested nim->datatype before the if (nim == NULL) check immediately below it.
  • Alignment UB in modify_field() — 8 sites writing through ((short *)((char *)base + offset)) where offset is a byte offset into a packed header. Now memcpy.
  • i and j uninitialised in nifti_mat44_to_orientation() — set only inside a switch whose default is assert(0), which disappears under NDEBUG. k was already fixed; the other two were missed.
  • Nine allocations dereferenced without a NULL check, two leaks, an unclosed stream, and a search loop in cifti_tool that never advanced its pointer.
  • Division by zero in FslReadSliceSeries() and FslReadRowSeries() — both divide by swapsize, which nifti_datatype_sizes() sets to 0 for DT_UINT8, DT_INT8 and DT_RGB24. Nothing stops a header declaring an 8-bit datatype with the opposite byte order.
  • 32-bit overflow throughout fslio's size arithmetic — size_t volbytes = xdim * ydim * zdim * wordsize; and size_t FslGetVolSize(...) { return nx * ny * nz; } do the multiply as int and widen only the result, so the destination type bought nothing past 2GB.
  • znzread(), znzwrite() and nifti_read_buffer() return -1 from size_t functions, so an error reaches the caller as SIZE_MAX. Callers happen to detect it by comparing against the requested count, but the documented contract is one the return type cannot express. Made explicit, not changed — fixing it properly is a contract change.
  • Stack buffer overflow in doPigz()/doPigz2() — a shell command is built in char command[768] with strcat(command, nim->fname) and handed to popen(). PATH_MAX is 4096, so an ordinary deep path overruns it. PR ENH: code review of recent nifti_image_conflicted_names PR #11 grows the buffer to 4096, which does not fix it; this bounds the write instead.
  • Unbounded vsprintf() in znzprintf(), into a buffer sized strlen(format) + 1000000 with the comment /* overkill I hope */. Now vsnprintf, and the va_list is ended on the allocation-failure path it previously leaked.

Verification

At the tip, for default, -DUSE_FSL_CODE=ON, and -DFSLSTYLE=ON (PIGZ), each under gcc 16.1.1 and clang 22.1.8 — six configurations, with the full warning set including -Wsign-conversion:

  • builds clean with -DNIFTI_WARNINGS_AS_ERRORS=ON
  • 92/92 tests pass

Also: valgrind reports 0 errors and 0 bytes lost across 91 tests with --trace-children=yes (which matters — without it valgrind only inspects the shell wrappers and reports a falsely clean run); ASan+UBSan with -fno-sanitize-recover is clean.

The reformat commit is verified by comparing compiled output rather than by reading a 58k-line diff: 14 of 16 object files are byte-identical before and after at -O2 -g0, and the two that differ are both nifti_tester001.c.o, whose PrintTest macro expands __LINE__ — disassembly shows the only differing instructions are immediates decoding to exactly the shifted line numbers.

External interface

Unchanged throughout, apart from the deliberate removal in commit 1. Every commit was checked with nm -D over all five shared libraries, the preprocessed installed headers, and gcc -dM -E over every installed macro. Across all 32 commits the only macro text that differs is FSL_RADIOLOGICAL, which becomes (-1) instead of -1; its value is unchanged, verified by compiling against the installed header.

This is why misc-use-internal-linkage is disabled in .clang-tidy with the reason recorded next to it: it flags 29 functions, 13 of which are exported symbols, and its fix-it would delete them from the shared libraries.

Two things to decide

Commit 1 removes libnifticdf, nifticdf.h and nifti_stats. That is a real interface change and a maintainer call, which is why it is first and standalone. It does not affect AFNI, SPM, MINC, MIRTK, BROCCOLI or Slicer — all of them vendor their own copy of nifticdf.c rather than linking the library, and ITK vendors only niftilib and znzlib. It does affect anyone linking -lnifticdf or using the NIFTI::nifticdf CMake target. Say the word and I will drop that commit and rebase the rest.

-Wsign-conversion is opt-in rather than fixed. Now fixed properly — the flag is in the default warning set and the tree holds at zero under it. All 225 findings are cleared across six commits, split by what the conversion actually does: fslio's size and offset arithmetic, the voxel-count and brick-size products, allocation sizes, copy lengths, read/write lengths, and the remaining offsets and counters.

The audit is the interesting part. Tracing the invariants showed most of these conversions are provably safe rather than latent bugs: nifti_update_dims_from_array() rejects dim[0] outside [1,7] and clamps every dim[i] < 1 up to 1, nifti_datatype_sizes() leaves nbyper at 0 for an unknown datatype and every caller checks it, and nifti_image_load() refuses to proceed unless nbyper > 0 && nvox > 0. So the casts document invariants the code already enforces. Each commit message says which invariant it is relying on.

Four real defects did fall out of it, described below.

Relationship to the open PRs and issues

On the string functions (upstream PR #24)

I audited every strcpy/strcat call rather than replacing them wholesale. Two were genuine defects and are fixed above. The other 145 are correct: 86 copy into a buffer allocated as strlen(source) + slack a few lines above, 10 write into a fixed array that fits exactly (extcopy[8] takes name + len - 7; the elist[] entries are the char[8] buffers whose declaration comment says "(leave space for .gz)"), and the rest are literals into much larger struct fields.

So instead of rewriting 145 correct calls, this enables _FORTIFY_SOURCE (level chosen by compiling a test, applied only to optimised builds). The C library then checks the destination size of every one of those calls wherever the compiler can determine it — including the run-time-known strlen(x) + n sizes this library uses — and aborts rather than overflowing. Verified it catches the doPigz defect above.

That complements PR #24 rather than replacing it: fortification catches a bad size at run time, strlcpy and -fbounds-safety aim to make the bound explicit at compile time. If #24 lands, this stays useful. Note also that #24 defines strlcpy/strlcat at global scope on glibc < 2.38, which claims names the C library reserves, and its #if defined(__GLIBC__) guard omits the fallback entirely on musl and MSVC.

Sorry for the size — the reformat cannot be small, and everything after it is deliberately one class per commit so the interesting parts are reviewable on their own.

gdevenyi and others added 19 commits August 14, 2026 19:29
nifticdf was a mechanical C translation of DCDFLIB (Brown and Lovato,
public domain) plus material from K. Krishnamoorthy.  It computes
cumulative distributions for the NIfTI-1 statistical intent codes and
has nothing to do with NIfTI file I/O: no other library in this package
referenced a single one of its symbols, and the only in-tree consumers
were the nifti_stats tool and three real_easy linkage demos.

At 11160 lines it was also the single largest file in the repository and
by far the most hostile to modernization: 792 goto labels at column 0,
890 gotos, Fortran-derived ALL-CAPS comment blocks and file-local
statics at column 0.  It would have dominated every subsequent
formatting and static-analysis pass while gaining nothing.

Downstream consumers are unaffected in practice.  AFNI, MIRTK, BROCCOLI,
Slicer and minc-toolkit-v2 all vendor their own copy of nifticdf.c
rather than linking libnifticdf, and SPM compiles it directly into
nifti_stats_mex.c.  ITK vendors only niftilib and znzlib.  Anyone who
does link it can continue to take it from the 3.0.1 release.

This does remove libnifticdf, nifticdf.h and nifti_stats from the
installed package.  Projects linking -lnifticdf or using the
NIFTI::nifticdf CMake target must be updated.  Verified with nm -D that
no symbol of libznz, libniftiio, libnifti2, libcifti or libfslio changed.

The real_easy demos now exercise linkage against niftiio, so they keep
demonstrating find_package(NIFTI) usage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The "Build and Test" workflow triggers on branch `main`, but this
repository's default branch is `master`.  Every job in it has therefore
never executed once since the workflow was added: coverage, valgrind
memcheck, clang scan-build, and the ASan+UBSan build.  The only workflow
that has been running is cmake-multi-platform.yml, the unmodified GitHub
starter template, which does a plain Release build in about 30 seconds.

Also:
 - macos-11 has been retired by GitHub, so the rel-clang-macos job would
   have failed to schedule even with the trigger fixed.  Use macos-14.
 - actions/checkout@v3 is deprecated; use @v4.
 - the starter-template workflow ran ctest without --output-on-failure,
   so a red build reported no diagnostic output at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Adds the style definition and the machinery to enforce it, but changes
no formatting.  The repo-wide reformat is the next commit, kept separate
so that this one is reviewable and that one is purely mechanical.

The style is ITK's .clang-format verbatim, with six overrides.  ITK was
chosen deliberately: the stated reason for preferring clang-format over
uncrustify was to stay in sync with ITK and reduce maintenance, so the
config should be re-synced from ITK rather than allowed to drift.

Five overrides protect hand-drawn layout:
  BreakStringLiterals: false     nifti_tool.c and nifti1_tool.c hold ~3000
                                 lines of help and history text whose
                                 layout inside the literals is meaningful.
  ReflowComments: false          Doxygen banners, boxed file headers, and
                                 the block closer used 151 times.
  AlignConsecutiveMacros: true   hand-aligned #define tables.
  AlignArrayOfStructures: Left   nifti_type_list[] and friends are grids.

The sixth protects the public API.  ITK sets QualifierAlignment to
Custom, which rewrites `char const *` to `const char *` -- 31 times here,
12 of them in installed headers.  The two spellings denote the same type,
but clang-format's documentation warns the option can make incorrect
decisions for lack of semantic information, and a 58000-line mechanical
change is not where you want to accept that risk on a public header.
Set to Leave.

Three files are never formatted at all, enforced by format.sh:
  niftilib/nifti1.h, nifti2/nifti1.h   the normative NIfTI-1 spec.  Its
      two-column trailing-comment layout -- NIfTI field on the left, the
      ANALYZE 7.5 field it replaced on the right -- is the document, and
      some lines exist only to hold the second column.  The two copies
      are byte-identical and a test enforces that.
  fsliolib/dbh.h                       ANALYZE 7.5 header, (c) Mayo
      Foundation, incorporated by permission and copied verbatim
      downstream.
The exclusions live in format.sh rather than .clang-format-ignore because
that file is not honoured by clang-format 19.1.7.

Twenty-two regions that clang-format cannot preserve are fenced in the
sources.  Verified by running the formatter over them: without the
fences the r11/r12/r13 matrix diagrams get split one statement per line,
the nifti_type_list[] grid collapses, and all 253 aligned NT_FILL lines
are broken in two.

format.sh applies or checks the style, fetching the pinned clang-format
via uv or pipx so that developers and CI agree.  A new Format workflow
runs it with --check and fails on drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Mechanical output of ./format.sh with the configuration added in the
previous commit.  No hand edits.  Add this commit to
.git-blame-ignore-revs, which the next commit does.

The diff is large because it has to be.  Before this commit the tree
carried five different indent widths -- 3 spaces in niftilib, nifti2 and
cifti, 2 in znzlib and fsliolib, 8 in parts of real_easy -- so any
consistent style rewrites nearly every line.  Trying LLVM, GNU, Mozilla
and ITK styles all produced changes of the same order.

Semantic equivalence is verified by comparing compiled output rather
than by reading the diff.  Building at -O2 -g0 before and after:

  14 of 16 object files are byte-for-byte identical.

The two that differ are both nifti_tester001.c.o, whose PrintTest macro
expands __LINE__.  Disassembling both shows the only differing
instructions are immediate constants, and they decode to exactly the
line numbers the reformat shifted (100 -> 116, 112 -> 128, 113 -> 129,
and so on).  No other instruction changed.

The external interface is unchanged:
  - nm -D over all five shared libraries: identical symbol sets.
  - The installed headers, preprocessed and stripped of whitespace, are
    byte-identical, so every declaration still declares the same thing.

Tests are unaffected: 91 of 92 pass, the same as before, with
nifti_c22_copy_image failing identically on master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
git blame skips this revision once the file lists it, so the reformat
does not mask the real authorship of every line in the project.

Configure it permanently with:
    git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The build never set a C standard at all, so it used whatever the
compiler defaulted to -- gnu17 on current gcc and clang, gnu89 on
compilers old enough to matter when this code was written.  The legacy
GNU Makefiles disagree among themselves, pinning -ansi (C89) at the top
level and -std=gnu99 in nifti2 and cifti.

Set CMAKE_C_STANDARD 11, CMAKE_C_STANDARD_REQUIRED ON and
CMAKE_C_EXTENSIONS OFF, so every target compiles as -std=c11.

Two POSIX dependencies were being satisfied silently by the gnu
default and have to be requested explicitly now:

  cifti/afni_xml.c     strdup(), 3 uses.  Without a declaration this was
                       an implicit function declaration returning int,
                       assigned to char * -- an error in C99 onward and
                       genuinely undefined behaviour on any platform
                       where pointers are wider than int.
  nifti1_io.c,         popen()/pclose() in the optional PIGZ write path.
  nifti2_io.c          Same failure mode.  This only shows up when
                       building with -DFSLSTYLE_PIGZ_SUPPORT=ON, which
                       is off by default, so it had gone unnoticed.

Each file now defines _POSIX_C_SOURCE 200809L ahead of its includes.
Doing it per-file rather than globally keeps the dependency visible at
the point it is taken.  strdup() is in C23, so afni_xml.c's define can
go once C23 becomes the floor.

Verified by building both the default configuration and
-DFSLSTYLE=ON -DFSLSTYLE_PIGZ_SUPPORT=ON with no diagnostics, and
confirming nm -D and the installed headers are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The CMake build set no warning flags at all.  The only warning flags in
the project lived in the CI workflow's environment, which meant they
applied to nobody's local build and, since that workflow had never run,
to nothing at all.

cmake/nifti_warnings.cmake enables a set the project can realistically
hold at zero, so a future warning means a new defect rather than more
noise.  Warnings are not errors yet; the categories below are fixed one
per commit in the branches that follow, and -Werror is turned on at the
end via NIFTI_WARNINGS_AS_ERRORS.

Baseline as of this commit, whole tree, cifti and fsl enabled:

  gcc 16.1.1                        clang 22.1.8
    223  -Wsign-conversion            272  -Wextra-semi-stmt
     19  -Wcast-align                 222  -Wsign-conversion
     14  -Wmissing-prototypes          31  -Wshorten-64-to-32
     12  -Wcalloc-transposed-args      19  -Wcast-align
      9  -Wcast-qual                   14  -Wmissing-prototypes
      3  -Wsign-compare                11  -Wconditional-uninitialized
      1  -Wstringop-truncation          9  -Wcast-qual
                                       3  -Wsign-compare
                                       2  -Wmissing-variable-declarations
                                       1  -Wnewline-eof

Three tempting flags are deliberately left out, documented in the module:
-Wdouble-promotion (~240 hits, silencing them means switching to sqrtf()
and changing the numerical results of the quaternion code),
-Wfloat-equal (~150, mostly deliberate tests against exact 0.0), and
-Wconversion (subsumed by the two above plus -Wsign-conversion).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
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 1489 came
from three style checks.  At that ratio nobody reads the output.

Retuned for what this project actually is: C11, with a shipped ABI.
Now 278 findings across 12 checks, every one a defect class.

  170  bugprone-macro-parentheses
   43  bugprone-unchecked-string-to-number-conversion
   25  bugprone-multi-level-implicit-pointer-conversion
   12  bugprone-switch-missing-default-case
   11  readability-redundant-casting
    5  bugprone-suspicious-string-compare
    4  bugprone-suspicious-realloc-usage
    3  readability-redundant-control-flow
    2  bugprone-misplaced-widening-cast
    1  each: readability-{use-concise-preprocessor-directives,
         implicit-bool-conversion,function-size}

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, and it is the comprehensive form of
what upstream PR #22 fixes by hand.

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 now says so next
to the check rather than leaving the next person to rediscover it.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The only file in the tree without a trailing newline, and the only
-Wnewline-eof warning.  A source file not ending in a newline is
undefined behaviour in C11 (5.1.1.2p1 requires a non-empty source file
to end in a newline not immediately preceded by a backslash), and it is
why the file always shows up in diffs as "\ No newline at end of file".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
afni_xml_tool.c and cifti_tool.c each define a file-scope `opts_t gopt`
with external linkage, so the two share a symbol name and neither
declares it in a header.  Each is used only inside its own translation
unit, always by address as `&gopt`.

Both files build executables rather than libraries, so `static` removes
nothing from any shared library's exported symbols; verified against the
recorded nm -D baseline, which contains no `gopt`.

Clears both -Wmissing-variable-declarations warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Three -Wsign-compare warnings, all comparing a signed value against an
unsigned one.  In each case the signed operand is silently converted to
the unsigned type, so a negative value compares as enormous and the
guard it was written to provide does not fire.

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

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

No behaviour change; these make the existing intent explicit so the
comparisons no longer depend on the surrounding guards for correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Five -Wunused-parameter warnings, all of the same shape: a parameter
that only has meaning when zlib support is compiled in.

  nifti_makehdrname(), nifti_makeimgname()   `comp` selects whether a
      .gz suffix is appended, in both nifti1_io.c and nifti2_io.c.
  znzopen()                                  `use_compression` selects
      gzopen() over fopen().

All three functions are declared in installed headers, so the parameter
cannot be removed: doing so would change the published prototype and
break every caller.  Instead the #ifdef HAVE_ZLIB blocks that consume
them gain an #else that discards the value explicitly, which is also a
place to record why the parameter is ignored.

These only fire in a build without HAVE_ZLIB.  The CMake build does
find_package(ZLIB REQUIRED) and defines it unconditionally, so the
configuration is reachable only through the legacy GNU Makefiles -- but
the code is compiled either way and the reader deserves to know the
parameter is deliberately dropped rather than forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Ten -Wunused-variable warnings, all of them locals declared at the top of
a function but consumed only inside an #ifdef HAVE_ZLIB block.

  znzread(), znzwrite()    remain, cbuf, n2read/n2write, nread/nwritten.
      Moved into the `if (file->zfptr != NULL)` branch that uses them.
      They are now declared next to the loop they drive rather than
      thirty lines earlier, and the non-zlib path -- a plain fread() or
      fwrite() -- no longer sets up four variables it never reads.

  nifti_find_file_extension()   extgz, in both nifti1_io.c and
      nifti2_io.c.  Wrapped in the same #ifdef as its only use, the
      strcat() calls that build the .gz variants of the extension list.

No behaviour change in either configuration; the declarations move, the
code does not.  Verified nm -D and the installed headers are unchanged,
and that the tree still compiles clean both with and without HAVE_ZLIB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Fourteen calls were written calloc(sizeof(char), n) rather than
calloc(n, sizeof(char)), which gcc 14 and later diagnose as
-Wcalloc-transposed-args.

There is no behaviour change: calloc multiplies its two arguments and
sizeof(char) is 1 by definition, so every one of these allocated the
right number of bytes.  The warning is worth clearing anyway, because the
diagnostic exists to catch the case where the element size is not 1 -- at
which point the transposition stops being harmless and starts producing
a buffer of the wrong size.  Leaving 14 benign instances in the tree
trains the reader to ignore the warning that will one day be real.

Sites: nifti_findhdrname, nifti_findimgname, nifti_makehdrname,
nifti_makeimgname and the extension readers in both nifti1_io.c and
nifti2_io.c, plus FslGetHdrImgNames and two callers in fslio.c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Thirteen functions are compiled into libniftiio, libnifti2, libfslio and
libcifti with external linkage, and not one of them is declared in any
header at all -- so they are exported, linkable, and invisible.

  nifti_fileexists                        nifti1_io.c and nifti2_io.c
  axml_recur_find_xml                     afni_xml.c
  FslIsValidFileType, FslGetFileType2,    fslio.c
  FslFileType, FslGetReadFileType,
  FslGetHdrImgNames, FslInit4Write,
  fsl_fileexists, check_for_multiple_filenames,
  FslSetVoxUnits, FslGetVoxUnits,
  FslSetIntensityScaling

Each gets a prototype in its own translation unit.  That is the only fix
here that does not change the library's interface:

  * `static` is what clang-tidy's misc-use-internal-linkage wants, and it
    would delete all thirteen symbols from the shared libraries.  Anything
    currently linking them breaks, silently, at run time.
  * Moving them to fslio.h or nifti1_io.h would enlarge the published API
    with functions nobody has reviewed as public.

Neither is a decision a warning fix should make, so the comment above
each block says so and leaves the choice to whoever wants to make it
deliberately.

Verified: nm -D over all five libraries is byte-identical to before, and
so are the preprocessed installed headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Nine -Wcast-qual warnings.  Casting away const is worth taking seriously
rather than silencing, because it is how a read-only contract turns into
a write to memory the caller thought was safe.  Looking at each:

fslio.c, FslGetFileType2()
    mutablefslio = (FSLIO *)fslio; /* dodgy and will generate warnings */
    mutablefslio->niftiptr->nifti_type = ...;

    The comment is right that it looks dodgy, but the cast was never
    needed.  `const FSLIO * fslio` makes the *member* niftiptr const --
    its type is `nifti_image * const` -- while what it points at stays
    fully mutable.  The assignment is legal exactly as written, so the
    cast and the local both go and the behaviour is identical.  A
    self-deprecating comment had been standing in for a two-line check.

fslio.c, FslWriteVolumes()
cifti/afni_xml_io.c, axio_num_tokens(), text_to_i64(), text_to_f64()
    Pointers cast to char * and then only ever read: walked, indexed, or
    handed to strtoll()/strtod(), all of which take const char *.  They
    are now declared const and the casts removed.

cifti/afni_xml.c, strip_whitespace()
    Returned `(char *)str` on its several early-exit paths -- handing the
    caller a writable pointer to the const string it passed in.  The
    function is static, so its return type is nobody else's business;
    it now returns const char *.  Both call sites feed the result
    straight to strdup() or fprintf(), which take const char * anyway.

No signature in any installed header changed; nm -D is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
272 -Wextra-semi-stmt warnings, and behind almost all of them the same
latent trap: a macro whose body is a bare brace block.

  #define FSLIOERR(x) { fprintf(...); fflush(...); exit(...); }

Written that way, `if (p == NULL) FSLIOERR("...");` expands to
`if (p == NULL) { ... };` -- the trailing semicolon becomes a separate
empty statement.  Harmless as long as nothing follows, which is why 72
uses in fslio.c and 198 in nifti_tester001.c have never caused trouble.
Add an `else` to any one of them, though, and the empty statement closes
the `if` first, so the else has nothing to attach to and the compiler
reports a syntax error pointing at the else rather than at the macro.

Wrapping the bodies in do { ... } while (0) makes the trailing semicolon
part of the statement, which is what every call site already assumed.
Eight macros in total: FSLIOERR, and the seven nifti_*_test helpers in
nifti_tester001.c.

The remaining three were plain stray semicolons: a `};` after an if block
in fslio.c, and a lone `;` on its own line in the CR/LF translation loop
of both nifti1_io.c and nifti2_io.c.

No behaviour change; nm -D and the installed headers are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
170 bugprone-macro-parentheses findings.  This is the comprehensive form
of upstream PR #22, which fixes the same defect class by hand in a
handful of places; everything that PR changes is included here.

An unparenthesised macro argument silently takes the precedence of
whatever the caller passed.  NT_FILL(..., rv) expanding to `rv = ...`
does the wrong thing for any caller passing an expression, and
NT_MAT33_TO_MAT44(m33, m44) expanding to `m44.m[0][0] = ...` breaks for
any argument that is not a plain identifier.  None of the current call
sites trip on it, which is precisely why it would be found the hard way.

Sites: the NT_DT_* constants and the NT_FILL, NT_DCONVERT and NT_MAT*
macro families in nifti_tool.h and nifti1_tool.h, the QSTR macro in both
nifti1_io.c and nifti2_io.c, three test macros in nifti_tester001.c, and
FSL_RADIOLOGICAL in fslio.h.

Two notes on what was NOT taken from the automated fix:

  * clang-tidy wrapped the `dtype` and `stype` parameters of
    NT_DCONVERT_NO_CHECKS and NT_DCONVERT_W_CHECKS.  Those are type
    names, so `(dtype) * pd = dptr;` is a cast expression rather than a
    declaration and the build fails outright.  Reverted, and the two
    macros carry a NOLINT fence explaining why the check cannot be
    satisfied there.

  * FSL_RADIOLOGICAL is in an installed header, so its text does change:
    -1 becomes (-1).  Its value does not.  Verified with gcc -dM -E over
    every installed header that this is the only macro whose definition
    differs from master other than whitespace inside __attribute__,
    and by compiling a program against the installed fslio.h that prints
    FSL_RADIOLOGICAL and still gets -1.

nm -D and the installed declarations are unchanged.  Tests unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
31 -Wshorten-64-to-32 warnings.  Most were benign, one was not.

The real defect is FslSeekVolume():

    int offset;
    offset = fslio->niftiptr->iname_offset
             + vols * FslGetVolSize(fslio) * fslio->niftiptr->nbyper;
    return znzseek(fslio->fileptr, offset, SEEK_SET);

offset is an int, but it holds a byte position into the image file.  Seek
to any volume past 2GB and the multiplication overflows, so znzseek --
which takes a 64-bit znz_off_t -- receives a wrong and quite possibly
negative position.  offset is a local, so widening it to znz_off_t and
casting the operands changes no interface.  FslReadVolumes' volbytes had
the same shape and is now size_t.

The rest fall into two groups.

Internal helpers were widened to carry the value they were already being
handed.  In nifti2_io.c, nifti_read_extensions() and rci_alloc_mem() now
return int64_t rather than truncating their own int64_t results, and
nifti_read_next_extension() and nifti_check_extension() take an int64_t
`remain` rather than accepting an implicit narrowing from their caller.
nt_read_bricks() in nifti_tool.h likewise takes an int64_t length.  All
of these are static or live in an uninstalled header.

Where the narrowing target is an installed prototype it cannot be fixed,
only made explicit, so each such cast carries a comment saying why the
value cannot exceed the range and what would have to change otherwise:
nifti_image_load_bricks() returning a brick count as int,
nifti_read_subregion_image() returning a byte count as int,
nifti_read_ascii_image() taking an int header length, axio_num_tokens()
returning int, and FslSeekVolume()'s int return.  The remainder are
strlen() results assigned to int and fread()/znzread() counts, all of
them bounded by buffers a few hundred bytes long.

Verified with both gcc and clang, and nm -D, the installed declarations
and every installed macro are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
gdevenyi and others added 2 commits August 14, 2026 21:46
FslSetAuxFile() copied with strncpy(dest, src, 24) into a char[24] and
then wrote the terminator at [24-1].  That is correct, but only because
of the second line: strncpy() writes no terminator when the source is 24
characters or longer, which is what gcc's -Wstringop-truncation is
pointing at.

Copy at most sizeof(dest) - 1 and terminate at sizeof(dest) - 1, so the
call is self-evidently safe without depending on the line after it, and
the buffer size is taken from the buffer rather than repeated as a
literal in two places.  This follows the same direction as commit 7b08ead
"Use sizeof() instead of repeating raw constant".

Behaviour is unchanged for every input: the resulting string was, and
still is, the first 23 characters of aux_file followed by a NUL.

This was the last warning in the default build under gcc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
19 -Wcast-align warnings, and behind them real undefined behaviour on any
target that cares about alignment.

The worst is modify_field(), which writes a value into a header field
given a byte offset parsed from a field table:

    ((short *)((char *)basep + field->offset))[fc] = (short)val;

field->offset is a byte offset into a packed on-disk header, so the
address that cast produces is only correctly aligned for a short by
coincidence.  On x86 this happens to work; on a target that faults on
unaligned access it does not, and even on x86 it is UB that the compiler
is entitled to optimise on.  The same pattern appears for int, int64_t,
float and double, in both nifti1_tool.c and nifti_tool.c.

Each becomes a memcpy of the right width at the right byte offset.  This
is the fix attempted in NIFTI-Imaging/nifti_clib PR NIFTI-Imaging#172, which was never
merged and still carries assert(0) and "// TEMP" debugging scaffolding;
it is re-derived here cleanly.  Modern compilers turn a fixed-size memcpy
into the same instruction the assignment produced.

The second group is the mirror image: reading a pointer back out of a
structure through a byte offset.

    sp   = *(char **)((char *)str + fp->offset);
    ext0 = *(nifti1_extension **)((char *)str0 + fp->offset);

Also memcpy, into a properly aligned local.

The third is nifti_header_version(), which cast its `const char * buf`
argument -- a buffer straight off a file read, with no alignment
guarantee -- to both nifti_1_header * and nifti_2_header * and read
fields through them.  It now copies into aligned locals first.  Only
sizeof(nifti_1_header) bytes are guaranteed present, which the function
already checks, and both sizeof_hdr and magic lie inside that range for
either version, so that is exactly how much is copied.

Verified three ways: the tree is now warning-free under gcc; the full
test suite passes under -fsanitize=address,undefined with
-fno-sanitize-recover; and nifti_tool -mod_hdr2 round-trips int16, int32,
int64, float32, float64 and string fields to the correct values.

nm -D, the installed declarations and every installed macro are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
gdevenyi and others added 12 commits August 14, 2026 21:46
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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The build instructions still pointed at "make all", the legacy GNU
Makefile that the Makefile itself describes as needing "a little love"
and which does not build the nifti2 or cifti libraries at all.  Replaced
with the CMake invocation and the options worth knowing about.

Adds a section on running the checks this repository is now set up for:
format.sh, -DNIFTI_WARNINGS_AS_ERRORS=ON, clang-tidy, the sanitizers and
valgrind.

Two things about valgrind are worth writing down because both cost real
time to rediscover:

  * ctest's memcheck must be given --trace-children=yes.  Most of the
    tests are shell scripts that exec nifti_tool, and without it valgrind
    inspects the shell and reports a clean run having never looked at the
    library.  With it, a full run traces 34 nifti_tool, 24 nifti1_tool
    and 14 nifti1_test processes.

  * valgrind will not start without the C library's debug symbols, and
    distributions that ship a stripped ld.so with no debuginfo package --
    Arch and its derivatives -- cannot run it at all.  DEBUGINFOD_URLS
    does not rescue this: those glibc builds are not published to
    debuginfod.archlinux.org, debuginfod.cachyos.org or
    debuginfod.elfutils.org, all of which return 404 for the build-id.
    A container is the practical answer, so the recipe is included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
…o it

A library must not terminate the host application.  niftilib and nifti2
each had two exit() calls; both are now error returns.  This is the
change hjmjohnson asked for in upstream PR #11 -- "the removal of the
exit() commands that could cause an application to crash from using this
library" -- extended to both libraries rather than just nifti2, and to
the other defects sitting in the same code.

nifti_findhdrname(), under -DFSLSTYLE, detects the ambiguous case where
both foo.nii and foo.nii.gz exist.  That block had four problems:

    free(basename);
    char * gzname = calloc(strlen(hdrname) + 8, sizeof(char));
    strcpy(gzname, hdrname);                    /* gzname unchecked */
    ...
        fprintf(stderr, "... basename (*.nii, *.nii.gz): %s\n",
                basename);                      /* freed above */
        exit(134);                              /* in a library */

  * basename is freed and then read by the fprintf that reports the
    error -- a use-after-free on the diagnostic path.
  * the calloc result is used by strcpy/strcat without a NULL check.
  * hdrname leaks on the error path.
  * exit(134) kills the caller.

The free now happens after the last use, the allocation is checked, both
buffers are released, and the function returns NULL like its other
failure paths.

nifti_image_read(), under -DREJECT_COMPLEX, tested nim->datatype before
the `if (nim == NULL)` check immediately below it, so a header that
failed to convert dereferenced a null pointer rather than reporting the
error.  The block moves after the NULL check, frees nim and the file
handle, and returns NULL instead of exit(13).

Behaviour change worth stating plainly: callers that relied on the
process dying will now get NULL back.  That is the point, and it is what
the maintainer asked for, but a caller that never checked the return
value will now continue with a NULL pointer where it previously exited.
Both paths are behind non-default build options (FSLSTYLE,
REJECT_COMPLEX).

fsliolib is deliberately untouched: its FSLIOERR macro exits, and
de-fatalising it changes the contract at roughly fifty call sites, which
belongs in its own change.

Verified: default, -DFSLSTYLE_REJECT_COMPLEX=ON and
-DFSLSTYLE_NAME_CONFLICTS=ON all build with -Werror and pass 92/92;
nm -D, the installed declarations and the installed macros are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Building with -DFSLSTYLE=ON, which turns on PIGZ support, failed under
-DNIFTI_WARNINGS_AS_ERRORS=ON.  This code is compiled only inside
#ifdef PIGZ, so the earlier sweeps never saw it; the default build does
not compile it at all.  Two separate diagnostics:

gcc, -Wmissing-prototypes: doPigz() and doPigz2() in both nifti1_io.c and
nifti2_io.c are exported with external linkage and declared in no header.
Same treatment as the library's other undeclared exported functions: a
file-local prototype.  Not `static`, which is what upstream PR #11
proposes and which would drop the symbols from a PIGZ-enabled build, and
not a header entry, which would publish them.  Note nifti2_io.c's
doPigz() takes a nifti_1_header while its doPigz2() takes a
nifti_2_header, so the prototypes reproduce each signature exactly rather
than assuming they match.

clang, -Wconditional-uninitialized: nifti_image_write_engine() declares
both n1hdr and n2hdr, fills exactly one of them according to
nim->nifti_type, and records which in nver.  The PIGZ block then passes
one of them *by value* to doPigz()/doPigz2(), selected on nver.  The
selection is correct, but clang cannot see the correlation between nver
and which struct was written, and an indeterminate struct passed by value
would be written straight to the pigz pipe.  Both are now zero
initialised, matching how the false positives on the matrix helpers were
handled earlier in this series.

-DFSLSTYLE=ON -DUSE_FSL_CODE=ON -DUSE_CIFTI_CODE=ON now builds clean with
-Werror under both gcc 16.1.1 and clang 22.1.8, and passes 92/92.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Nine allocations had their result used without a NULL check, in each
case within a line or two, so an allocation failure is a null dereference
rather than an error.

  fslio.c   FslInit()      calloc(FSLIO) then FslSetInit(fslio), which
                           writes through it
            FslReadHeader() calloc(dsr) then FslReadRawHeader(hdr, ...)
            FslGetHdrImgNames(), check_for_multiple_filenames()
                           calloc then strcpy into it
            FslWriteVolumes() calloc of the byte-swap buffer, then
                           written in the reorder loop
  nifti2_io.c nifti_read_header() malloc(nifti_2_header) then
                           nifti_convert_nim2n2hdr() fills it in
  nifti1_io.c, nifti2_io.c  doPigz()/doPigz2(), four sites: the znzFile
                           is calloc'd and fp->zfptr assigned on the next
                           line
  afni_xml.c  make_afni_xml(), axml_add_attrs(): strdup results stored
                           straight into the structure

fslio.c uses FSLIOERR for allocation failure elsewhere (d3matrix,
d4matrix), so these follow the same idiom rather than introducing a
second convention in one file.  The others return an error the way their
neighbouring failure paths do.

Also removes the two unreachable `return (NULL)` statements that follow
FSLIOERR in FslReadAllVolumes() and FslReadHeader().  FSLIOERR exits, so
the returns cannot run; this is the fslio half of upstream PR #23.  That
PR's third hunk added a malloc check in nifticdf.c, which no longer
exists here.

The remaining allocations flagged by a mechanical scan were checked by
hand and already guarded, usually on the following line: the brick loops
in nifti1_io.c, nifti2_io.c, nifti1_tool.c and nifti_tool.c, and the
matrix helpers in fslio.c.

Verified across the default, -DUSE_FSL_CODE=ON and -DFSLSTYLE=ON builds:
all compile with -Werror and pass 92/92.  nm -D, the installed
declarations and the installed macros are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
suppressions-cppcheck.txt was manual-only: no CI job ever ran cppcheck,
and the file existed to make an ad-hoc local run quiet.  With clang-tidy,
the clang static analyzer, the project warning set and the sanitizers all
wired into CI, it is a list nobody consults that quietly ages against the
code it describes.

Some of it had already aged.  Three of its entries are obsolete because
this series fixed the defects they were hiding:

  invalidPointerCast:*/nifti1_tool.c
  invalidPointerCast:*/nifti_tool.c
      The casts through char * to short/int/int64_t/float/double in
      modify_field() are now memcpy, so the warning they suppressed no
      longer fires.
  constParameter:*/nifti1_tool.c, */nifti_tool.c
      Partly addressed by the const-correctness fixes in fslio and cifti
      and the file-local prototypes elsewhere.

The rest documented deliberate decisions ("the project likes C89",
"correct, but hard to reformulate without hurting readability") whose
reasoning is now recorded next to the checks in .clang-tidy instead.

Worth knowing before merging: seanm was still running cppcheck as
recently as January 2026 -- upstream PR #23 is titled "Fixed some
warnings from the new cppcheck 2.19".  Deleting this file means a fresh
cppcheck run reports the suppressed findings again.  Restoring it is a
git revert if that turns out to matter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
41 of the project's 225 -Wsign-conversion warnings are in fslio.c, and
they share one shape: a byte count or file offset is declared size_t, but
the expression assigned to it multiplies ints, so the arithmetic happens
at 32 bits and only the result is widened.

    size_t volbytes;
    volbytes = xdim * ydim * zdim * wordsize;   /* int product */

    size_t FslGetVolSize(...)
    { return (nx * ny * nz); }                  /* int product */

For a 3D volume over 2GB the product overflows before it is ever assigned
to the size_t, so the destination type buys nothing.  This is the same
defect already fixed in FslSeekVolume() earlier in this series, present
in FslGetVolSize(), FslSetDim()'s nvox computation, FslReadVolumes(),
FslWriteVolumes(), FslReadSliceSeries(), FslReadRowSeries(),
FslReadTimeSeries(), and the d3matrix/d4matrix allocators.

Each operand is now widened before the multiply rather than after.  The
znzseek() arguments are cast to znz_off_t for the same reason.

Two other things fell out of this:

nifti_swap_Nbytes() takes size_t as its element count, so the (int) casts
at the two call sites here were narrowing a size_t and then widening it
straight back.  Removed.

Both of those call sites divide by fslio->niftiptr->swapsize, which
nifti_datatype_sizes() sets to 0 for DT_UINT8, DT_INT8 and DT_RGB24 --
the datatypes that need no byte swapping.  Nothing stops a header
declaring an 8-bit datatype with the opposite byte order, and reading
such a file divides by zero.  Both are now guarded on swapsize > 1, which
is also the condition under which the swap does anything at all.

fslio.c is now free of -Wsign-conversion warnings.  92/92 tests pass and
the external interface is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
First of five commits clearing -Wsign-conversion, which 225 findings
made worth doing properly rather than by casting at each site.

This one covers the products that compute a voxel count or a brick size
from image dimensions.  They were already partly widened -- the first
operand carried a (size_t) cast -- but the rest were left as int, so each
subsequent multiply converted implicitly:

    nim->nvox = (size_t)nim->nx * nim->ny * nim->nz * nim->nt * ...
    nbl->bsize = (size_t)nim->nx * nim->ny * nim->nz * nim->nbyper;

and the running accumulators had the same shape:

    nim->nvox *= nim->dim[c];
    read_size *= nim->nbyper;
    total_alloc_size *= region_size[i];

These are safe, and it is worth saying why rather than asserting it.
nifti_update_dims_from_array() rejects dim[0] outside [1,7] and clamps
every dim[i] < 1 up to 1, so nx through nw are at least 1 by the time any
of this runs; nifti_datatype_sizes() leaves nbyper at 0 for an unknown
datatype and every caller checks for that, and nifti_image_load() refuses
to proceed unless nbyper > 0 and nvox > 0.  The conversions cannot be
negative, so making them explicit documents an invariant the code already
enforces rather than hiding one it does not.

No behaviour change: the arithmetic was already being done at 64-bit
width because the leading operand was size_t.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Second of five commits clearing -Wsign-conversion.  54 hunks, all of the
same shape: an int or int64_t element count reaching a size_t parameter
of malloc(), calloc(), realloc() or the cifti safe_realloc() wrapper.

    nbl->bricks = malloc(nbl->nbricks * sizeof(void *));
    *slist      = malloc(nbricks * sizeof(int64_t));
    ext->edata  = calloc(esize - 8, sizeof(char));
    ilist       = malloc((nints + 1) * sizeof(int));

Where an int count was multiplied by a sizeof, the cast goes on the count
rather than around the whole product, so the multiply happens at 64-bit
width.  That matters beyond the warning: (int)count * sizeof(T) can
overflow before it is widened, whereas (size_t)count * sizeof(T) cannot.

Each count is bounded before it gets here -- nbricks is checked > 0 by
its callers, num_ext and nints are grown only by the code that allocates
them, and the extension sizes are validated by nifti_check_extension()
before an allocation is attempted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Third of five commits clearing -Wsign-conversion.  22 hunks covering the
length argument of memcpy(), memset(), strncpy() and strncat() where the
length arrives as an int or int64_t.

    memcpy(NBL->bricks[idest], NBL->bricks[sindex[c - 1]], NBL->bsize);
    memcpy(*list, tmplist, (new_length - 1) * sizeof(nifti1_extension));
    memcpy(ext->edata, data, len);
    strncpy(dest, data, field->len);

As with the allocation sizes, where a count is multiplied by a sizeof the
cast goes on the count so the multiply is done at 64-bit width rather
than after a possible 32-bit overflow.

field->len comes from the static field tables compiled into the tools,
not from the file being read; the extension lengths are validated by
nifti_check_extension() first; and NBL->bsize is the product fixed in the
first commit of this group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Fourth of five commits clearing -Wsign-conversion.  15 hunks on the
length argument of znzread(), znzwrite(), fread() and expat's XML_Parse.

Two of them are worth more than a cast, because the conversion runs the
other way and the declared return type cannot represent the error value:

  znzlib.c   znzread() and znzwrite() return size_t, and both do
      `return nread;` where nread is the int returned by gzread()/
      gzwrite(), documented in a comment as "returns -1 on error".  A
      size_t cannot hold -1: the caller receives SIZE_MAX.  Callers do
      detect it, because they compare the result against the number of
      bytes they asked for, but the comment describes a contract the
      function's type cannot honour.  The conversion is now explicit and
      the comment says what actually reaches the caller.

  nifti1_io.c  nifti_read_buffer() has the same shape, returning -1 from
      a size_t function on two failure paths.

Neither behaviour is changed here -- callers depend on the current
values -- but both are now visible rather than implicit.  Making these
functions report failure in a way their return type can express is a
separate change to their contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Last of five commits clearing -Wsign-conversion: 29 hunks that did not
fall into the allocation, copy or I/O groups.

  File offsets and positions.  znztell() returns a signed znz_off_t and
  its result is stored in size_t locals; iname_offset and the extension
  sizes are ints that take part in size_t arithmetic.

  Extension bookkeeping.  `remain -= len` in the four fill_cmd_string()
  and extension loops, where remain is size_t and len an int already
  known non-negative.

  Chained assignments.  `nbl->bsize = nbl->nbricks = 0` assigns an int 0
  through a size_t field; split into two statements, which is also
  clearer about the two different types.

  The ANALYZE orientation byte.  nifti_convert_nhdr2nim() reads it with
  `unsigned char c = *((char *)(&nhdr.qform_code));`, taking a signed
  char and widening it into an unsigned one -- so any value above 127
  arrives sign-extended.  Reading through unsigned char * instead gives
  the byte that is actually in the header.

  nifti_get_filesize() returned `(unsigned int)buf.st_size` from a
  function declared to return int, converting off_t to unsigned and then
  to signed.  Now a single cast to the declared return type.

With this the tree is free of -Wsign-conversion warnings under both gcc
16.1.1 and clang 22.1.8, in the default, USE_FSL_CODE and FSLSTYLE/PIGZ
configurations, and 92/92 tests pass in all six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
gdevenyi and others added 2 commits August 14, 2026 22:05
…ailure

znzprintf() built its output with vsprintf(), which has no bound:

    size = strlen(format) + 1000000; /* overkill I hope */
    tmpstr = (char *)calloc(1, size);
    ...
    vsprintf(tmpstr, format, va);

The comment is honest about what was protecting the buffer.  A single
%s argument longer than a megabyte, or a format expanding past it,
writes past the end of the allocation.  vsnprintf() with the size already
being computed turns that from an overflow into a truncation, and the
truncation is now reported rather than passing silently.

The same block also returns on calloc failure without calling va_end(),
which leaves the va_list unterminated.  Harmless on x86-64, undefined in
general, and free to fix.

This is the first of three commits on the string-handling functions, and
it is the one place in the tree where an unbounded formatted write
existed at all -- there is no sprintf() and no gets() anywhere, and 138
snprintf() calls, largely thanks to seanm's earlier work in upstream
PRs #5 and #19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
doPigz() and doPigz2(), in both nifti1_io.c and nifti2_io.c, assembled a
shell command in a 768-byte stack buffer with no bound at all:

    char command[768];
    strcpy(command, "pigz");
    strcat(command, " -n -f > \"");
    strcat(command, nim->fname);
    strcat(command, "\"");

nim->fname is whatever the caller set, so any path longer than about 750
characters writes past the end of a stack array, and the result is then
handed to popen().  PATH_MAX is 4096 on Linux, so this is reachable with
an ordinary deep directory, no adversary required.

All four call sites become one bounded snprintf(), which also refuses to
run a command that would have been truncated -- silently executing half a
redirect is worse than failing.

Upstream PR #11 addresses this by growing the buffer to 4096.  That helps
but does not fix it: 4096 is exactly PATH_MAX, the quoting and the "pigz
-n -f > " prefix push a maximum-length path over the limit, and the
strcat chain still has no bound. The fix here is the bound rather than a
larger constant.

This is the second of three commits on the string handling.  The other
fixed-size destinations in these files were checked and left alone,
because they are correct by construction and adding bounds would be
churn: extcopy[8] receives `name + len - 4` or `name + len - 7`, at most
7 characters plus the terminator, and the elist[] entries are the char[8]
extension buffers that hold ".nii" plus ".gz" exactly, which is what the
"(leave space for .gz)" comment on their declaration is about.

Only reachable in a build with -DFSLSTYLE=ON, which enables PIGZ;
verified there with both gcc and clang, 92/92.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Third of three commits on the string handling, and the one that decides
what to do about the remaining strcpy() and strcat() calls.

Upstream PR #24 replaces all of them with strlcpy()/strlcat() for
-fbounds-safety compatibility.  Having audited every call site to answer
that question, I do not think the wholesale rewrite earns its risk here:

  86 copy into a buffer allocated as strlen(source) + slack a few lines
     above -- nifti_makehdrname(), nifti_makeimgname(), nifti_strdup()
     and the rest of the filename construction.
  10 write into a fixed-size array that fits their contents exactly:
     extcopy[8] takes `name + len - 7`, the elist[] entries are the
     char[8] extension buffers whose declaration comment says "(leave
     space for .gz)", and nhdr.magic[4] takes a 3-character literal.
  The remainder are string literals into struct fields many times their
     length.

Rewriting 145 correct calls is 145 chances to get a length expression
wrong, in code whose test coverage compares output byte for byte.  PR #24
also has to define strlcpy and strlcat at global scope on glibc older
than 2.38, which claims identifiers the C library reserves, and its
`#if defined(__GLIBC__)` guard omits the fallback entirely on musl and
MSVC.

_FORTIFY_SOURCE gets the same protection without touching a call site.
The C library checks the destination size of every one of these functions
wherever the compiler can determine it, and aborts rather than writing
past the end.  Level 3, which gcc 12+ and clang 15+ support, also covers
sizes only known at run time, which is exactly the strlen(x) + n shape
this library uses.  Confirmed against the defect fixed in the previous
commit: the unbounded `strcat(command, nim->fname)` in doPigz() is caught
and terminated at level 3.

The level is chosen by compiling a test rather than by checking versions,
since both the compiler and the C library have to support it, and it is
applied only to the optimised configurations because glibc warns if it is
defined without optimisation.  NIFTI_ENABLE_FORTIFY_SOURCE=OFF disables
it.

This complements PR #24 rather than replacing it: fortification catches a
bad size at run time, while strlcpy and -fbounds-safety aim to make the
bound explicit at compile time.  If that PR lands, this stays useful.

Also documents the buffer FslGetVoxUnits() requires.  It writes
nifti_units_string() into a caller-supplied char * with no length
parameter -- the defect class upstream PR #21 is about.  The longest
value it can produce is "Unknown", so 8 bytes suffices; the signature is
exported and cannot gain a length parameter without breaking callers, so
the requirement is recorded at the function instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Enabling scan-build --status-bugs in CI needed the tree to be clean under
it.  It reported one finding, in axml_read_file()'s read loop, and it is
a real one:

    blen = (unsigned)fread(buf, 1, bsize, fp);
    ...
    done = blen < (unsigned)bsize;

A short read is the loop's only stopping condition, so an I/O failure is
indistinguishable from reaching the end of the file: the parse simply
stops early and the caller is handed whatever was parsed so far, with no
indication that the rest of the document was never read.  ferror() is now
checked and the failure reported.

The loop also called fread() once more after the file ended exactly on a
buffer boundary -- harmless, since it returns 0, but it is the read at
EOF the analyzer objected to.  Testing feof() as part of the stopping
condition removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
@gdevenyi
gdevenyi force-pushed the ci/enable-werror branch 2 times, most recently from 0bb0637 to 95db957 Compare August 15, 2026 02:38
@seanm

seanm commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Boy LLMs sure are good at generating text! But this is way too large to review. No one has even reviewed small PRs that have been open for months. Generating walls of text is easy these days, human review is in short supply.

...so upstream it has to arrive as one.

"has to"? I don't see why one PR per thing cannot be created.

As to all the formatting changes, even though I've advocated for them, I'm not actually convinced anymore it's a good idea, as it will make merging with upstream harder. At least, it's probably better to do them last, after fixing actual bugs, so that upstream, should it re-activate, can more easily cherry pick them.

At least the commits seem generally well divided. I suggest picking one commit, starting with the most important and/or simplest, and create a PR for it. Then human reviewers can digest it. Then create another PR, and repeat.

gdevenyi and others added 2 commits August 14, 2026 22:42
Enabling the "Build and Test" workflow earlier in this series was not
enough to make it work.  Its trigger was wrong -- it fired on `main` in a
repository whose default branch is `master` -- but fixing that only
revealed the second reason it had never run: every job invoked
cmake/travis_dashboard.cmake, which begins

    set_from_env(CTEST_SITE "TRAVIS_APP_HOST" REQUIRED)

and aborts with a FATAL_ERROR because GitHub Actions does not set that.
The script is a half-migrated hybrid: it wants TRAVIS_APP_HOST from
Travis and AGENT_BUILDDIRECTORY from Azure Pipelines, and the workflow
supplies only the latter.  All five jobs failed in about 20 seconds
having compiled nothing.  My earlier claim that fixing one line "turns on
all the analysis" was wrong, and this finishes the job.

Each job now configures and runs directly instead of going through the
dashboard script:

  coverage-gcc-linux    --coverage build, tests, lcov summary
  valgrind-gcc-linux    ctest -T memcheck, then a step that fails on any
                        non-zero ERROR SUMMARY or any definite or indirect
                        leak.  ctest -T memcheck exits 0 even when
                        valgrind reports defects, so the exit status
                        cannot be trusted on its own.
  use_prefix-gcc-linux  builds with NIFTI_PACKAGE_PREFIX set
  sanitize-clang-linux  ASan and UBSan with -fno-sanitize-recover, plus
                        scan-build --status-bugs
  rel-clang-macos       Release on macos-14

Two details that cost time to find and are recorded in comments so they
do not have to be found again: valgrind will not start without libc6-dbg,
and its memcheck needs --trace-children=yes or it inspects the shell
wrappers most of these tests use and reports a clean run having never
looked at the library.

This also drops the submission to my.cdash.org.  Posting results to the
project's public dashboard from every pull request, including ones from
forks, is not obviously wanted, and nothing else in the workflow needed
it.

Separately, install_linking has never been able to pass in CI, or in any
build directory that was not a sibling of a source tree named exactly
"nifti_clib".  The test script hard-coded

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

which resolves to <checkout>/nifti_clib/nifti_clib/... on a GitHub
runner.  The source directory is now passed in from CMake, the way the
other scripts in that directory already receive their arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
The project now builds clean under its full warning set with both gcc
16.1.1 and clang 22.1.8, so the warnings-as-errors switch added earlier
can be exercised in CI to keep it that way.  A new matrix job configures
with -DNIFTI_WARNINGS_AS_ERRORS=ON for each compiler, builds, and runs
the tests.

A clang-tidy job is added alongside it.  It reports findings without
failing the build: 278 remain across 12 checks, all of them real defect
classes rather than style noise, and they need fixing one class at a time
rather than in a single sweep.  Removing the "|| true" is the last step
of that work.

The workflow is renamed from Format to "Static checks" now that it runs
three related jobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
@gdevenyi

Copy link
Copy Markdown
Author

"has to"? I don't see why one PR per thing cannot be created.

I was trying to use https://docs.github.com/en/pull-requests/get-started/about-stacked-prs but it seems you can't do that for PRs on repos that aren't yours.

As to all the formatting changes, even though I've advocated for them, I'm not actually convinced anymore it's a good idea, as it will make merging with upstream harder.

OK, I'm dropping all the clang-format work.

@gdevenyi gdevenyi closed this Aug 15, 2026
@seanm

seanm commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

I was trying to use https://docs.github.com/en/pull-requests/get-started/about-stacked-prs but it seems you can't do that for PRs on repos that aren't yours.

It's quite a new feature I believe; perhaps not very capable yet.

It looks like there's a lot of nice stuff here, but it's just overwhelmingly huge.

I have the impression you are gungho on this new tool :), but perhaps a little more coordination is advisable before going too far too fast?

OK, I'm dropping all the clang-format work.

Maybe @hjmjohnson will also chime in on formatting changes... upstream still has no commits since 1.5 years, but there was a bit of activity in an Issue recently. If upstream is moribund forever, then perhaps this fork will essentially be the new upstream. I had hoped when creating this fork that this could be a proving ground, and eventually integrated into upstream too, but not sure that will ever be...

@gdevenyi

gdevenyi commented Sep 3, 2026

Copy link
Copy Markdown
Author

@seanm @hjmjohnson I rebased all these fixes, minus any formatting changes into individual decoupled PRs.

@seanm

seanm commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

@seanm @hjmjohnson I rebased all these fixes, minus any formatting changes into individual decoupled PRs.

I'm just back from vacation and will try to take a look at them little by little...

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.

Make indentation uniform; add .editorconfig file

2 participants