Skip to content

BUG: Stop returning -1 from functions that return size_t - #43

Merged
hjmjohnson merged 2 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-size-t-error-returns
Sep 22, 2026
Merged

hjmjohnson merged 2 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-size-t-error-returns

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 15, 2026 •

Copy link
Copy Markdown

znzread(), znzwrite() and nifti_read_buffer() all return size_t and all
returned -1 to report an error. In a size_t that value is SIZE_MAX,
which is larger than any length a caller can have asked for, so every
caller that tests the result with '<' reads the error as a complete
transfer:

ii = nifti_read_buffer(fp, nim->data, ntot, nim);
if( ii < ntot ){ ... }              /* SIZE_MAX < ntot is false */

The visible effect is that a truncated image loads as if it were whole.
Reading a NIfTI file 1000 bytes short of its declared data size, on
master:

++ WARNING: nifti_read_buffer(short.nii):
   data bytes needed = 361284
   data bytes input  = 360284
   number missing    = 1000 (set to 0)
ACCEPTED nvox=90321 data=0x7f2f502eb010

nifti_image_read() prints the warning, ignores the failure and hands
back an image whose last 1000 bytes are uninitialised heap. With this
change the same file gives:

REJECTED (nifti_image_read returned NULL)

FslReadVolumes() had the same problem one level up: it divides the
returned byte count by the volume size, so a failed read was reported to
its caller as SIZE_MAX/volbytes volumes successfully read.

The fix is to return 0, which is the value every one of these callers
already treats as failure, and the only value a size_t function has for
'nothing was transferred'. The return types are published in
znzlib.h and nifti1_io.h and are unchanged.

The equivalent nifti2 functions return int64_t, where -1 is
representable, and are left alone.


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 master b4876bf, commit message cleaned, and a red-green regression test added.

Before reviewing the diff, please read the risk note below. This is a silent semantic change to three published library functions, which under this project's ABI/API policy is the most expensive category of change there is. The in-tree evidence says it is right, but the out-of-tree cost is real and cannot be measured from here.

The risk, stated plainly

znzread(), znzwrite() and nifti_read_buffer() keep their published size_t return types, so every existing call site still compiles, and some of them silently change meaning. There is no compiler diagnostic anywhere. The direction of the change differs by caller shape:

Caller shape Before After
if( n < ntot ) error missed (SIZE_MAX < ntot is false) error caught
if( n != ntot ) error caught error caught
(int)znzread(...) then compare error caught ((int)SIZE_MAX is -1) error caught
if( n == (size_t)-1 ) error caught error missed
if( (ssize_t)n < 0 ) error caught error missed

The bottom two rows are the hazard. A downstream consumer that wrote its check against the documented-by-observation -1 sentinel keeps compiling and stops detecting failures. nifti_clib is vendored into ITK, AFNI, FSL, dcm2niix and Slicer, and I have no way to scan them from this branch.

In-tree blast radius, counted

nifti_read_buffer() (nifti1, size_t) — 6 call sites:

  • 4 test with != (nifti1_io.c:842, :880, :7299, :7355) — unchanged outcome
  • 1 tests with < ntot (nifti1_io.c:5009, the nifti_image_read data path) — this is the one that was broken, and the test below pins it
  • 1 divides the result (fsliolib/fslio.c:854, FslReadVolumes) — reported SIZE_MAX/volbytes volumes read on failure, now reports 0

znzread() — 20 in-tree call sites. Every one that checks either casts to int first or compares with !=, so none changes outcome. The (int) casts are what made those sites work by accident; they are now correct for a different reason. znzwrite() — 9 call sites; gzwrite() already returns 0 on error, so that hunk is purely defensive.

No nifti2 code is touched: its equivalents return int64_t, where -1 is representable and meaningful.

Test and red proof

niftilib/nifti_short_read_test.c, registered as ctest nifti_short_read_rejected. It writes a 31x31x31 DT_FLOAT32 image, copies it 1000 bytes short, and asserts nifti_image_read() returns NULL for the copy — plus a companion assertion that the untruncated original still reads back with data, so a blanket refusal cannot pass.

With the nifti_read_buffer() short-read hunk reverted:

1: ++ WARNING: nifti_read_buffer(short_read_short.nii):
1:    data bytes needed = 119164
1:    data bytes input  = 118164
1:    number missing    = 1000 (set to 0)
1: FAILURE: truncated image was accepted, nvox=29791
1/1 Test #1: nifti_short_read_rejected ........***Failed    1.54 sec

That is the defect in the PR description reproduced exactly: the warning prints, the failure is ignored, and the caller gets an image whose last 1000 bytes are uninitialized heap. With the fix the same file is rejected and the test passes.

Other changes

Removed the Co-Authored-By: naming an AI tool and the Claude-Session: URL. Trimmed the two added comments in znzlib.c from four lines to one each, per the project's comment rules; the reasoning they carried is in the commit message, which is where it belongs.

Build: -DNIFTI_BUILD_APPLICATIONS=ON -DUSE_NIFTI2_CODE=ON -DUSE_CIFTI_CODE=ON -DUSE_FSL_CODE=ON. 363/363 pass. No whitespace churn.

What I would ask of the maintainer

If you want this to land safely rather than quietly, the alternative worth weighing is an additive one: leave the three size_t functions alone and give callers a new checked entry point, deprecating the old on a major version. That costs downstream nothing and cannot silently change anyone's error handling. It is more work and it leaves the current defect in place for a release cycle, so it is a judgment call — but it is the option the ABI/API policy points at, and this PR should not be merged on the strength of "the tests still pass."

gdevenyi and others added 2 commits September 22, 2026 10:33
znzread(), znzwrite() and nifti_read_buffer() all return size_t and
all returned -1 to report an error.  In a size_t that value is
SIZE_MAX, larger than any length a caller can have asked for, so a
caller that tests the result with '<' reads the error as a complete
transfer:

    ii = nifti_read_buffer(fp, nim->data, ntot, nim);
    if( ii < ntot ){ ... }          /* SIZE_MAX < ntot is false */

The visible effect is that a truncated image loads as if it were
whole: nifti_image_read() prints its short-read warning, ignores the
failure and returns an image whose tail is uninitialized heap.
FslReadVolumes() has the same problem one level up, dividing the
returned byte count by the volume size to report SIZE_MAX/volbytes
volumes read.

These now return 0, which every one of these callers already treats
as failure and which is the only value a size_t function has for
"nothing was transferred".  The published return types in znzlib.h
and nifti1_io.h are unchanged.

The nifti2 equivalents return int64_t, where -1 is representable,
and are left alone.
Writes a 31x31x31 float image, copies it 1000 bytes short, and asserts
nifti_image_read() returns NULL for the copy and a populated image for
the original, so a blanket refusal cannot pass.  Without the fix the
truncated file comes back accepted with nvox=29791 and an
uninitialized tail.
@hjmjohnson
hjmjohnson force-pushed the pr/fix-size-t-error-returns branch from aa39d73 to d4bd5a4 Compare September 22, 2026 15:42
@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (63b361a); CI is 25/25 including both Windows legs, which this PR had not previously been run against.

Maintainer decision recorded: external consumers are not assumed to depend on the current behavior, so this is cleared to merge.

The change, stated precisely

Three size_t-returning functions reported failure as -1, which in a size_t is SIZE_MAX. They now report 0:

  • nifti_read_buffer() — NULL dataptr, and short read
  • znzread() — gzread error
  • znzwrite() — gzwrite error

NIFTI-2's nifti_read_buffer() returns int64_t, where -1 is a legitimate value, and is deliberately untouched.

The effect on a caller depends on how it tests the result:

Caller pattern with -1 with 0
ret != expected detects detects
ret < expected misses — SIZE_MAX is never < detects
(ssize_t)ret < 0 detects misses
ret == (size_t)-1 detects misses
Blast radius, measured rather than argued

Downstream native callers: none. Searched the four major vendors for direct calls to the three functions:

Consumer Native call sites
ITK 0 — one mention, in a comment
AFNI 0 — every hit is inside its vendored src/nifti/ copy of this library
dcm2niix 0
Slicer 0

In-tree: 37 call sites classified, 0 broken.

Verdict Count
would silently stop detecting errors 0
compares against the expected count, unaffected 27
result unchecked 8
propagates to its own caller 2

The change repairs a latent defect rather than risking one. At nifti1_io.c:5268:

ss = nifti_write_buffer(fp, NBL->bricks[bnum], NBL->bsize);
if( ss < NBL->bsize ){        /* SIZE_MAX < bsize is false: never fired */

Under the old return that test could not fire on error. It fires correctly now. The read side has the same shape.

nifti_write_buffer() already returned 0 for its own error, so propagating 0 from znzwrite() makes the value consistent with the contract that function already had.

Why the old sentinel was not a usable contract

SIZE_MAX is the worst available sentinel for a byte count: it compares greater than every valid length. Any caller written the natural way — comparing the result against the number of bytes it asked for — was already failing to detect errors, silently, which is the defect above.

Detecting it required either casting to a signed type first or comparing against (size_t)-1 exactly. Both are unusual, neither appears in this tree, and neither appears in the four vendors checked.

Test and red proof

nifti_short_read_rejected writes a well-formed .nii, truncates its data section by 1000 bytes, and asserts the read is refused rather than returning an image with an uninitialized tail.

Reverting only the short-read return in nifti_read_buffer():

GREEN   100% tests passed, 0 failed out of 363
RED      99% tests passed, 1 failed out of 363
          1 - nifti_short_read_rejected (Failed)

@hjmjohnson
hjmjohnson merged commit 9b31abf into InsightSoftwareConsortium:master Sep 22, 2026
25 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