Conversation
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
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
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
6731d90 to
a7ac11d
Compare
…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
a7ac11d to
9780de9
Compare
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
9780de9 to
cab3532
Compare
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
0bb0637 to
95db957
Compare
|
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.
"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. |
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
95db957 to
e24ecfe
Compare
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.
OK, I'm dropping all the clang-format work. |
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?
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... |
|
@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... |
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
nifticdf(see the caveat below).clang-format, the reformat,.git-blame-ignore-revs.clang-tidy-WerrorReal defects fixed, not just warnings silenced
MIM_disp_funcs[mind]guarded byif (kid >= 0)inaxio_show_mim_summary().kidis the loop counter and never negative, so the guard is always true;get_map_index()returns-1for any element name not inMIM_kids[]. A CIFTI file with an unrecognised element under MatrixIndicesMap reads a function pointer from before the table and calls it.int offsetinFslSeekVolume()holds a byte position into the image file. Any volume past 2GB overflows it, andznzseek()takes a 64-bit offset.nifti_findhdrname()under-DFSLSTYLE:basenameis freed, then read by thefprintfthat reports the ambiguous-filename error. Same block had an uncheckedcalloc, a leak ofhdrname, andexit(134).nifti_image_read()under-DREJECT_COMPLEXtestednim->datatypebefore theif (nim == NULL)check immediately below it.modify_field()— 8 sites writing through((short *)((char *)base + offset))whereoffsetis a byte offset into a packed header. Nowmemcpy.iandjuninitialised innifti_mat44_to_orientation()— set only inside aswitchwhosedefaultisassert(0), which disappears underNDEBUG.kwas already fixed; the other two were missed.cifti_toolthat never advanced its pointer.FslReadSliceSeries()andFslReadRowSeries()— both divide byswapsize, whichnifti_datatype_sizes()sets to 0 forDT_UINT8,DT_INT8andDT_RGB24. Nothing stops a header declaring an 8-bit datatype with the opposite byte order.fslio's size arithmetic —size_t volbytes = xdim * ydim * zdim * wordsize;andsize_t FslGetVolSize(...) { return nx * ny * nz; }do the multiply asintand widen only the result, so the destination type bought nothing past 2GB.znzread(),znzwrite()andnifti_read_buffer()return-1fromsize_tfunctions, so an error reaches the caller asSIZE_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.doPigz()/doPigz2()— a shell command is built inchar command[768]withstrcat(command, nim->fname)and handed topopen().PATH_MAXis 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.vsprintf()inznzprintf(), into a buffer sizedstrlen(format) + 1000000with the comment/* overkill I hope */. Nowvsnprintf, and theva_listis 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:-DNIFTI_WARNINGS_AS_ERRORS=ONAlso: 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-recoveris 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 bothnifti_tester001.c.o, whosePrintTestmacro 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 -Dover all five shared libraries, the preprocessed installed headers, andgcc -dM -Eover every installed macro. Across all 32 commits the only macro text that differs isFSL_RADIOLOGICAL, which becomes(-1)instead of-1; its value is unchanged, verified by compiling against the installed header.This is why
misc-use-internal-linkageis disabled in.clang-tidywith 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.handnifti_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 ofnifticdf.crather than linking the library, and ITK vendors onlyniftilibandznzlib. It does affect anyone linking-lnifticdfor using theNIFTI::nifticdfCMake target. Say the word and I will drop that commit and rebase the rest.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.-Wsign-conversionis opt-in rather than fixed.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()rejectsdim[0]outside [1,7] and clamps everydim[i] < 1up to 1,nifti_datatype_sizes()leavesnbyperat 0 for an unknown datatype and every caller checks it, andnifti_image_load()refuses to proceed unlessnbyper > 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
.clang-formatplus.editorconfig, uniform indentation, enforced by CI..clang-formatwith six documented overrides.exit()removal that is the point of ENH: code review of recent nifti_image_conflicted_names PR #11.On the string functions (upstream PR #24)
I audited every
strcpy/strcatcall rather than replacing them wholesale. Two were genuine defects and are fixed above. The other 145 are correct: 86 copy into a buffer allocated asstrlen(source) + slacka few lines above, 10 write into a fixed array that fits exactly (extcopy[8]takesname + len - 7; theelist[]entries are thechar[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-knownstrlen(x) + nsizes this library uses — and aborts rather than overflowing. Verified it catches thedoPigzdefect above.That complements PR #24 rather than replacing it: fortification catches a bad size at run time,
strlcpyand-fbounds-safetyaim to make the bound explicit at compile time. If #24 lands, this stays useful. Note also that #24 definesstrlcpy/strlcatat 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.