Skip to content

ENH: Make the implicit sign conversions explicit - #53

Merged
hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-sign-conversion
Sep 22, 2026
Merged

hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-sign-conversion

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 15, 2026 •

Copy link
Copy Markdown

Clears -Wsign-conversion across the library. There is a lot of it, so it is split into three commits by what the conversion is for: allocation, copy and I/O lengths, then file offsets and counters, then the remainder.

Most of it is casting an int dimension to size_t at the point of use so that a product is computed at 64 bits instead of overflowing at 32 and then being widened. Three of the changes are not cosmetic and are called out in the individual commit messages below: a stale (int) cast that reintroduced the very truncation a 2010 fix removed, a (unsigned int) cast on a value returned as int, and a subtraction done in size_t whose result is stored in an int.

ENH: Make the integer conversions in allocations, copies and reads explicit

152 of the -Wsign-conversion findings are an int or int64_t count
reaching a size_t parameter of malloc(), calloc(), realloc(), memcpy(),
memset(), strncpy(), znzread(), znzwrite() or fread().

Where a count is 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.

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.

Lines that upstream PRs #11, #21, #22, #23 and #24 also change are left
untouched throughout.

ENH: Make the remaining integer conversions explicit

The -Wsign-conversion findings that are not allocation, copy or I/O
lengths: file offsets and positions, extension bookkeeping, and a few
conversions that were wrong rather than merely implicit.

File offsets. znztell() returns a signed znz_off_t stored in size_t
locals; the znzseek() arguments are cast to znz_off_t so the arithmetic
is done at the width the function takes.

fslio's nvox. The product of seven int dimensions assigned to a size_t
field, widened per operand so it is computed at 64 bits.

The ANALYZE orientation byte. nifti_convert_nhdr2nim() read 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
arrived sign-extended. Reading through unsigned char * gives the byte
that is actually in the header.

expat's XML_Parse takes an int length while blen is unsigned; the
conversion is now at the call rather than hidden in the variable.

Lines that upstream PRs also change are left alone, including the
d3matrix and d4matrix allocations in fslio.c, which PR #22 rewrites.

ENH: Make the last implicit sign conversions explicit

This clears -Wsign-conversion for every file the open upstream PRs do
not touch. Most of it is casting an int dimension to size_t at the
point of use so a product is computed at 64 bits rather than
overflowing at 32 and then being widened.

Two of the changes are not cosmetic:

nifti_read_buffer() called
nifti_swap_Nbytes( (int)(ntot / nim->swapsize), ... )
although nifti_swap_Nbytes() takes a size_t count, and the comment
three lines above it says the count was widened in 2010 precisely
because it 'might not fit as int'. The cast put the truncation back:
swapping a data segment larger than 2 GiB passed a wrapped count and
left most of the image unswapped. The cast is gone.

nifti_get_filesize() returns int and documents -1 for error, but ended
with return (unsigned int)buf.st_size;, converting through unsigned
before narrowing. It now casts to the type it returns.

nifti_image_read() computed remaining = iname_offset - sizeof(nhdr)
in size_t and narrowed the result to the int it is stored in, so a
header offset below sizeof(nhdr) produced a huge unsigned value. The
subtraction is now done in int, which is the type of both the variable
and the value.

Left for their owners: the fslio allocations and volbytes in PR #22, the
znzwrite calls and nifti_tester001 in PR #24.

Three groups of sites are handled in sibling PRs of this set rather than here, so that each PR applies to master on its own: the modify_field() stores carry their casts inside #44's memcpy rewrite, FslSeekVolume()'s operands are widened in #52, and the rest of fslio's size arithmetic is #52's second commit. Where this PR and a sibling touch the same line -- the XML fread call, and the sbuf calloc that #50 reorders -- whichever merges second will need a one-line rebase.

Nine -Wsign-conversion warnings are deliberately left, all on lines an open PR rewrites: five in fsliolib/fslio.c (#22), two in nifti2/nifti2_io.c (#24), and two in nifti_tester001.c, in a function #24 restructures.


Interface impact: none. On the union of all these changes, configured with USE_FSL_CODE=ON and USE_CIFTI_CODE=ON: all 448 exported symbols across libniftiio, libnifti2, libznz, libfslio, libnifticdf and libcifti are identical to master under nm -D --defined-only, and all ten installed headers are identical under gcc -E -P. Under gcc -dM -E one macro definition differs, intentionally and only in text: #61 makes FSL_RADIOLOGICAL read (-1) so it is safe inside an expression. Its value is still -1, checked by compiling against each installed fslio.h and printing it.

Verification. This branch: builds with gcc 16.1.1, ctest unchanged from master (2 of 345 fail on master itself in this environment; #31 and #29 each fix one). The union of all the PRs: 0 errors under both gcc 16.1.1 and clang 22.1.8, ctest 345/345 under each, and the whole suite under valgrind memcheck with --trace-children=yes gives 484 traced processes with no invalid access, no uninitialised value and no leak in any nifti binary.

Coordination. Every line of every branch was compared, whitespace-normalised, against the diffs of the open PRs (#11, #21, #22, #23, #24). Where one of those already changes a line, the line was left alone, and the few deliberate overlaps are named in the text above. What survives is 17 compiler warnings, all of them on those lines: 9 -Wsign-conversion (5 in fslio.c for #22, 2 in nifti2_io.c and 2 in nifti_tester001.c for #24) and 8 -Wcalloc-transposed-args in nifti_findhdrname and nifti_findimgname, which #11 rewrites. No formatting changes appear anywhere, to stay clear of #10 and #12.

One of a set of independent, single-purpose PRs. Each bases on master and can be merged on its own, in any order.

The full set of PRs (35)

The union of all of them is on the fork as all-changes, if you want to build and test the lot at once.

CI and build

Configuration and documentation

Defects

Warning and check classes

This was referenced Aug 15, 2026
@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (b4876bf) and pushed as 545dde9, still three commits. I checked each of the changes the commit messages called "not cosmetic" against what the C conversion rules actually produce, and two of the three claims were wrong. The code is fine in all three cases; the messages were overstating, and are corrected.

The claim that holds: the swap count

nifti_read_buffer() called

nifti_swap_Nbytes( (int)(ntot / nim->swapsize), nim->swapsize, dataptr );

nifti_swap_Nbytes() takes a size_t, and the comment three lines above records that the count was widened in 2010 because it "might not fit as int". The (int) put the truncation back, so swapping a segment past 2 GiB passed a wrapped count and left most of the image unswapped. Removing the cast is a real fix. It is NOT-DEMONSTRABLE as a ctest: the observable needs a >2 GiB byte-swapped data segment, which means generating a file of that size in the test suite.

The two claims that do not hold, now reworded

The ANALYZE orientation byte. The message said unsigned char c = *((char *)(&nhdr.qform_code)); meant "any value above 127 arrived sign-extended". It did not. Converting a negative signed char to unsigned char is defined modulo 256, so c already held exactly the byte in the header. The change to *((unsigned char *)...) is value-preserving; it is worth making because it reaches the byte through a signed lvalue for no reason, not because it fixes a misread.

remaining = iname_offset - sizeof(nhdr). The message said a header offset below sizeof(nhdr) "produced a huge unsigned value". The subtraction does wrap in size_t — and is then stored into an int, which on a two's complement platform recovers exactly the value the int subtraction gives. No behavior change. Same for nifti_get_filesize()'s (unsigned int)buf.st_size becoming (int)buf.st_size.

Both messages now describe these as tidying rather than as fixes. Worth getting right: a reviewer who checks the ANALYZE claim and finds it false will start doubting the 189 lines they were not going to check.

What else changed in this update
  • Rebased onto master. One real conflict, in nifti_convert_nhdr2nim() (niftilib/nifti1_io.c): master has since added an overflow guard to the nvox loop this commit was casting inside. Resolved by keeping master's guard and putting the (size_t) cast on the multiply within it, so both the bound check and the widened multiply survive. nifti1_tool_reject_nvox_overflow and nifti1_tool_reject_volsize_overflow still pass.
  • Commit messages: dropped the Co-Authored-By: naming an AI tool and the Claude-Session: URL from all three; replaced the bare #11, #21, #22, #23, #24 cross-references with words, since this is a fork and those numbers name different pull requests here; behaviour -> behavior.
  • No source change beyond the conflict resolution. 189 added lines with and without -w, so no whitespace churn.
Build and test

Release, NIFTI_BUILD_APPLICATIONS=ON USE_NIFTI2_CODE=ON USE_CIFTI_CODE=ON USE_FSL_CODE=ON, Ninja, macOS/AppleClang: build clean apart from the pre-existing duplicate-library link warning, 100% tests passed, 0 tests failed out of 362.

@hjmjohnson
hjmjohnson force-pushed the pr/fix-sign-conversion branch 2 times, most recently from 4f7fa60 to dcf1ce4 Compare September 22, 2026 20:37

@hjmjohnson hjmjohnson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed both axes at max effort: conformance to the project's own standards, and
whether the diff does what the PR says it does. The nifti_swap_Nbytes change earns
this PR on its own
— removing (int)(ntot/swapsize) fixes a real truncation above 2 GiB.

Line comments below. One I would hold the merge for; the rest are tidying.

Two of the three "this is not cosmetic" claims do not hold

The ANALYZE orientation byte was not arriving sign-extended. The description says
"taking a signed char and widening it into an unsigned one, so any value above 127
arrived sign-extended."
Reading through *(char *)&... yields signed char, and the
conversion to unsigned char is defined modulo 256 — so 200 stays 200. The change is
value-preserving, which is fine, but it is tidying rather than a fix. A later comment on
this PR already concedes this; the body of commit dcf1ce4 still makes the original
claim
and should be corrected before merge, since the commit message outlives the PR.

The remaining wrap recovers the same value. int - size_t wraps in size_t and then
converts back to int, giving the same result on two's complement. Strictly the old
conversion was implementation-defined rather than undefined, so again: tidying.

The swap count is genuine. nifti_swap_Nbytes(size_t n, int size, void *ar) — the
(int) truncated a count above 2 GiB. That one is a real bug fix.

Worth correcting the claims rather than dropping the hunks: the changes are still
improvements, they are just not the defect fixes the text asserts.

What I checked and found clean

Both znzlib.c casts are value-preserving. remain -= (size_t)nread; is dominated by
if( nread < 0 ) return 0; two lines above, and nread <= n2read <= remain by
construction, so the cast cannot change the value and remain cannot underflow. Same for
nwritten. This is the only open PR touching znzlib.c, so I looked at it closely.

No whitespace churn: git diff -w reports the same 189 added lines as the plain diff.

The (size_t)nim->dim[c] and nx/ny/nz/nw widenings are correct, and the
invariants the commit messages cite do hold.

The nine deliberately-deferred warnings are declared and match what is left.

Commit bodies exceed the project's convention

All three run 28, 24 and 24 lines against a 12-line convention. The subjects are fine. The
reasoning is good material — it reads better in this PR body, where it is not carried in
git log forever.

A follow-up, out of scope here

(size_t)nx * (size_t)ny * (size_t)nz * (size_t)nbyper recurs in nifti1_io.c,
nifti2_io.c and fslio.c. A small nifti_voxel_count() helper would remove the
hand-widening at every site and give the invariant one home. Not for this PR.

Comment thread niftilib/nifti1_io.c Outdated
Comment thread niftilib/nifti1_io.c Outdated
Comment thread niftilib/nifti1_io.c Outdated
Comment thread niftilib/nifti1_io.c Outdated
Comment thread niftilib/nifti1_io.c Outdated
Comment thread niftilib/nifti1_io.c
Comment thread niftilib/nifti1_io.c Outdated
…plicit

152 of the -Wsign-conversion findings are an int or int64_t count
reaching a size_t parameter of malloc(), calloc(), realloc(), memcpy(),
memset(), strncpy(), znzread(), znzwrite() or fread().

Where a count is multiplied by a sizeof, the cast goes on the count
rather than around the product, so the multiply happens at 64-bit width:
(int)count * sizeof(T) can overflow before it is widened.

The counts cannot be negative. nifti_update_dims_from_array() clamps
every dim[i] to at least 1, nifti_datatype_sizes() leaves nbyper at 0
for an unknown datatype, and nifti_image_load() requires nbyper > 0 and
nvox > 0.
The findings that are not allocation, copy or I/O lengths: file offsets,
extension bookkeeping, and the ANALYZE orientation byte.

znztell() returns a signed znz_off_t stored in size_t locals, so the
znzseek() arguments are cast to znz_off_t and the arithmetic happens at
the width the function takes. fslio's nvox is widened per operand so the
product of seven int dimensions is computed at 64 bits. XML_Parse() takes
an int length while blen is unsigned; the conversion is now at the call.

The ANALYZE orientation byte is read through unsigned char * rather than
a signed lvalue. That is value-preserving either way -- signed to
unsigned char is defined modulo 256 -- so it states the intent only.
One change is a fix. nifti_read_buffer() passed
(int)(ntot / nim->swapsize) to nifti_swap_Nbytes(), which takes a size_t
count that was widened in 2010 because it "might not fit as int". The
cast reinstated that truncation, leaving most of an image above 2 GiB
unswapped. The cast is gone.

nifti_image_read() took the file size through (size_t), which turns
nifti_get_filesize()'s -1 error return into SIZE_MAX and slips past the
guard below it. A signed temporary is used instead, matching what
nifti2_io.c already does at the same place.

The rest casts an int dimension to size_t at the point of use.
@hjmjohnson
hjmjohnson force-pushed the pr/fix-sign-conversion branch from dcf1ce4 to 3adf034 Compare September 22, 2026 22:39
@hjmjohnson

Copy link
Copy Markdown
Member

Force-pushed as 3adf034 with every review point applied, and the reasoning moved
out of the commit bodies into the threads above. One of my own comments was wrong
and is withdrawn — details in that thread.

The diff got smaller: 227 added / 242 deleted, down from 252 / 382, with zero
whitespace-only lines. CI is green at 26/26.

What changed, by finding
Finding Resolution
nifti_get_filesize() -1 becoming SIZE_MAX signed temporary; now matches nifti2_io.c
(int64_t)sizeof(float/double) in nifti1 cast dropped (ntot is size_t there)
(size_t)sizeof(...) and calloc((size_t)1, ...) dropped at 7 sites across 4 files
(size_t)(nim->nvox) in nifti1 dropped (nvox is already size_t there)
,(size_t) spacing normalised at all 64 sites
(int)sizeof(...) for remaining / offset left as the PR had it — my suggestion was wrong
NBL->bsize / nbl->bsize statement splits reverted to the original single statements
commit bodies 26 / 26 / 30 lines now 12 body lines each
The nifti1/nifti2 pair settled two findings

nifti2_io.c has always written the filesize check the way this PR now writes it
in nifti1 — ii is int64_t there and the guard is if( ii <= 0 ). So the fix is
nifti1 catching up to its sibling rather than a new invention.

The same pairing caught an error in my own review. (int64_t)sizeof(float) is
redundant in nifti1, where ntot is size_t, and necessary in nifti2, where
ntot is int64_t. Dropping it in both would have introduced exactly the
conversion this PR removes, so nifti2_io.c:6945 and :6957 are deliberately
untouched. (size_t)(nim->nvox) at nifti2_io.c:7436 stays for the same reason.

Verification, including downstream ITK

Locally, USE_CIFTI_CODE=ON USE_FSL_CODE=ON FSLSTYLE=ON: 370/370 tests, zero
compiler diagnostics
. The three commits keep their original author and dates.

Because ITK vendors this code, the series was also injected into an ITK checkout
and built there. ITK compiles only nifti1_io.c and znzlib.c — it force-sets
USE_NIFTI2_CODE, USE_FSL_CODE, USE_CIFTI_CODE, USE_NIFTICDF_CODE,
NIFTI_BUILD_APPLICATIONS and NIFTI_BUILD_TESTING to OFF — so the
nifti1_tool.c hunks are vendored but never built, and the nifti2 / cifti /
fsliolib hunks are not in ITK at all.

  • ITK NIFTI test suite: 51/51 passed
  • libITKniftiio / libITKznz: built with no warnings
  • Exported symbols unchanged except doPigz, which gains internal linkage; it is
    declared in no header and has no consumer anywhere in ITK, so this narrows an
    accidental export rather than breaking anything

@hjmjohnson
hjmjohnson merged commit 2696f66 into InsightSoftwareConsortium:master Sep 22, 2026
26 checks passed
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.

2 participants