Testing & Robustness¶
Developer note. A running map of test coverage, decoder-robustness guarantees, and roadmap items — derived from the issue trackers and fuzzing harnesses of the tools
fqxvreimplements (htscodecs for rANS Nx16, the fqzcomp quality model, and the name tokenizer; fqzcomp, SPRING, and PgRC/PgRC2 for reordering) plus a local robustness probe of our own decoders. Each item cites the upstream bug or behaviour it guards against.
Status legend: ✅ done · ▶ recommended next · ○ backlog.
What already exists¶
Every codec crate has inline #[test] round-trips plus at least one proptest!
block; fqxv/src/container/tests.rs has integration coverage (pairing,
interleaving, single-cell, reorder modes, +-normalization, truncation), and
crates/fqxv/tests/ adds a corruption harness and the golden-archive fixtures
below. All decoders return Result and guard header/length reads with
Malformed. CI (.github/workflows/ci.yml) runs check, nextest, doctests, fmt,
clippy, an MSRV-1.95 check, the Python bindings, and the cross-release compat
job. The gaps below are where round-trip-only testing structurally can't reach:
malformed input, degenerate distributions, and cross-tool edge cases.
0. Cross-release compatibility (what self-comparison can't reach)¶
Every other test in this document compares a build against itself. The
round-trips, the proptests, the fuzz targets, the thread-determinism
byte-comparisons, the accession corpus: each one encodes and then decodes with the
same binary. All of them are therefore invariant to any change that moves the
encoder and the decoder together — and that is precisely the change that breaks
archives already on disk. Reordering context-model initialization in fqxv-seq,
altering rANS frequency normalization, repacking a fqzcomp context: each
round-trips perfectly, passes the whole suite, and leaves every existing .fqxv
either rejected or silently misdecoded. Two mechanisms close that hole.
- ✅ Golden archive fixtures.
crates/fqxv/tests/fixtures/holds small.fqxvfiles written by an earlier release, withmanifest.tsvrecording what each must decode to (byte length + CRC-32C of the decoded stream; the expected plaintext is not checked in).crates/fqxv/tests/golden.rsdecodes every one and compares, and separately re-runsinspecton each — the header/footer parsing path that random access and the remote/Rangeclients share, which a decode-only check would not cover. One fixture per on-disk layout and per sequence codec, since a fixture with too thin an input falls back to plain order-k and pins nothing new:
| fixture | what it pins |
|---|---|
plain_se |
plain layout, order-k sequence codec — the default path |
paired_labels |
G=2 interleaving, and the header extension region (non-critical tag 0x01, member labels) |
reorder |
the whole-file global-cluster reorder layout, with its own header and framing |
longread |
the long-read cross-read overlap codec (a sequence method byte other than 0) |
A second test pins that set by name, so dropping a fixture cannot quietly leave
a layout uncovered while the suite still passes. Regenerating the fixtures is
almost always the wrong response to a failure — it makes the red go away and
ships the regression; regenerate (cargo run --release -p fqxv --example
make_fixtures -- crates/fqxv/tests/fixtures) only when adding a fixture or when
a format change was made deliberately and gated per the
evolution policy, and keep the
old fixtures either way. The fixtures also seed the container fuzz target's
corpus, which otherwise has to guess the magic and a valid header CRC before it
reaches any decode path.
- ✅ compat CI job. The only job that puts two different builds of fqxv in
the same room: it downloads the newest published release's static Linux CLI,
builds the PR's, generates a seeded FASTQ corpus in-job (sampled from one
synthetic reference so the reorder path has real depth redundancy), and makes
the two exchange archives both ways.
- Backward (release writes, PR reads) is the format's central promise and
a hard failure: the decoded FASTQ must be byte-identical. Cases: default,
paired, and --order any --keep-order (skipped if the old binary predates
those flags, so a new flag can't read as a compatibility break).
- Forward (PR writes, release reads) is not a promise — the format may
move forward. Exactly two outcomes pass: the old binary succeeds with
byte-identical output, or it exits non-zero with a recognizable refusal
(unsupported / upgrade fqxv, i.e. one of the Unsupported* errors).
Succeeding with different bytes is silent corruption and fails; so does a
crash or a bare non-zero exit, which leaves a user unable to tell "your fqxv
is too old" from "your archive is broken".
- A repo with no published release asset skips cleanly rather than turning a
green PR red.
Between them: the fixtures catch a codec that changed under an archive's feet,
and compat catches an end-to-end CLI-level break against the actual last
release, including the quality of the refusal when the format does move.
1. Decoder robustness on untrusted input (highest priority)¶
htscodecs fuzzes every one of these codecs via OSS-Fuzz, feeding arbitrary bytes
straight to the decompressor. We now have an equivalent — coverage-guided
cargo-fuzz targets over every decode entry point (see below). A local probe (30k
random inputs per codec) found no panics, but targeted adversarial headers did
crash the process:
- ✅ fqzcomp/seq unbounded allocation from length headers. A ~13-byte
fqzcompstream declaring a huge length count aborted the process (memory allocation of 288230376151711740 bytes failed, SIGABRT); a 12-byte stream declaring1000 reads × u32::MAXlength spun a ~4.3 TB decode (>120 s timeout). Fixed by usingtry_reserveinstead ofwith_capacity/resize, a checked sum ontotal, and a decompression-bomb guard that bounds declared output bypayload_len × max-symbols-per-byte— a principled ceiling from the range model's1<<13frequency cap, with wide margin so legitimate streams never trip it. Regression tests:rejects_huge_length_count,rejects_huge_total_length,decode_never_aborts_on_garbagein bothfqxv-fqzcompandfqxv-seq. Mirrors the htscodecs fqzcomp 1-bp buffer-overrun fix (NEWS 1.5.2). - ✅
cargo-fuzzharness over every decoder.fuzz/fuzz_targets/has seven coverage-guided targets —container,rans,tokenizer,seq,fqzcomp,reorder,lroverlap— each feeding arbitrary bytes straight to a decode entry point, the invariant being that a decoder returnsOk/Erron any input and never panics/aborts (release ispanic = "abort"). This complements the in-tree proptest corruption harness (crates/fqxv/tests/corruption.rs), which mutates valid encodings in normal CI. Seefuzz/README.md. - ✅ Inline
decode_never_aborts_on_garbagein every codec crate. The cheap random-garbage regression test — arbitrary bytes straight to a decode entry point, asserting only that it returns — now runs without the nightly fuzz toolchain infqxv-rans,fqxv-range,fqxv-fqzcomp,fqxv-tokenizer,fqxv-seq, andfqxv-reorder(the latter coveringdecode_clustered_autoand everyGlobalReferencemethod, not just the packed sub-path). Residual (▶):fqxv-lroverlaphas no top-level equivalent — itsarbitrary_bytes_never_panic/odd_anchor_sets_never_panicproptests cover the minimizer and edit-script layers, and thelroverlapfuzz target covers the decoder, but the inline smoke check overdecode/tile_decodeis missing. - ○ tokenizer/reorder speculative allocations cap at
1<<20/1<<22, so the worst case is ~96 MB rather than an abort — lower risk, but converting them totry_reservewould remove the last alloc-abort paths for consistency.
2. Degenerate-distribution & raw-path cases (entropy coders)¶
Round-trip tests over random data almost never generate these:
- ✅ Empty stream through compress and decompress — htscodecs #99 was a
divide-by-zero (SIGFPE) on empty input.
roundtrip_emptyin bothfqxv-ransandfqxv-rangenow encodes and decodes empty input (not just decode-empty). - ✅ Single distinct symbol / all-same-byte with 12-bit frequency — htscodecs
1.5.2 fixed a SIMD mis-decode here.
avx2_encode_matches_scalar_skewed(fqxv-rans) exercises the "SIMD ≡ scalar" invariant on degenerate cases: all-same (vec![b'Q'; 40_000], freq == TOTFREQ), a dominant symbol (freq > 2048), and a 2-symbol skew. Residual (▶): the AVX2-vs-scalar proptests are still seeded with a small random alphabet, not degenerate distributions — a proptest seeded with all-same / 2-symbol-at-4095/4096 would close the gap. - ▶ Raw/CAT passthrough path, if/when added — htscodecs #144 fell through into the order-0 encoder because the test suite omitted the raw path. (No raw path exists today; this fires only if one is added.)
- ✅ Malformed frequency table (doesn't sum to total) → decode must reject.
decode_rejects_malformed_freq_table_without_abortingandfrom_freqs_rejects_malformed_tables(fqxv-rans) assertErr(Malformed)on sum ≠ TOTFREQ and over-cap tables. - ▶ 1–2 byte inputs ✅ (
roundtrip_single_byte,roundtrip_short_and_odd_lengths, rangeroundtrip_single) but block-boundary-straddling large inputs remain ○: tests cover the 32-state interleave boundary, not the htscodecs #1281.25 MiB output-sizing boundary specifically.
- ○
output.len() <= bound(input)property for every encoder — guards the class of max-growth underestimates behind several htscodecs security fixes. Not yet written (the nearest test asserts a compression ratio on favorable data, not a worst-case growth bound).
3. Name tokenizer¶
- ▶ Names with bytes ≥ 0x80 — htscodecs #105 was a signed-char crash on
[0x80, 0x0a]. Rust avoids the UB, but index/sign logic can still misbehave. Still open: the arbitrary-name generators sample only fromb"AB:._-0129 "(all < 0x80); the0xFFbytes in the corruption tests mutate encoded output, not name input. - ▶ Exactly
MAX_COLandMAX_COL+1 tokens — htscodecs 1.5.2 overflowed writing a 129th token.MAX_COLhere is 63 (not 128); no test constructs a name that tokenizes to exactly the column-cap boundary. Still open. - ○ Empty name ✅, single-char ✅, all-numeric ✅, varying column counts row-to-row
✅ (all in
roundtrip_varying_structure); all-hex still missing (ties into the hex-aware token class, §5). - ○ UUID4 read-name corpus (Nanopore) as a benchmark + roadmap: htscodecs #130/#131 show a transpose strategy beats the tokenizer for these; worth a hex-aware / leading-zero-preserving token class (see §5).
4. Sequence content & read reordering (SPRING / PgRC)¶
- ▶ Exact A/C/G/T/N count and N-position preservation across reorder —
PgRC #6 drifted per-base counts because N-reads go to a separate subset. Partly
covered:
reorder_free_preserves_records_as_a_set/reorder_rescue_preserves_records_as_a_setassert the full record multiset survives (implying N positions), andcontent_stats_and_metadataasserts exact base counts — but only on the plain path, and the reorder fixture has no N bases. Still open: an explicit base-count + N-position assertion on an N-containing input through reorder. - ✅ Compress-twice, byte-identical, independent of
--threads— SPRING #35 produced non-deterministic archives.archive_is_deterministic_across_threads(1 vs 4 threads, byte-identical), plusreorder_paired_is_thread_count_deterministicandmerge_roundtrips_and_is_deterministic. - ✅
--threads 0— SPRING #44 infinite-looped. We clamp (commit93eb5ff);resolve_threads_zero_is_all_cores_and_explicit_is_clamped(fqxv-cli) is the regression test. - ✅ Mixed/variable read lengths across a block boundary — PgRC #2 is
fixed-length-only (≤255 bp).
ragged_lengths_roundtrip_multiblock(30 reads, 10–310 bp,block_reads: 5) andpaired_split_spans_multiple_blocksown it. - ○ RC palindromes and a read + its own reverse complement — read + own
revcomp clustering is ✅ (
revcomp_duplicate_flips_to_match); a true palindrome (sequence == its own revcomp) is not exercised through clustering. - ○ N↔quality coupling and IUPAC codes — IUPAC losslessness is covered at the
codec level (round-trip proptests seeded with
RYKMetc. infqxv-seq,refpack,fqxv-lroverlap), but there is no container-level IUPAC test, no N↔quality independent-preservation test, and no test documenting the collapse-to-N stance. - ○ Structural fixtures: empty read ✅ / all-N read ✅ (
handles_short_and_n_reads); single read/single spot ✅; line count not a multiple of 4 ✅ (a_truncated_fastq_errors_in_both_order_modes); seq/qual length mismatch → clean error ✅ (compensating_seq_qual_mismatch_is_rejected). Still missing: 511/512-bp boundary (SPRING switches algorithms >511 bp).
5. Feature-request candidates (from upstream trackers)¶
- ✅ Selectable lossy quality tiers. SPRING offers lossless / Illumina-8-bin /
QVZ / binary-threshold; we have
QualityBinning(Lossless/Bin8/Bin4/Bin2plusBinOnt/BinHifi). The binary 2-level threshold ask is shipped asBin2(--quality-bin bin2; fixed Q25 cutpoint — a user-selectable threshold value would extend it). - Hex-aware / UUID name tokenization. htscodecs #131 (columnar by token type, leading-zero preservation) and #130 (transpose for UUID4). Adaptive: detect name style and switch strategy; regresses on Illumina if non-adaptive.
- fqzcomp READ1/READ2 + strand selectors. CRAM 3.1 uses multiple parameter
sets at higher levels and per-record strand/mate hints; ties into our
interleaved-spots invariant and
--levelmapping. - Archive introspection / partial decode. Partly shipped. Introspection is
done (
fqxv info, and theFQXFfooter index gives per-stream(offset, length, crc32c)); random-access column projection over HTTPRangeexists in the Pythonfqxv.remoteAPI. Remaining: no CLI subcommand for subset/row-range decode — partial decode is library/Python-only. - ✅ Streaming (
-) stdin/stdout. Shipped in #242: decompress reads an archive from stdin (-) and writes FASTQ to stdout (-Z/-o -); compress reads FASTQ from stdin. Carve-outs enforced:--split/--recoverfrom stdin are rejected (no rewind), compress-from-stdin requires-o, and the archive itself is not written to stdout (needs a seekable footer). Test:stdin_stream.rs.
6. Interop conformance¶
- ○ Decode a handful of htscodecs
tests/dat/tests/namesfixtures and assert byte-identical plaintext — cross-implementation decode conformance against the reference, separate from internal round-trips.CRAMcodecs.pdfis the normative reference. The fqzcomp.testfixture format (one line of qualities per record, optional trailing READ1/READ2 flag) is a convenient interop format to adopt.
7. CLI¶
- ✅
fqxv-clinow has integration tests (crates/fqxv-cli/tests/):batch.rs(info/verify/estimatebatch shapes,--verify, bad-input and--interleaved 0error paths),quality_bin.rs(compress→decompress with--quality-bin), andstdin_stream.rs(stdin round-trip +--split-from-stdin rejection). Plus theresolve_threadsunit test for--threads 0. - ▶ Remaining CLI gaps: no test exercises the
--level→ order/block mapping, and no end-to-end CLI test drives--threads 0(only theresolve_threadsunit test does).