Skip to content

test(dng): close the crate's first mutation survey - #495

Merged
justin13888 merged 10 commits into
masterfrom
fix/110-dng-survivors
Sep 2, 2026
Merged

test(dng): close the crate's first mutation survey#495
justin13888 merged 10 commits into
masterfrom
fix/110-dng-survivors

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Refs #110.

gamut-dng's first mutation survey: 1796 caught, 23 survivors, 3 timeouts. All 23 are closed
here — 20 by test, 1 by rewriting an equivalent mutant out of existence, and 2 by exclusions that
carry their proof.

One shape accounts for most of them: a rejection path with no file that reaches it

The crate validates carefully and the happy paths are well covered, so nearly every survivor sits
on the refusal side of a guard.

guard what the mutant admitted
WhiteLevel count is spp or 1 every wrong count, silently broadcasting v[0]
BlackLevelRepeatDim has 2 values any other count, reading the first two and ignoring the rest
repeat rows/cols > 0 a zero dimension, giving a cell count of nothing
floats is non-empty an empty FLOAT array
BlackLevelDeltaV length and finiteness a delta failing one condition but not both

Two of those needed two fixtures rather than one, and that is the part worth stating:
white_level_value refuses a level that is fractional or past u32::MAX, and a value tripping
one disjunct does not trip the other — 100.5 kills the ||&& mutant, u32::MAX + 1 kills the
>== mutant, and neither closes both. Same for BlackLevelDeltaV.

Boundaries where both answers are legal

black_level_value and white_level_value pick SHORT or LONG by width, and every DNG reader
accepts either — so nothing but the file's length records which was chosen. Both boundaries are now
pinned at the two straddling values, the same blind spot gamut-tiff's dim_value had.

The RATIONAL grid's bound is sharper than it looks: a fractional level is stored as (v * 65536).round() / 65536, so exactly 65536 needs a numerator of 2³² — one past u32::MAX, where
the as u32 cast wraps. The bound has to be exclusive. Reaching that arm also needs a mixed
slice, since 65536 alone is integral and is taken by the LONG arm above.

A compatibility path that only a comment vouched for

The decoder falls back to IFD 0 when the raw IFD carries no NoiseProfile — "some writers put it
there instead" — guarded by raw_index != 0. Every fixture put the tag where the spec says, so
inverting that guard disabled the fallback in exactly the case it exists for, and left it firing
only where it is a no-op. These are the easiest gaps to leave open, because the code reads as
though the comment were the evidence.

The archival verdict, again

DeconstructReport::is_fully_classified survived replacement by true. Unlike gamut-tiff's copy
of this defect the wrapper is called here — but every call expects true, so the suite
pinned that clean files are clean and never that a broken one is not.

The fixture transferred from gamut-tiff, including the negative result: trailing junk does not
work, because the walk classifies it as a trailer (and trailing_junk_is_classified_as_a_trailer
already relies on that). A strip declared past EOF is out_of_bounds, one of the five conditions
the verdict is built from.

MD5, and one fix made ahead of the survey

RFC 1321 prints F as (b & c) | (!b & d), whose halves are disjoint by construction, so | and
^ agree on every input. Both F and G are now their standard XOR forms. Only F was reported;
G has identical structure, and a later shard duly reported it — fixing the pair on sight is the
point of recognising the shape. This is the twelfth instance of disjoint-lane | in the workspace
and the fifth crate.

A hash is the last place to trust "obviously equivalent" algebra, so this leans on the RFC 1321
vectors already in the module rather than on that argument: they pass unchanged, and the operator
mutations of the new forms are each confirmed caught.

Two exclusions, both optimisations rather than behaviour

  • x_factor's Predictor::None arm. validate returns early for that predictor above its
    x_factor(...).is_none() check, and the other call site filters f > 0, where Some(0) and
    None take the same path. The arm stays rather than being deleted: it keeps the match total, and
    if that early return ever moved, _ => None would misreport the ordinary no-predictor case as a
    floating-point one.
  • undo's channels == 0 || group_cols < 2 early return, and lossless JPEG's pt > 0 skip. In
    both, the guarded work is already a no-op — an empty channels..row_step range, and <<= 0
    respectively — so the mutant does strictly more work for byte-identical output.

Validation

cargo test -p gamut-dng --all-features clean, mise run fmt-check, mise run check-tests and
clippy clean. Every mutant was applied by hand and confirmed caught. That caught a flaw in one
of my own tests: the repeat-dim fixture used [1, 0], which exercises only the column guard, and
the row mutant survived — rows and columns are filtered separately, so one zero cannot stand in for
the other. Both orientations are asserted now.

Note on completeness

Four shards (7, 8, 11, 12) were truncated — one by the filesystem hitting 100%, three by a
per-shard timeout that was too tight for this crate under load. They are queued for re-run, so 23
is a floor
. Any further survivors will follow in a separate PR rather than holding this one.

`decode_levels` accepts WhiteLevel at exactly two counts -- one value per sample
plane, or a single value it broadcasts -- and rejects everything else. Both
accepted counts were tested and the rejection was not, so relaxing the
`v.len() == 1` guard to `true` swallowed every wrong count into the broadcast
arm, silently reading `v[0]` and ignoring the rest. Two values across three
planes is neither count, and now fails.

The scaffolding for these fixtures is long -- encode the sample LinearRaw image,
pull its strip out, re-emit a minimal single-IFD file in two passes to learn the
IFD's size, append the data -- and all of it is identical between the two tests.
It moves into `rebuilt_with_raw_ifd`, which takes a closure to mutate the raw
IFD; what remains in each test is the tags it writes and what it expects.

Refs #110
`floats` guards on `!v.is_empty()`, and only the well-formed and wrong-type
cases were tested. Relaxing the guard to `true` let an empty array through as
`Some(vec![])`, and downstream that is not inert: `tone_curve` chunks it into
zero points, finds the "strictly increasing" condition vacuously true, and
returns an empty curve rather than none.

Asserted from both sides, because a `floats` that always returned `None` would
satisfy the rejection on its own.

Refs #110
Deleting `Predictor::None => Some(0)` drops it to `_ => None`, and neither
caller can tell those apart.

`validate` returns early at `if predictor == Predictor::None`, several lines
above its `x_factor(predictor).is_none()` check, so the one site that would
distinguish `None` from `Some(_)` is unreachable with that predictor -- it would
otherwise report it as a floating-point predictor. The other site is
`x_factor(predictor).filter(|&f| f > 0)`, where `Some(0)` and `None` both become
`None` and take the same early return. Equivalent on every reachable call.

The arm stays rather than being deleted, which is the choice worth defending.
Deleting it would remove the mutant too, but it would also make the function's
doc false -- `Predictor::None` really does difference against zero pixels to the
left -- and it would leave a trap: if that early return ever moved, `_ => None`
would silently misreport the ordinary no-predictor case as a floating-point one.
Same shape and justification as the defensive clamps already excluded here.

Verified against `--list`: only this arm goes, while the other three arms and
all three whole-function replacements stay in the survey.

Refs #110
RFC 1321 prints F as `(b & c) | (!b & d)` and G as `(d & b) | (!d & c)`, and the
two halves of each are disjoint by construction: where `b` is set the second
term is zero, where it is clear the first is. So `|` and `^` agree on every
input, and the printed form carries a mutant no test can kill.

Both become the standard XOR formulations -- `d ^ (b & (c ^ d))` and
`c ^ (d & (b ^ c))` -- which are the same function with no such twin.

Only F was reported as a survivor, from the shard that happened to run first.
G has the identical structure and would have come back as one later; fixing the
pair now rather than waiting for the survey to find the second is the point of
recognising the shape.

This is the twelfth instance of disjoint-lane `|` in the workspace and the fifth
crate, after gamut-tiff, gamut-deflate, gamut-avif and gamut-png.

A hash is the one place to be careful about "obviously equivalent" algebra, so
this leans on the RFC 1321 vectors already in the module rather than on the
argument above: they pass unchanged, and the three operator mutations of the new
forms are each confirmed caught.

Refs #110
… count

Three more survivors, all the same omission in different places: a rejection
path with no fixture that reaches it.

`white_level_value` refuses a level that is fractional *or* past `u32::MAX`, and
had only ever been handed values that pass, so neither disjunct was exercised.
Relaxing the `||` to `&&` accepted 100.5, and narrowing the `>` to `==` accepted
`u32::MAX + 1`. Each needs its own case: a value that trips one does not trip
the other, so one fixture cannot close both.

Its SHORT/LONG choice is pinned too, at the two values that straddle the
boundary. Both widths are legal DNG and every reader takes either, so nothing
but the file's length records which was chosen -- the same blind spot as
gamut-tiff's `dim_value`.

`BlackLevelRepeatDim` is `(rows, cols)`, so any other count is malformed, and
only the well-formed and absent cases were tested. Relaxing `v.len() == 2` to
`true` read the first two values and ignored the rest rather than refusing the
file.

Refs #110
…ards

Five more survivors, four closed by test and one by an exclusion that carries
its proof.

`Compression::is_lossy` could be replaced by either constant, so nothing called
it. It decides which compressions route through the compressed-chunk digest
path, and closing it needs cases on both sides.

`unsigned_f64s` accepts the three types `BlackLevel` allows, but only `SHORT`
and `RATIONAL` were ever reached, so deleting the `LONG` arm dropped it to
`_ => None` with the suite green -- a black level stored as `LONG`, which the
spec permits, would have been refused as a bad type.

`black_level_value` has its own copy of the `<= u16::MAX` SHORT/LONG comparison,
tested now at the same two straddling values as its `white_level_value` twin.

`BlackLevelRepeatDim` filters rows and columns for non-zero, and neither guard
had a fixture. Both orientations are asserted, which the first version of this
test did not do: it set `[1, 0]`, exercised only the column guard, and the row
mutant survived the check. Rows and columns are filtered separately, so one zero
cannot stand in for the other.

The exclusion is lossless JPEG's `if pt > 0` around the point-transform upshift.
`pt` is a `u16`, so `pt >= 0` is unconditionally true and the loop it guards then
runs with a shift of zero -- `*sample <<= 0` leaves every sample exactly as it
was. The mutant does strictly more work for byte-identical output, so no test
can separate them: an optimisation, not a behaviour.

Refs #110
The decoder falls back to IFD 0 when the raw IFD carries no `NoiseProfile`,
guarded by `raw_index != 0` so it does not pointlessly re-read the same
directory. Every fixture in the suite put the tag where the spec says, so
inverting that guard to `== 0` disabled the fallback in precisely the case it
exists for, and left it firing only where it is a no-op. Nothing failed.

The fixture builder already assembles a two-directory file, so it takes a
`NoiseAt` argument rather than gaining a second copy; the existing entry point
keeps its signature and its meaning.

Worth naming what this one is: not a missing boundary or an unreachable branch,
but a documented *compatibility* path -- "some writers put it in IFD 0 instead"
-- with no file that exercises it. Those are the easiest to leave untested,
because the code reads as though the comment were the evidence.

Refs #110
`BlackLevelDeltaV` must carry one finite value per active-area row, and only
well-formed deltas were ever passed. Relaxing the guard's `||` to `&&` then
accepted a delta that failed one condition but not both. Both malformations are
asserted -- wrong length, and a NaN at the right length -- because either alone
leaves the other disjunct unexercised.

Its `BlackLevelDeltaH` twin, checked immediately above it in the same function,
was already covered; only the V side had no fixture.

The `black_level_value` LONG-arm comparison also came back a survivor from the
same shard, but it is already dead: the SHORT/LONG test added earlier in this
branch covers it. Confirmed by applying the mutant rather than assuming, since a
survivor list produced against master says nothing about a branch that has moved
since.

Refs #110
`is_fully_classified` survived replacement by `true`. Unlike gamut-tiff's copy of
this defect, the wrapper *is* called here -- but every call expects `true`, so
the suite pinned that clean files are clean and never that a broken one is not.
A constant `true` satisfies all of them.

The fixture is the one that worked for gamut-tiff: a strip declared at offset
100000 in a file of a few hundred bytes. The walk can name the segment but not
place it, which is `out_of_bounds`, one of the five conditions the verdict is
built from. Trailing junk does not work -- the walk classifies that as a trailer,
and `trailing_junk_is_classified_as_a_trailer` above already relies on it.

Also excluded here: `undo`'s `if channels == 0 || group_cols < 2 { return; }`
relaxed to `&&`. `factor` reaches that line only through
`x_factor(predictor).filter(|&f| f > 0)`, and in every case the guard skips, the
loop it guards is already empty -- `row_step = group_cols * channels`, and the
body runs `for i in channels..row_step`, an empty range whenever
`group_cols < 2` or `channels == 0`. The mutant does strictly more work and
writes exactly the same bytes: an early return for speed, not for behaviour.

Refs #110
…grid

A fractional black level is stored as `(v * 65536).round() / 65536`, so a value
of exactly 65536 would need a numerator of 2^32 -- one past `u32::MAX`, where
the `as u32` cast wraps. The bound has to be exclusive, and relaxing `<` to `<=`
admits precisely the value whose numerator does not fit.

Reaching that arm at all needs a *mixed* slice. 65536 on its own is integral and
is taken by the LONG arm above, so it never gets to the fractional path; the
fixture pairs it with a 0.5 that forces the branch.

Refs #110
@justin13888
justin13888 merged commit 77f61f9 into master Sep 2, 2026
8 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.

1 participant