Skip to content

tests: sharpen the cargo-fuzz oracles, and fix what they found - #37970

Closed
def- wants to merge 22 commits into
MaterializeInc:mainfrom
def-:pr-cargo-fuzz
Closed

tests: sharpen the cargo-fuzz oracles, and fix what they found#37970
def- wants to merge 22 commits into
MaterializeInc:mainfrom
def-:pr-cargo-fuzz

Conversation

@def-

@def- def- commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Sharpens the cargo-fuzz suite's oracles, and fixes the product bugs the sharpened
oracles then found.

The branch is ordered so the product fixes come first, grouped by area, and the
fuzz-harness work lands last as a single commit. Every product commit touches zero
files under a fuzz/ directory, so each can be reviewed, reverted or backported on
its own. The final commit is deliberately the one that would be red without the
fixes below it: its whole purpose is oracles that assert what they claim to.

Product fixes

persist

  • reject state diffs whose data_lens sum overflows
  • don't panic formatting a LazyPartStats with malformed bytes
  • saturate the spine's fuel computation instead of wrapping

avro

  • don't let a block see the previous block's bytes
  • bound an OCF block's object count by its payload

sql-parser

  • only start a map literal when a bracket follows MAP

expr

  • don't memoize a fallible operand out of an AND/OR
  • render an unbounded OutOfDomain instead of panicking

repr, proto and catalog decode

  • enforce type-modifier domains when decoding from proto
  • reject corrupt ProtoRelationDesc shapes when decoding
  • count both versions a dropped non-root column stamps
  • bound ProtoNumeric.bcd length before the BCD FFI

repr, regex

  • bound regex compile memory, not just the pattern's byte length

repr, datetime and strconv

  • check the offset subtraction in parse_timestamptz
  • bound round_to_precision's rounding arithmetic
  • carry a rounded-up fraction into the seconds field
  • reject a TIME string that names no time field
  • strip the quotes from a quoted range bound
  • report a BC date as needing escaping

pgrepr

  • reject numeric infinity as a binary parameter

Two of these want a second look before merge

  • repr: check the offset subtraction in parse_timestamptz is security-relevant.
    A 24-byte text value in a pgwire bind parameter, the HTTP SQL API, or a source
    cast aborted the process: remote input, no catch_unwind above it, and the
    enhanced panic hook ends in process::abort(). Same shape as SS-157. Worth
    deciding whether that needs an advisory rather than just a changelog entry.
  • repr: report a BC date as needing escaping changes user-visible output.
    {0001-02-24 BC} becomes {"0001-02-24 BC"}, matching PostgreSQL's array_out.
    It is a correctness fix, but anyone parsing date[] text sees the change, so it
    belongs in release notes.

Ordering constraint worth knowing if these get split up: strip the quotes from a quoted range bound has to land before report a BC date as needing escaping. The
date fix makes daterange start emitting quoted BC bounds, and without the range
parser fix those cannot be read back. The range parser was already broken for
tsrange with a pre-Common-Era bound before either change.

Known deviation left unfixed

PostgreSQL processes \ escapes in unquoted range bounds; we still do not. Not
reachable from any renderer output, since format_range quotes whenever escaping
is needed, so round trips stay total. It manifests only as erroring where PG
succeeds on hand-written SQL. Happy to file it separately.

Fuzz-harness work

One commit, tests: sharpen the cargo-fuzz oracles across the workspace, whose
message carries the per-target detail and the measurements. The recurring themes:

  • oracles that could pass while the behaviour they name is broken — three
    structural fusions whose assertions compared a plan against a clone of itself on
    100% of inputs, a Nestable verdict the DATE target discarded, an interval
    round-trip whose failure branch was dead, a constraint-string parser whose
    documented accept/reject boundary was never asserted;
  • generators that stall before the decode they exist to drive — arms made
    unreachable by capped parameters, selector bytes parked past a 32-byte seed
    window, Unstructured exhaustion silently pinning a branch;
  • carve-outs resting on premises that turned out false — a Typecheck
    rejection that panics rather than erroring, a >4-digit year the parser accepts
    fine, an absorbed error that can resurface as a wrong value;
  • coverage the docs claimed but never reached — corrected rather than papered
    over, including three transforms that cannot see the exact key facts this suite
    was said to give them.

Every behavioural change is A/B verified: the mutation that should trip each new
assertion is reported, and the unmutated tree runs clean. The commit message states
the execution counts.

Verification

mz-repr, mz-expr, mz-transform, mz-sql, mz-pgrepr, mz-avro and
mz-persist-client unit suites pass (590 tests). dates-times, range, arrays,
list, map, record, timestamp, timestamptz, timezone and funcs
sqllogictest files pass. All four touched fuzz crates build under
cargo +nightly fuzz build, and each modified target was run to convergence.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E9wiXMn5L34GwkmwpaFGif

def- and others added 22 commits July 31, 2026 07:02
`ProtoStateFieldDiffs::validate` summed the `data_lens` entries with
unchecked arithmetic. Overflow checks are off in the release and
optimized profiles, so a diff declaring `data_lens = [u64::MAX, 1]` with
an empty `data_bytes` wrapped the sum to 0, matched the real byte length,
and passed validation. `ProtoStateFieldDiffsIter::next` then sliced
`0..u64::MAX` out of an empty slice. State diffs are read from consensus
on every state update, so this input is untrusted and reachable from a
corrupted or crafted blob. Under overflow checks (the `ci` profile,
`cargo test`, cargo-fuzz builds) the panic is the addition itself.

Sum with `checked_add` instead and reject on overflow, so a malformed
diff becomes the same decode error as every other inconsistency
`validate` catches, rather than a panic in the iterator.

Adds `proto_state_diff_data_lens_overflow_is_error`, which panics inside
`validate` without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LazyPartStats` holds its `ProtoStructStats` undecoded, and its
`RustType<Bytes>` impl stores the bytes without validating them, so a
value read off blob carries whatever encoding was there. `Debug` called
`decode()`, which `expect`s that encoding to be well-formed.

That is reachable on the persist state-load path, and specifically on its
rejection path: `Spine::validate` formats the whole spine into the
message it rejects with, and `Trace::unflatten` turns that into the
decode error a `Rollup::from_proto` caller sees. A rollup whose trace
fails validation *and* carries malformed part stats therefore panicked
while building the error meant to reject it, so the shard failed to load
by panic rather than by error, on every attempt.

Split the fallible half out as `try_decode` and have `Debug` render an
`<undecodable: ...>` placeholder instead of panicking. `decode()` keeps
its panicking contract for callers holding stats they encoded
themselves, now documented as such.

NOTE: the read-path callers of `decode()` (`fetch.rs`, filter pushdown in
`operators/shard_source.rs`) run on the same untrusted bytes and still
panic on a malformed encoding. Left alone here because each needs a
decision about what a missing-stats fallback should do.

Adds `lazy_part_stats_debug_does_not_panic_on_garbage`, which panics in
`Debug` without this change. Found by the `rollup_proto_roundtrip`
cargo-fuzz target in release-qualification 1330 (v26.35.0-rc.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Spine::introduce_batch` sized each maintenance step as
`(8 << batch_index) * effort`, where `batch_index` comes from the
inserted batch's `len`. Both steps were unchecked, and the result was
`as`-cast to `isize`. A `len` near `2^60` makes the shift land on the
`isize` sign bit, so the budget handed to `apply_fuel` was negative.

`FuelingMerge::work` then read that back with `*fuel as usize`, which
wraps a negative budget to a huge one, spent `remaining_work` against it,
and subtracted that from the still-negative `fuel` -- an underflow. This
is reachable from a corrupted or crafted blob: `Trace::unflatten` replays
decoded legacy batches through the real spine, and its `MAX_TOTAL_LEN`
guard only caps the total at `usize::MAX >> 3`, which still admits such a
`len`. Under overflow checks (the `ci` profile, `cargo test`, cargo-fuzz
builds) the panic is the subtraction. In the release and optimized
profiles it wraps silently and leaves the merge's fuel accounting
meaningless instead.

Compute the fuel with checked arithmetic and saturate at `isize::MAX`.
Fuel is a budget, so an over-large one only completes merges sooner,
whereas a wrapped one can go negative and starve them. In `work`, read a
negative budget as zero available rather than as a huge one, which also
retires the two `as` casts the `TODO` there asked about.

Adds `spine_fuel_isize_overflow`, which panics in `work` without this
change. Found by the `rollup_proto_roundtrip` cargo-fuzz target in
release-qualification 1332 (v26.35.0-rc.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Reader` reuses one buffer across object-container blocks, and `fill_buf`
only ever grew it. A block shorter than its predecessor therefore left
that predecessor's tail readable past its own payload, because everything
downstream reads the buffer by its length: `read_next` slices
`self.buf[self.buf_idx..]`, and `Codec::decompress` hands the whole buffer
to the decompressor.

A block whose declared object count outran its own bytes would then decode
those stale bytes into values rather than running out of input. Truncate
to the payload after reading it. `Vec::truncate` keeps the allocation, so
the buffer is still reused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A data block's object count is read straight off the wire and `safe_len`
capped it only at `MAX_ALLOCATION_BYTES` — a sensible ceiling for a byte
length, an enormous one for a count, and unrelated to the block it
describes. A zero-width schema (an empty record, or a record of only
`null` fields) encodes every object to no bytes, so a handful of wire
bytes could claim hundreds of millions of objects and the reader would
decode every one, allocating a `Vec` and per-field `String` each time.
Measured: a ~30-byte file spends 47s on 100M objects, minutes at the old
ceiling.

Bound the count against the block that is supposed to hold those objects,
deciding which bound applies the way `decode.rs` already does for an
array block: the byte floor when the object schema has a proven positive
one, otherwise the node-weighted `MAX_VALUE_NODES` cap, since no payload
length can constrain a count of zero-width objects.

Unlike an array block the two are alternatives, not both applied. An array
materializes all of its elements at once so its cap is about retained
memory, while a block's objects are yielded and dropped one at a time, so
this cap is about work amplified out of a few bytes. Once the byte floor
holds the work is linear in the file, and capping nodes as well would
reject a legitimate large block of small records.

The count is bounded after decompression, since the declared byte size is
the compressed size.

Found by the reader_decode cargo-fuzz target, which timed out within
seconds of drawing block counts from the range `safe_len` accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`parse_option_map` committed on the `MAP` keyword alone and then demanded
`[`, so a bare `map` in an option-value position was a hard parse error
rather than falling through to the item name it is.

`map` is a legal bare identifier everywhere else — `SELECT "map"` prints
as `SELECT map` and reparses fine — so `AstDisplay` prints an option whose
value is the item name `map` unquoted, and that output failed to reparse:

    CREATE SINK s FROM t INTO KAFKA CONNECTION c (TOPIC CONFIG = "map")

prints `TOPIC CONFIG = map` and then fails with "Expected left square
bracket, found right parenthesis".

Look ahead for the bracket instead. The alternative, quoting `map` in
`can_be_printed_bare`, would change the printed form of every `map`
identifier in every position to work around one non-backtracking call
site; the sibling branches of `parse_option_value` all backtrack already.

Found by the sql_roundtrip cargo-fuzz target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AND` and `OR` discard an operand's error once another operand fixes the
result: `Or::eval` returns `true` as soon as it sees a `true` operand,
dropping any error it collected, and `And::eval` does the same for
`false`. So an erroring subexpression under one of them is not
observable.

`MapFilterProject::optimize`'s memoization pass descended into those
operands like any other child. A subexpression shared between two of them
therefore became a mapped column, and `SafeMfpPlan::evaluate_inner`
evaluates every mapped column up front, with `?`. That surfaced an error
the expression suppresses, so a row the plan should emit failed the whole
query instead. `MfpPlan::create_from` runs `optimize` unconditionally, so
this is on the path every dataflow operator's linear pipeline takes.

    SELECT * FROM t WHERE ((a * a) <= (a * a)) IS NULL OR b IS NULL

with `a` large enough that `a * a` overflows int8 and `b` NULL should
return the row on the strength of the second disjunct. Sharing is what
makes it reachable: a singly-referenced part is inlined back by
`inline_expressions`, so only a part with two or more uses survives as a
column.

Withhold fallible `AND`/`OR` operands from eager memoization, alongside
the `If` branches and `COALESCE` tail already withheld. Infallible ones
stay eligible, which is what keeps the shared comparisons of a null-safe
join predicate in one column each -- gating all operands regressed
`join-implementation`'s plan for `(#0 = #1) OR (#0 IS NULL AND #1 IS
NULL)`. Also withhold `error_if_null`'s message operand, which is only
evaluated when the first operand is NULL and had the same exposure.

This costs plan quality wherever a fallible expression is shared across the
operands of an AND/OR. Every affected plan stays correct and recomputes the
expression instead of sharing a Map column:

  * `canonicalize_mfp.spec` and two cases in `literal_constraints.slt`, each
    annotated in place.
  * TPC-H q22, in `tpch_select.slt`, `tpch_create_index.slt` and
    `tpch_create_materialized_view.slt`, and CH-benCHmark q22 in
    `chbench.slt`. `substring(c_phone, 1, 2)` leaves its Map column for all
    seven operands of the `IN` list, in each of the two filters over
    `customer`.
  * Nine builtin-view plans in `catalog_server_explain.slt`.
    `jsonb_array_length` doubles in `mz_clusters` and `mz_cluster_replicas`,
    `parse_catalog_create_sql` doubles in `mz_sources`, and a
    `case when ... coalesce` chain is recomputed per operand in
    `mz_arrangement_sizes_per_worker`, the four `mz_records_per_dataflow`
    variants, and `mz_aws_privatelink_connection_statuses`.
  * `aoc_1219.slt` and `github-5536.slt`, where a shared `substr` and a shared
    negation are recomputed.

In all of them the shared part occurs in *every* operand, which is the
refinement that would recover them: if no operand can reach the value that
ends the AND/OR without first evaluating the shared part, then the part's
error cannot be swallowed and hoisting it stays sound. Not implemented here,
and worth its own change given how much plan quality is on the table.

Adds `optimize_preserves_and_or_error_suppression`, which fails with
`Err(NumericFieldOverflow)` without this change. Found by the
`mfp_optimize` cargo-fuzz target, which failed release-qualification
1330, 1332, 1334 and 1335 (v26.35.0-rc.1 through rc.4) on this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Display for EvalError` panicked on `OutOfDomain(None, None, _)`, a state no
constructor produces: all four `EvalError::OutOfDomain` call sites set at least
one bound. It is reachable by decoding a corrupted or forged `ProtoEvalError`,
because `from_proto` accepts any pair of present `ProtoDomainLimit`s, and
`DomainLimit::None` is a present field with an unbounded value.

That path is live in clusterd. `DataflowErrorSer::Display` decodes error bytes
read straight out of a persist shard and delegates to `EvalError::fmt`, and the
index peek path renders the error-trace key. So one such error in a shard panics
the dataflow, and re-panics on every retry.

Render the degenerate domain instead. Rejecting it in `from_proto` would not
help: `DataflowErrorSer::deserialize` expects the conversion to succeed, so the
same bytes would panic a few frames earlier on the same path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CharLength`, `VarCharMaxLength`, `NumericMaxScale` and `TimestampPrecision`
each have a `TryFrom<i64>` that rejects out-of-domain values, but their
`from_proto` copied the wire value straight through. `from_proto` is a trust
boundary for durable catalog state and for the compute/storage protocol, so a
corrupt or forged value became a type modifier no SQL statement could have
produced. Consumers rely on the invariant being real: a `TimestampPrecision`
above `MAX_PRECISION` panics `round_to_precision`.

Route all four through `TryFrom<i64>` and surface the rejection as an
`InvalidFieldError`.

Narrow the `Arbitrary` impls to the same domains, which the validation above
makes load-bearing. `CharLength` and `VarCharMaxLength` mapped an arbitrary
`u32` through `% 300`, so they generated 0, and there is no `char(0)` or
`varchar(0)`. `NumericMaxScale` and `TimestampPrecision` used
`derive(Arbitrary)`, which knows nothing of `NUMERIC_DATUM_MAX_PRECISION` or
`MAX_PRECISION`. Hand-written strategies keep the generated values inside the
domain the type promises, so a round-trip property cannot fail on a value the
constructor would have refused to build.

`source-datas.txt` holds such a value, generated before the narrowing: 10 of
its 40 SourceData stability entries carry a `numeric` max scale or a
`timestamp` precision outside the domain, so `from_proto` now refuses to
decode them. Those 10 are regenerated from the narrowed strategies. The other
30 stay byte-identical, and the encoding itself does not change, so
`MINIMUM_CONSOLIDATED_VERSION` does not move.
`VersionedRelationDesc::validate` treats a names/column-types length mismatch
and an out-of-bounds key column index as corruption, but
`RelationDesc::from_proto` accepted both. Nothing downstream catches them: such
a desc decodes and re-encodes cleanly and only panics at first use, `iter()`
indexing `typ.columns()[typ_idx]` out of bounds or `into_iter()` tripping
`zip_eq`. Both are reachable from untrusted proto bytes.

Also record why only dense descs may be handed to `Codec::encode_schema`.
`ProtoRelationDesc` has no field for the `ColumnIndex` keys, only the values in
`ColumnIndex` order, so a sparse desc, which is what
`VersionedRelationDesc::at_version` returns once a column has been dropped and
what `RelationDesc::apply_demand` returns, comes back from `from_proto`
renumbered to `0..n`. `ColumnIndex::to_stable_name` is the arrow field name that
`RowColumnarEncoder` and `RowColumnarDecoder` agree on, so encoding a sparse
desc as the schema of data written under the original indexes builds a decoder
that looks up the wrong fields.
`VersionedRelationDesc::validate` derived each column's versions with
`dropped.unwrap_or(added)`, which discards the add's version whenever a column
was also dropped. Every version past the root is stamped exactly once, by the
add or the drop that created it, so a column added after the root and later
dropped accounts for two of them and both have to be counted. Collapsing them
lost any desc where a non-root column was later dropped.

Count both, filtering the root, which is the one version many columns can share.
`decPackedToNumber` writes one BCD digit per input nibble into the
coefficient of the `decNumber` it is handed, without checking that the
coefficient can hold them. `Numeric` is a `Decimal<13>`, whose
coefficient is a fixed `[u16; 13]` holding 39 digits, so a
`ProtoNumeric` whose `bcd` exceeds 20 bytes overruns a struct on our
stack. At 21 bytes that silently corrupts the value, yielding a
`Datum::Numeric` holding `Infinity`, which the encoder treats as
impossible for this datum type. A few KB of `bcd` walks off the frame
and segfaults.

`ProtoRow` decoding is reachable from untrusted bytes via `Codec for
Row` and `Codec for SourceData`, so a corrupted or tampered persist blob
turns what should be a decode error into memory corruption in
environmentd or clusterd. The empty-bcd segfault was already rejected
here, so bound the upper end next to it. `to_packed_bcd` never emits
more than 20 bytes, so nothing we write is rejected.

Adds `proto_row_oversized_numeric_bcd_is_error`, which covers the silent
corruption and the segfault, and asserts a max-precision numeric still
round-trips so the bound cannot drift below what we encode.

Found by the row_proto_roundtrip cargo-fuzz target. That target cannot
detect this on its own: the corrupted value re-encodes and re-decodes to
itself, so its round-trip oracle stays silent and only the multi-KB
segfault shows up.
`MAX_REGEX_SIZE_BEFORE_COMPILATION` caps `pattern.len()` at 1 MiB to keep a
user-supplied regex from OOMing envd. A byte budget cannot do that job. The
memory goes to `regex-syntax` translating the pattern's AST into its HIR, a
stage that runs before anything `size_limit` measures, and while cost there is
linear in the AST's node count, the cost per node spans two orders of magnitude:

  group, alternation, repetition, assertion    0.33 - 0.53 KB
  literal, `.`, `[a-c]`                        0.42 - 0.60 KB
  `\d`, `\s`                                   0.65 - 1.1 KB
  `\w`, `\W`, case-sensitive `\p{L}`           6 - 18 KB
  case-insensitive `\p{L}`, `\p{Alphabetic}`   39 - 41 KB

So a pattern one byte under the limit could allocate ~7.8 GB. Confirmed on a
single-node environment with unprivileged SQL, no table and no cluster work,
from ~90 bytes of query text:

  SELECT 'x' ~* repeat(chr(92) || 'p{L}', 209715);

envd VmRSS went 320 MB -> 7 620 MB over ~18 s (VmHWM 8 303 MB) while clusterd
stayed flat at ~420 MB, and only then did the statement error with "Compiled
regex exceeds size limit". `statement_timeout` does not help, since the
allocation precedes the error. Any user who can issue a `SELECT` could do this
on demand, and two concurrent queries exceed any realistic envd memory limit.

Project the cost from the AST instead, which is cheap to obtain (~30 ms and
~10 MB at 174 KB, flat in the pattern's shape) and charge each node a
conservative upper bound: 48 KB for a Unicode-property or Perl class item, the
kinds that expand to hundreds of ranges, and 768 bytes for everything else.
Reject above 1 GiB. Classes nested in a bracketed class are charged too, else
`[\p{L}]` repeated evades the budget at the same cost.

This is additive. The byte limit stays, since it is what bounds the AST parse
this check needs, and its number stays valid in the user-facing docs. Patterns
that were cheap remain accepted: a 1 MiB pattern of plain literals, the shape a
machine-generated alternation takes, still compiles. A counted repetition needs
no budget of its own, since `\p{L}{200000}` stays two AST nodes and the NFA
compiler's incremental `size_limit` check already rejects it in 40 ms at 17 MB.

A pattern we cannot parse falls through to `RegexBuilder` so users keep its
error message. That is not an escape hatch: both parse through the same
`regex-syntax` 0.8.11 with the same default configuration, verified against 35
syntax corner cases to never split in the direction that would skip the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ection

Leap-second handling in `timezone()` was covered only in the
TIMESTAMPTZ -> TIMESTAMP direction, which goes through
`checked_add_with_leapsecond`. The mirror direction uses
`checked_sub_with_leapsecond` and needs the same fold.

`timezone()` reads a numeric offset as POSIX, where a positive offset is west of
Greenwich, so `+00:00:01` has a `local_minus_utc` of -1 and moves the leap second
forward past the minute boundary, while `-00:00:01` moves it back onto `:58`.
Neither lands on `:59`, so both take the folding branch. A zero offset does keep
the result on `:59`, exercising the `else` branch that restores the leap-second
nanos.
`parse_timestamptz` applied the time-zone offset with chrono's
`NaiveDateTime - FixedOffset`, which panics on overflow. `HIGH_DATE` is
exactly `chrono::NaiveDate::MAX`, so a westward offset on a late time that
day left chrono's range and aborted the process. The subtraction runs
before `CheckedTimestamp::from_timestamplike`, so the range check could
not catch it. A 24-byte literal such as `262142-12-31 23:00:00-01` was
enough, reachable from a bind parameter, the HTTP SQL API, a string cast,
or a source cast.

The same subtraction also produced a value that cannot be represented.
chrono stores a parsed `:60` as a leap second, legal only at a
second-of-minute of 59, and copied that sub-second verbatim while
shifting the second. An offset that is not a whole number of minutes,
as in `1970-01-01 00:00:60+00:00:30`, therefore yielded a leap second at
`:30`, which panics in row encoding on every re-render. Folding it into
the next regular second matches what PostgreSQL does with `:60`.

Both are fixed by the leap-aware checked offset helpers that
`timezone_timestamp` already used for this defect class. They move from
`mz_expr` to `mz_repr::adt::timestamp`, next to the range they enforce,
so the two crates share one copy rather than keeping the leap-second
reasoning in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`round_to_precision` rounded with chrono's `+` and `-`, which panic on
overflow. Rounding *up* off the last fraction of `HIGH_DATE`, itself
exactly `chrono::NaiveDate::MAX`, leaves chrono's range and aborted the
process. The rounding runs after the `CheckedTimestamp` range check, so
the check could not catch it either.

Every precision reaches this, each with its own quantum, and the lower the
precision the fewer fractional digits it takes: at precision 0 a single
`.5` rounds up a whole second, so
`SELECT '262142-12-31 23:59:59.5'::timestamptz(0)` was enough. The
precision is an ordinary type modifier on the column, so this is not a
corner only the microsecond default can reach.

Report `TimestampError::OutOfRange` instead, which the callers already
propagate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`format_nanos_to_micros` rounded a nanosecond fraction to microseconds and
wrote the result as a fractional field. It could not carry out of that
field, so a fraction of `.9999995` or more became `1000000` microseconds,
which the trailing-zero stripper then reduced to `1`. The value rendered
about a second early with a nonsense fraction:

    '2020-01-01 00:00:00.9999999'  ->  2020-01-01 00:00:00.1
    '12:34:56.9999999'             ->  12:34:56.1

The same happened to chrono's leap-second representation, a sub-second of
one second or more, which `%S` has already rendered as `60` by then.

Nothing rounds on the way in, so these values reach the renderer as
stored. `mz_pgrepr::Value::decode_text` pushes the parsed nanoseconds
straight into the `Row` behind COPY in TEXT and CSV format, COPY FROM S3,
and text-format bind parameters, and `Row` keeps all of them. The TIME
cast does not round either, nor does DATE + TIME. Only the TIMESTAMP cast
rounds, which is why it hid this. The result was silent corruption: the
stored value displayed as something else, and the displayed text did not
re-parse to it, so text round trips through COPY TO and sinks lost it.

Split the helper in two. `split_nanos_to_micros` returns the whole-second
carry separately from a fraction that is guaranteed below one second, and
`format_micros` asserts that bound before writing. The carry is applied to
the timestamp itself, so it reaches `%H:%M:%S` and rolls the minute, day,
month or year as needed. Clearing the fraction before adding the second is
what makes this correct for a leap second too: the second after `23:59:60`
is `00:00:00` of the next minute.

Where the carry cannot be represented it is dropped and the fraction
saturates at `.999999`, rather than the value rolling over to something
unrepresentable. That is the last microsecond of `HIGH_DATE`, which is
exactly `chrono::NaiveDate::MAX`, and the last second of the day for
`NaiveTime`, which wraps to midnight rather than reaching PostgreSQL's
`24:00:00`.

Covered by `test_format_subsecond_carry`, which drives all three renderers
across the second, minute, day, month and year boundaries plus both
saturation edges, by `test_split_nanos_to_micros` on the helper, and by
`simple` blocks in `dates-times.slt` for the SQL-visible TIME and
DATE + TIME paths. Those have to be `simple`: they assert on the text
encoding, and the extended protocol that plain `query` uses returns TIME
and TIMESTAMP in binary.

The `datums.pt` golden moves with the fix. The DATUMS load generator's
leap-second TIMESTAMP is `23:59:59` plus 1234 milliseconds, whose whole
nanosecond count divided straight down to 1234000 microseconds and stripped to
a `.1234` fraction. Reducing it below one second first leaves the leap in the
seconds field where `%S` already put it, so the value renders as
`2019-07-24 23:59:60.234`.
`parse_time("")` returned `00:00:00`. PostgreSQL raises `invalid input
syntax for type time: ""`. The same held for `" "` and `":"`.

Such a string tokenizes to nothing the time grammar has to consume:
`determine_format_w_datetimefield` returns no format, `fill_pdt_time`
falls into its catch-all arm without consuming anything, the
leftover-token check passes because the deque is empty, and
`compute_time` then defaults hour, minute and second to zero. The value
looked deliberate rather than defaulted, and it reached `''::time`, the
text-format bind parameter path, and COPY in TEXT and CSV format.

Reject it in `parse_time` instead. Hour, minute and second are the only
fields the time grammar fills, and the only ones `compute_time` reads, so
requiring one of the three to be set is the whole condition.

The storage source cast `CastStringToTime` keeps the old reading via a new
frozen `parse_time_legacy`, as `CastStringToDate` and `CastStringToOid`
already do. Its stability contract is not stylistic: replication re-casts
the old tuple on delete, so a row ingested pre-upgrade as `00:00:00` would
post-upgrade be retracted as a cast error, leaving a phantom value in the
ok shard and a negative multiplicity in the err shard. Nothing is lost by
freezing it, because a PostgreSQL `time` column never renders as one of
these strings, so no export could have held one.

`parity_time` drops its fieldless input, since storage and SQL now
intentionally diverge there, and
`test_cast_string_to_time_frozen_fieldless` pins the frozen side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`format_range` emits a bound quoted whenever the element renderer reports
`Nestable::MayNeedEscaping` and the rendering needs escaping, but
`parse_range_inner` extracted bounds with a bare
`take_while(|c| c != ',')` and handed the substring to the element parser
with the quotes still on it.

That mostly went unnoticed because the datetime parser tolerates a stray
`"`. It stops being tolerable the moment the quote abuts a token the
parser attributes meaning to: the `BC"` of a pre-Common-Era bound is read
as a time zone, so

    SELECT tsrange('0001-02-24 BC'::timestamp, '2020-01-01'::timestamp)::text::tsrange

failed with "invalid input syntax for type range: 'BC' is not a valid
timezone". A `tsrange` or `tstzrange` with a pre-Common-Era bound could
not be read back from its own rendering.

Use `lex_quoted_element`, the same helper the list and array parsers use,
so the quotes come off and `\` escapes are processed. Unquoted, absent and
`empty` bounds take the path they always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`format_date` appends a ` BC` suffix for a pre-Common-Era date and then
returned `Nestable::Yes` regardless. `Nestable::Yes` is documented as
being for renderings that can *never* need escaping, and the whitespace in
` BC` is exactly what every element escaper quotes for, so the promise was
false.

It is not bookkeeping. `stringify_datum` returns this value for the `Date`
arm and the `Array`, `List`, `Map`, `Record` and `Range` arms use it to
decide whether to call `escape_elem`, as does the pgwire text encoder for
`Value::Date` nested in a container. So the quoting step was skipped for
every BC date inside a container, on both `::text` and the wire text
format:

    SELECT ARRAY[('0001-02-24'::date - interval '1 YEAR')::date];
    -- was {0001-02-24 BC}, PostgreSQL gives {"0001-02-24 BC"}

`format_timestamp`, `format_timestamptz` and `format_interval` all return
`MayNeedEscaping` for the same reason. `format_date` was the outlier, and
the same value already quoted correctly when rendered as a timestamp.
Same class as SQL-466, whose fix extended `needs_escaping` and left this
side untouched.

Return `MayNeedEscaping` only for the BC case, so a Common Era date keeps
the cheaper answer and its rendering is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The numeric wire format's `0xD000`/`0xF000` sign words spell `±Infinity`, and
`Numeric::from_sql` decodes them because it also decodes query *results*, where
an infinite numeric is legitimate: aggregation overflow produces one. As a
parameter it has to be rejected. The text path already rejects
`'Infinity'::numeric` in `strconv::parse_numeric`, so accepting the binary
spelling let a client smuggle in a `Datum::Numeric` value that no SQL literal
can name.

Covered by a pgtest pinning both signs, and pinning that `NaN`, which the text
path does accept, and a finite value still decode.
Everything here is a fuzz target, its corpus, dictionary or seeding script, or
the runner. The edits to non-fuzz sources are all test-only or test-enabling:
a `#[derive(Debug, Eq, PartialEq)]` on `SourceExportStatementDetails` so its
round-trip oracle can compare and report, unit tests added inside
`mz_avro::schema`'s `mod tests`, a proptest added to `mz_repr::adt::interval`,
and a `pub use` in `mz_pgwire` exposing the auth sub-parsers that
`Codec::decode` never calls, so the pre-auth SASL/password grammars are
reachable from the codec fuzzer at all, and a note on `mz_sql`'s exhaustive
`OptimizerFeatures` destructuring pointing at the fuzz crate's `fuzz_features`
helper, and a `strconv::element_needs_escaping`
predicate exposing the union of the list, map and record escaper rules so that a
formatter's `Nestable` verdict can be checked against the real ones.

The recurring theme is oracles that could pass while the behaviour they name is
broken, generators that stall before the decode they exist to drive, and
coverage the module docs claimed but never reached.

Formatting and tooling:
* `bin/fmt` skipped the fuzz crates entirely. `cargo metadata` only reports the
  workspace it is pointed at, and the `src/*/fuzz` crates attach to
  `test/cargo-fuzz`, which the root workspace does not include. Run it over both
  manifests, keyed by edition, since `gen` is an identifier in 2021 but a
  reserved keyword in 2024 and rustfmt cannot parse a file at the wrong edition.
* Pin `style_edition = "2024"` in rustfmt.toml. Formatting style is repo-wide
  and independent of the edition a crate parses as, but overriding `edition`
  per package would otherwise drag the 2021 import-sorting style along with it.

Runner and profiles:
* Release qualification runs `--profile all`, not `--profile fruitful`. A target
  nobody runs is a target whose oracle can quietly stop asserting anything, and
  the 24h budget is the only place the low-yield targets get exercised at all.
  The profile docs said the opposite.
* Pair the two durable-state decoders (rollup = full snapshot, state diff =
  incremental) in `fruitful`. Both are read from blob/consensus on every state
  load, so a decode panic there makes the shard unloadable.
* Add `strconv_parse_interval` to `fruitful`. Every other hand-written strconv
  text parser is in the "untrusted text/bytes parsers" bucket, so what that
  file's own doc calls "the most intricate parser in strconv" was the one
  omission, and did not run under the 24h release-qualification profile at all.
* Record that ASan does not cover the cc-built C dependencies. The
  justification for defaulting `--sanitizer=none` claimed our targets cannot
  find memory-corruption bugs. Some can: `ProtoNumeric` decode hands untrusted
  bytes to libdecnumber's `decPackedToNumber`, which writes past the coefficient
  it is given. Keep the default, because ASan does not help there either.
  cargo-fuzz instruments through RUSTFLAGS while the C is compiled by the `cc`
  crate without them, so the FFI writes stay invisible unless CFLAGS carry
  `-fsanitize=address` too. Say so, so the next reader does not read the old
  wording as "no target can hit memory corruption".

mz-avro `schema_resolve`:
* Require the resolutions documented as infallible instead of guarding them.
  Identity and writer->reader resolution succeed by construction, so
  `if let Ok(..)` meant a promotion that regressed at a non-union position came
  back as a top-level `Err` and switched the decode oracle off for exactly the
  inputs that found it. Deleting `ResolveIntDouble` from reader.rs now fails
  within seconds; before it left the target green.
* Draw the encoded union branch from the input. Decoding consults only the
  resolved permutation entry for the branch on the wire, so the fixed
  first-promotable choice left every other entry's deferred `Err` unobserved,
  the exact failure mode this target exists for.
* Reach the other two union arms: the reader rendering now sometimes collapses a
  union to the encoded branch (`ResolveUnionConcrete`) and sometimes wraps a
  concrete node in one (`ResolveConcreteUnion`), whose no-match error path has a
  fixed out-of-bounds history. Unions also carry named variants now, for the
  name-keyed side of variant matching.
* Drop trailing reader enum symbols so the `default` becomes load-bearing, and
  require each decode to consume every byte the writer wrote.

mz-avro `reader_decode`:
* Straddle `safe_len` deliberately. All three "corrupt framing" magnitudes sat
  above `MAX_ALLOCATION_BYTES`, so every one of them stopped at that guard: the
  short-read case was dead and duplicated the negative-size case, and the
  accepted-but-oversized count band, where the bug this target is credited with
  finding still lived, was never generated.
* Stop generating schemas the parser rejects outright (`fixed` size 0,
  `["null","null"]`, nested unions), which discarded ~6% of inputs at the header
  with no block decoding at all.

mz-avro `avro_schema_parse`:
* Reach `MAX_SCHEMA_DEPTH`. It was unreachable through `Schema::from_str`:
  serde_json's own 128-container limit fires one level first, so the target's
  headline claim of driving the guard past its limit was never true. The deep
  case now goes through `Schema::parse`, and the text case is bounded to nesting
  serde_json accepts. Adds the unit test the depth guard never had, against a
  hand-built `Value`, plus one pinning why the text entry point cannot reach it.
* Generate two named types sharing a fullname, the only shape that reaches
  `alloc_name`'s duplicate rejection.
* Correct the doc: two of the "must reject" shapes are accepted today
  (`parse_record` has no duplicate-field-name check, and `parse_bytes` swallows
  a decimal `scale > precision`), and alias collisions mean nothing until
  `resolve_schemas`, which this target never calls.
* Seeds are prefixed with the byte that selects `reader_decode`'s raw-bytes
  branch. `reader_decode` reads its input as an `Unstructured` recipe, so the
  seeded container file was being consumed as generator choices and never
  reached the magic-header check that prepare-corpus.sh exists to skip past.

Catalog serde round-trip:
* Route the oracle through the durable encoding. The on-disk form is not JSON
  text: `StateUpdateKindJson` packs the serde value into a `Jsonb`, i.e. an
  `mz_repr::Row`, and reads it back through `to_serde_json` + `from_value`. That
  leg is the one that can lose information, since every number becomes a
  `Datum::Numeric` and object keys are deduplicated and reordered by the `Row`
  map encoding, and `to_vec`/`from_slice` never touches it. The text form is
  still asserted alongside it.
* Add `StateUpdateKind`, the durable envelope every catalog write goes through
  and the only member of this family whose round trip is not trivially total: it
  is `#[serde(tag = "kind")]`, so it deserializes through serde's
  content-buffering path. Every other reachable field bottoms out in integers,
  bools, strings, `Vec`, `Option`, or fieldless enums, for which the derived
  `Serialize`/`Deserialize` are inverses by construction.
* Drop the `json == json2` assertion. Serialization is a pure function of the
  fields and every reachable type derives structural `PartialEq`, so it was
  implied by the value equality above it. It earns its keep in the proto
  siblings this was modelled on, where `from_rust` + `encode_to_vec` can differ
  for equal values, but not here.
* Both arms now dispatch over one type list in the same order, so a corpus
  entry's type byte survives a one-bit change to the arm byte. Seed the corpus
  and add a dictionary: the raw arm feeds the input straight to
  `from_slice::<T>`, which returns nothing until the input is a complete object
  with the right field names and variant tags, so the nested types were
  unreachable.

`build_regex`:
* The target capped patterns at 4096 chars on the premise that "the `regex`
  crate is size-limited, so an oversized pattern returns an error rather than
  OOMing". That premise is wrong. `size_limit` bounds only the compiled NFA. The
  memory is spent earlier, translating the pattern's AST into `regex-syntax`'s
  HIR, which `size_limit` never sees, and cost there scales with the pattern's
  source length. Measured with regex 1.12.4 and the exact `RegexBuilder`
  configuration `build_regex` uses, `\p{L}` repeated under `i`, peak RSS is
  ~7.4 KB per pattern byte against ~0.35 KB for a plain literal: 4 095 bytes ->
  47 MB, 524 285 -> 3 903 MB, 1 048 575 -> 7 792 MB, all returning
  `CompiledTooBig`. The cap sat 87x under the runner's `-rss_limit_mb=4096` and
  was dead besides, since the runner passes no `-max_len` and libFuzzer's
  default is 4096 bytes.
* Add a rare `gen_long_pattern` arm that synthesizes a long pattern by repeating
  one small unit, so the length comes from a handful of input bytes rather than
  from `-max_len`, and straddles the 1 MiB guard. Drop the pattern cap, keep the
  text cap. The arm now reaches 4043 MB of peak RSS, and libFuzzer reports an
  11-byte input that spends 59 s building a 513 845-byte pattern.
* Correct three doc claims: the module premise above, `gen_near_limit`'s claim
  that its output can hit `PatternTooLarge` (those patterns are <= ~45 bytes, so
  only `CompiledTooBig` is reachable), and its example, which put the innermost
  quantifier inside the group. `REPLACEMENT`'s `$99` was documented as an
  out-of-range capture index but parses as the capture *name* `99y`, since an
  unbraced `$name` takes the longest run of `[0-9A-Za-z_]`, which took the
  name-lookup path and silently swallowed the trailing literal. Brace it as
  `${99}`.

`EvalError` proto round-trip:
* The round-trip assertion alone certified a decoded `EvalError` that aborted
  the process the moment anything looked at it: `OutOfDomain(None, None, _)`
  survives the wire intact, and `Display` panicked on exactly that state. Assert
  that the decoded value also renders, which is what `DataflowErrorSer::Display`
  does with untrusted bytes on the index peek path. Verified falsifiable both
  ways: a 15-byte mode B input crafted to decode as
  `OutOfDomain(None, None, "x")` crashes the target against the pre-fix
  `Display` and passes after it, and 1.3M executions are clean on the fixed
  tree.
* Handle a `new_tree` rejection by returning rather than panicking, matching the
  sibling round-trip targets, so a generator limit cannot be reported as a
  product crash. Correct the module doc: `EvalError` carries no
  `NumericMaxScale`, and it is mode B, not mode A, that reaches the rejection
  branches of `char::from_proto` and `usize::from_proto`, since mode A always
  starts from an already-valid Rust value.

Transform equivalence oracles:
* Add `hits_non_strict_error_fold`, which skips plans matching the open bug
  CLU-137 rather than rediscovering it on every run. `Or::eval` returns `true`
  the moment it sees a true operand and drops any error it collected,
  `And::eval` does the same for `false`, and `error_if_null` evaluates its
  message operand only when the first operand is NULL, yet `reduce`'s generic
  variadic fold replaces the whole call with an operand's literal error and
  `undistribute_and_or` can recombine an erroring operand across the
  short-circuit boundary. Deliberately conservative: it asks whether an operand
  *could* error, because `reduce` folds a column-free fallible operand to a
  literal error first and absorbs it after. Measured at 3.6% of
  `gen_rel(depth = 4)` plans skipped.

`interchange::avro::Decoder` over a fuzzer-generated reader schema:
* Gate every "don't build a valid body" branch on remaining input.
  `Unstructured::int_in_range` does not fail once the data is exhausted, it
  returns the low end of the range forever, and schema generation consumes
  input greedily, so the ungated `== 0` branches were taken by every exhausted
  input. Measured over 400k executions with the arms instrumented: empty bodies
  fell from 36.5% to 4.3% of schema-directed runs, the clean-body oracle armed
  on 5.8% before and 13.6% after, and the same budget found 1035 new coverage
  units against 608, at a 7% throughput cost.
* Make the corruption arms corrupt. `truncate(keep)` drew `keep` from
  `0..=b.len()`, so a `keep == b.len()` draw was a no-op, and the byte flip used
  `^= arbitrary::<u8>()`, so a zero mask was one too.
* Stop generating schemas mz-avro rejects, which discarded the whole input:
  `fixed` size 0 (`parse_fixed` requires a positive size) and
  `["null",["null",T]]` (`UnionSchema::new` rejects a union directly inside a
  union). Schema rejection went from 3.4% of schema-directed runs to zero.
* Bound a `decimal`-over-`fixed`'s precision by the chosen byte run. Precision
  and size were drawn independently, and `parse_fixed` silently demotes an
  over-precision decimal to a plain `fixed` with a `warn!` rather than an error,
  so 10.2% of generated `DecimalFixed` types never reached the decimal path
  while the harness still believed they had.
* Assert a clean body is consumed in full. A length-accounting bug that
  under-consumes while still packing a well-formed `Row` passed every other
  oracle.
* Correct two coverage claims. The `assert_success` oracle cannot reach the
  deferred union-promotion class it cited (MaterializeInc#37087): with
  `WriterSchemaProvider::None` the resolver returns the reader schema verbatim,
  so no `SchemaPiece::Resolve*` piece is ever built, and that class is covered
  by `avro/fuzz::schema_resolve` instead. And the target never runs
  `validate_schema_1`/`validate_schema_2`/`get_union_columns`, which are
  reachable only from `schema_to_relationdesc`, so what it exercises is the
  runtime `AvroFlatDecoder::union_branch`, not the column-name invention.

`mz_repr::adt::interval` proto round-trip:
* Replace the `interval_proto_roundtrip` fuzz target with a proptest next to the
  type. `Interval` <-> `ProtoInterval` is a field-for-field copy of three
  integers, so there is no untrusted-input surface for a fuzzer to explore: the
  property only bites once the two structs drift, which a proptest over
  `any::<Interval>()` catches just as well and runs in CI on every commit.

`strconv` time and timestamp round-trips:
* `strconv_parse_time` skipped every parsed value whose nanosecond field was not
  an exact multiple of 1000, then asserted exact equality across
  `format_time`/`parse_time`. Post-filter that pair is a bijection, so neither
  the assertion nor the re-parse-failure return could ever fire and the
  effective property was only "`parse_time` must not panic". The skipped region
  was also the only part of the space where the renderer does anything
  interesting, so `format_time` never saw a sub-microsecond fraction or a leap
  second at all. Render unconditionally and assert bounded drift instead: the
  renderer writes microseconds rounding half away from zero, so a faithful
  rendering moves the value by at most half a microsecond. Measure the drift as
  nanoseconds from midnight rather than field by field, because a fraction that
  rounds up legitimately carries into the seconds field and on past a minute or
  hour boundary. The one looser case is a carry out of the last second of the
  day, where there is no second to carry into and the fraction saturates at
  `.999999`. Falsifiable: dropping the renderer back to truncation is reported
  in under a minute (`00:08:00.000000800` renders as `00:08:00`).
* Both timestamp targets fixed the rounding precision at `None`, i.e.
  microseconds, which makes `round_to_precision`'s rounding branch dead: the
  quantum is a single microsecond, so every value is already on a boundary and
  only the seventh-digit nudge runs. The cast they mirror carries an arbitrary
  `Option<TimestampPrecision>` from the column's type modifier, and a precision
  below 6 is where rounding up off the end of `HIGH_DATE` is easy to reach, a
  single `.5` at precision 0 rather than seven exact digits. Take the precision
  from the input.
* Add a second oracle for the consumer that does not round.
  `mz_pgrepr::Value::decode_text`, behind COPY and text-format bind parameters,
  stores the parsed nanoseconds as they are, so for it the renderer is itself
  the rounding step, and rendering plus re-parsing must land exactly on the
  microsecond-rounded value. The carve-out is where rounding up leaves chrono's
  range: the renderer has no error channel and saturates the fraction instead,
  so there is nothing for the two to agree on.
* Drop both escape hatches. A parsed `:60` now round-trips, so the leap-second
  carve-out's comment no longer holds. The re-parse-failure return's stated
  reason, a year of more than four digits, was simply wrong:
  `262142-12-31 23:59:59` parses and round-trips byte-identically, and over
  395,498 successful parses the guard fired zero times. A renderer that emits
  text the parser rejects is a bug to report, not tolerate, so both are now
  `expect`.
* Add `strconv_parse_timestamp.dict` and `strconv_parse_timestamptz.dict`.
  Neither end of the accepted range is reachable by mutation alone: `HIGH_DATE`
  is the single date `262142-12-31`, so hitting it means mutating a 6-digit
  ASCII integer onto one exact value while a late time-of-day and a westward
  offset are already in place, and the range check offers no coverage gradient
  to climb, just one saturated edge for "accepted" and one for "rejected".
  `src/repr/fuzz` shipped no dictionary at all, and its corpus directory is
  gitignored, so there were no seeds either. With the boundary dates, the
  round-up fractions and the sub-minute offsets spelled out as tokens, an
  empty-corpus run reproduces the timestamptz rounding overflow in under three
  minutes, on `(precision 2, "262142-12-31 ?23:59:59.9999995")`.
* Verified by A/B: against the pre-fix renderer the unrounded oracle fails on
  `2020-01-01 00:00:00.9999999` with `00:00:01` vs `00:00:00.100`, and on a leap
  second because the rendering no longer parses. Against the fixes, three
  minutes each of `strconv_parse_timestamp` (7.1M execs),
  `strconv_parse_timestamptz` (7.2M) and `strconv_parse_time` (3.6M) are clean.

`strconv_parse_interval`:
* Assert the round trip the module doc names. The oracle returned silently when
  the rendering failed to re-parse, so only the "if it re-parses, values agree"
  half was checked and any rendering the parser rejects was a silent success. The
  justification given for that carve-out does not exist: the hours field is the
  only unbounded one and is derived as `(micros / 1_000_000).abs() / 3600`, so
  multiplying it back out during the re-parse cannot exceed `|micros|` and cannot
  overflow. `i64::MAX` micros renders as `2562047788:00:54.775807` and re-parses
  exactly, as does `i64::MIN`. A direct sweep of the `(months, days, micros)`
  space, 300,776 triples over both extremes, an edge grid and a pseudo-random
  fan-out, produced zero re-parse failures and zero mismatches, so re-parse is
  total and the branch was dead.
* What the tolerance cost, measured by mutating `Display`'s months spelling to
  one `parse_interval` rejects: the tolerant oracle ran 3,570,705 executions and
  reported success, while the strict one reports the 4-byte input `1mon`
  immediately. Against the real renderer 5.2M executions are clean.
* Render through `strconv::format_interval` rather than `Display` directly, since
  that is the entry point `::text` and the pgwire encoders call. The two agree
  today, `format_interval` being a `write!` of `Display`, so this only keeps the
  target pointed at the right function if that stops being true. This matters for
  a type whose rendering is load-bearing and has already changed once: the unit
  spelling moved to `mon`/`mons` in v26.35 (SQL-463), filed as a wrong-result bug
  because a client's parser stopped at the old spelling.

`strconv_parse_date`:
* Assert the `Nestable` verdict, not just the round trip. `format_date` returns a
  `Nestable` alongside the rendering and the target dropped it, leaving half the
  contract unchecked, and that half was wrong: the ` BC` suffix carries
  whitespace, which every element escaper quotes for, yet `Nestable::Yes` was
  returned unconditionally. A wrong `Yes` is invisible to a round-trip oracle,
  because the wrongly-unquoted rendering still re-parses, so it needs an
  assertion of its own. Checked against the real escapers through
  `element_needs_escaping` rather than restating their rules in the target, where
  a copy would go stale: SQL-466 already added a rule to that predicate.
* Make the re-parse a hard failure. The tolerance was borrowed from the timestamp
  targets and neither reason applies to DATE. `format_date` writes no time
  component, so the leap-second gap cannot appear in its output, and a year of
  more than four digits parses, since `fill_pdt_date` reads a 6-digit leading
  number followed by a dash as a full year. Sweeping the whole `Date` range,
  exhaustively at both ends and across the BC/AD transition, leaves exactly four
  reachable forms, a 4, 5 or 6 digit Common Era year and a 4 digit BC year, and
  100,962 sampled dates produced zero re-parse failures. The branch was dead.
* Verified by A/B: with `format_date`'s verdict put back to an unconditional
  `Nestable::Yes` the target reports `8==8=8= BC` within seconds, and against the
  fix 7.9M executions are clean.

`sql_server_table_desc_proto_roundtrip`:
* Assert the constraint-string arm's oracle. The module doc and an inline comment
  both stated it, "garbage strings must be rejected, valid ones must parse", and
  neither half was checked: `Err` was discarded by an early `return`, and on `Ok`
  nothing looked at which spelling produced it. The arm's whole input space is
  `CONSTRAINT_TYPES.len() * 4` fixed cases, so without that assertion it
  contributed nothing beyond "does not panic", and it was blind to all three ways
  the parser can regress. A normalization or catch-all arm would start accepting
  `primary key`; a valid spelling ceasing to parse would make every input take the
  early return, leaving an inert arm indistinguishable from a passing one; and
  swapping the two variants is preserved by a round trip. The variant is not
  cosmetic, it drives `is_primary` on the `TableConstraint::Unique` that
  purification writes into the generated subsource DDL, so misparsing a non-unique
  upstream constraint as `PrimaryKey` declares a key that does not hold. Verified
  by mutating the product parser three ways: trim/uppercase normalization,
  `"PRIMARY KEY"` no longer parsing, and the two variants swapped. Each is now
  reported within seconds, and the unmutated tree is clean.
* Give every arm its bytes from offset 1. Only the proptest arm consumes the
  32-byte seed, yet all four skipped past it, so the other three read their
  selector bytes from offset 33 and saw nothing but defaults until libFuzzer grew
  inputs that far. That also meant the raw-bytes arm, the one meant to consume a
  real encoded descriptor, decapitated any genuine blob added to the corpus, which
  `--corpus-sync` accumulates across runs. The same 3-minute budget now reaches
  3594 edges and 18382 features against 3507 and 17215 before.
* Unlock two `parse_data_type` branches that were unreachable by construction, not
  merely rare. `precision` and `scale` were drawn `% 39`, capping both at 38, so
  the `precision > 39` rejection and the `NumericMaxScale::try_from` failure could
  never fire, while the header claimed the type list exercises every branch of the
  mapping. `% 45` reaches both, and `SqlServerColumnDesc::new` folds either
  rejection into an `Unsupported` decode type that still round-trips.
* Pin `max_length` 4 and 8. `real`/`float`/`double precision` select `F32`/`F64`
  by byte width, not by name or precision, so the two decode types the header
  documents were reachable only by luck through the two random length branches.
  The inline comments claimed precision selected them, which was never the rule.
* Drop the per-column leaf round-trip loop. The table's `into_proto`/`from_proto`
  delegate per element and equality is structural, so the table-level assertion
  already implies it. The loop just doubled the arm's conversion work.
* Give the constraint arm's descriptor the columns its constraint names, instead
  of pairing a constraint over `c0..cn` with an empty column list. Harmless against
  today's oracle, but a constraint/column consistency check in `from_proto` would
  have been reported as a crash on the arm's own inputs.

Transform oracle helpers:
* Stop swallowing `TransformError`. `optimize` discarded the whole enum with
  `.ok()`, documented as skipping the case where "the `Typecheck` pass rejects the
  plan". That case cannot happen. `Typecheck::actually_perform_transform` returns
  `Ok(())` on every path and routes a type error through
  `type_error!(true, ..)` -> `soft_panic_or_log!`, as does `Fixpoint`
  non-convergence, and soft assertions default to `cfg!(debug_assertions)`, which
  cargo-fuzz enables. Confirmed by handing `optimize` a `Filter` with an `int4`
  predicate in the built fuzz binary: it panics at `typecheck.rs:1672`, it does
  not return `None`. So the comment sent anyone extending the generators looking
  for a silent skip that does not exist, while a rejected plan actually lands as a
  `crash-*` artifact pointing into `Optimizer::optimize`, which CI reports as an
  optimizer bug.
* What could still reach `.ok()` was the opposite of benign: every remaining
  `TransformError` over these plans is an optimizer invariant violation, a
  `Let`/`Get` on an unbound local id, a `Let` whose type changed under it, an ANF
  rebinding that lost an identifier, all of which reach users as `internal error`.
  `NormalizeLets` runs on every input and `RelationCSE` in `fixpoint_logical_02`,
  so the harness ran through this code constantly while being structurally unable
  to report anything in it. Measured false-pass width by making
  `NormalizeLets::actually_perform_transform` return
  `Internal("injected invariant violation")` on every input: the old oracle
  completed **367,428 executions and reported success**. It now panics on the
  first one. `fold_to_multiset`'s `.ok()?` and `collapse`'s two
  `is_err() -> StuckFixpoint` arms folded the same class into a skip and get the
  same treatment; `StuckFixpoint` still covers genuine non-convergence.
* Record why the `EvalError` skip in `fold_to_multiset` is required rather than a
  blind spot. `predicate_pushdown` knowingly pushes a predicate that can error
  into a join input and manufactures an error the unoptimized plan never raises
  (database-issues#6258), so one side folding to an error while the other yields
  rows is accepted behaviour, measured at roughly 1 in 1,500 executions. Without
  the reference the next reader is likely to "fix" it into an assertion.
* Document `gen_rel`'s arity contract. The doc spelled out the non-negativity
  contract for the caller-supplied `leaf` but not that it must return at least one
  column. A zero-arity leaf makes the `Project` arm's `int_in_range(1..=arity)`
  an empty range and underflows `arity - 1`, and worse than either panic,
  `join_scalars` drops an arity-0 single-row input *after* `join` computed the
  equivalences' global column offsets over the full input list, so the
  equivalences and the returned schema would silently point at the wrong columns.
  Both in-tree leaves return 1-3 columns, so this is a contract note, not a fix.

Transform generators and the three transform targets:
* Build some nodes as raw variants instead of through `MirRelationExpr`'s smart
  constructors, which normalize away exactly the shapes the structural fusions
  collapse: `map` extends an existing `Map`'s scalar list, `negate` cancels an
  enclosing `Negate`, `union` flattens its operands, `project` composes into an
  existing `Project`, `filter` merges/sorts/dedups predicates. `MapFusion`,
  `NegateFusion` and `UnionFusion` therefore could not fire on *any* input, so
  their two assertions compared a plan against a clone of itself. Measured over
  25,714 plans before: 0, 0 and 0 firings against FilterFusion's 16,526 (which
  survives only through its `canonicalize_predicates` call) and ProjectFusion's
  6,046 (through identity-projection removal). After: 939, 2,934 and 10,504,
  with the other three unchanged or slightly up.
* Rebuild `wrap_preserving` from raw variants. Two of its four arms were
  unconditional no-ops, `filter` dropping a literal-true predicate and
  `negate().negate()` cancelling to the identity, which silently reduced the
  structural-rewrite oracle to `fold(rel) == fold(rel)`. It now changes the plan
  on every input (40,001 of 40,001) against 81.6% before.
* Apply each fusion in the traversal order its real driver uses. `fusion::union`
  and `ProjectionExtraction` call `visit_mut_post`; only the
  `Filter`/`Project`/`Map`/`Negate` fusions are pre-order. The comment claimed
  all six matched their drivers.
* Report the input plan, not the folded one, in `FoldConstants`' shape assertion.
  Every other call site passes the original; this one had moved it.
* Plan with production's `OptimizerFeatures`, not `Default::default()`, which is
  all-`false` and is what no deployment runs. 14 of the 19 boolean optimizer flags
  default to `true` in production, including the three `EquivalencePropagation`
  reads. `enable_eq_classes_withholding_errors` is the pointed one: it exists to
  stop equivalence propagation suppressing errors, and `gen_scalar` deliberately
  seeds error literals, so the one feature built for this generator's input class
  was the one turned off. `fuzz_features` names only the flags whose
  production default is `true` and takes the rest from `Default`, so it cannot
  break this crate's slow sanitizer build. The tripwire for a newly added flag
  stays where it is cheap: `mz_sql`'s
  `optimizer_features_no_enable_for_item_parsing` already destructures
  `OptimizerFeatures` exhaustively, and now says to enable the flag in
  `fuzz_features` too when it ships on, per the repo convention that a new flag is
  ON in the test configuration.
* Cap the oracles' constant folding at `FOLD_ROW_LIMIT` rows. `FoldConstants`'
  join arm materializes the full cross product before applying equivalences and
  `limit: None` disables its only size check, so the harness could ask for a
  `Vec<(Row, Diff)>` bounded only by the product of every leaf. Declining to fold
  is already a benign skip on both oracles, and the cap sits well above the
  largest product these generators were measured to reach.
* Optimize before deciding the baseline is comparable, in
  `optimizer_symbolic_equiv`. A plan whose result is an evaluation error folds to
  an `Err` constant, which `collapse` reports as a skip, and returning there meant
  never calling the optimizer on it: `gen_scalar` spends one of three literal-leaf
  choices on `EvalError::DivisionByZero`, so a deliberately generated class of
  input never reached the code under test at all, not even for panic coverage.
* Share one `collapse` between the two equivalence targets.
  `full_optimizer_equiv` folded the optimized side with a single `FoldConstants`
  pass, which cannot fold through a `Let`, while its sibling already iterated
  `FoldConstants` + `NormalizeLets` to a fixpoint "to collapse any `Let`s the
  optimizer's CSE introduced". `RelationCSE` gets perfect candidates from the
  `Union` arm cloning `inner` into both branches, and the skip landed precisely on
  the executions where the post-collapse stages had run on real structure.
* Correct two doc claims. Neither target skips on an optimizer error any more, so
  the `Typecheck`-rejection rationale is gone from both. And of the five
  transforms `full_optimizer_equiv` claimed as its unique coverage, only
  `NonNullRequirements` and `RedundantJoin` run before step 2's trailing constant
  folding; `Demand`, `SemijoinIdempotence` and `ReduceElision` sit in
  `fixpoint_logical_01`/`_02` and see a bare `Constant`. Nothing in the suite
  covers those three against exact key facts, which the doc now says.
* Record that `Collapse::StuckFixpoint` conflates "folding could not evaluate it"
  with "it folded to an `Err`", that only the second is a determined result, and
  why the tempting `Ok -> Err` assertion is not available today
  (`predicate_pushdown` manufactures such errors, database-issues#6258).

Carving out CLU-137, once, in one place:
* Give the shape a single definition,
  `MirScalarExpr::could_hit_nonstrict_error_fold`, in `mz_expr` next to the fold it
  describes. Four oracles now compare evaluation across `reduce` and each needs to
  skip it; two had already drifted, since the transform helper counted
  `ErrorIfNull` as non-strict and `mir_scalar_reduce`'s copy did not.
* Apply it to `mfp_optimize`, which had no carve-out at all. `optimize` reduces
  every map expression and predicate, and that target generates `.and()`/`.or()`
  over a fallible vocabulary, so all three of its assertion arms were exposed: a
  folded error makes a row the raw MFP passed error out, the literal is typed
  non-nullable so a nullability-dependent rewrite above it can change the projected
  value, and a predicate folded that way drops the row. Its existing tolerance
  covers none of those, because it only forgives an error on a row the raw MFP
  *filtered*. Measured cost of the skip: 2.2% of MFPs (30,576 of 1,400,000).
* `mir_scalar_reduce` keeps its row-precise variant rather than adopting the coarse
  predicate, because the shape is common in its generator and skipping every
  expression carrying it would cost most of the comparisons. The relationship
  between the two is now written down at both ends.

`mir_scalar_reduce`:
* Skip a row that absorbs a non-strict AND/OR operand error outright, instead of
  merely allowing it to reduce to an error. The old carve-out was gated on the
  reduced side also being an error, justified as "an absorbed error cannot
  resurface as a value". The fuzzer falsified that after 2.2M executions:
  `Or([Literal(Err(DivisionByZero)), Column(3)])` evaluates to `Ok(True)`, because
  `Or::eval` returns true on its first true operand and drops the error it
  collected, while reduce replaces the whole call with the literal error. From
  that point the reduced expression is folding a value the original never
  computed, and the rewrites downstream do not all propagate it: the reported case
  turned `CastBoolToString(IsNull(If(..)))` from `Ok("true")` into a literal
  `Ok("false")`.
* That is CLU-137, which PR MaterializeInc#37299 did not fix, and the wrong value is downstream
  of it rather than a second defect, verified by reducing the `Or` in isolation.
  So the whole row goes, matching how the transform targets skip the same shape
  via `hits_non_strict_error_fold`. The skip stays per-row rather than per-
  expression, so every non-absorbing row of an affected expression is still
  compared.
* Checked that this does not blunt the oracle: swapping `reduce_if`'s literal-
  condition branches is still reported within seconds, and 4.8M executions are
  clean against the unmutated tree.

Workspace-wide sweep of the remaining targets:
* The same three corrections applied across the rest of the suite: generators
  that reach the decode instead of stalling at an early reject, oracles that
  assert the property they name rather than a weaker one it implies, and module
  docs pruned of coverage that was never reached. Touches the targets in
  `repr` (row/arrow/codec round-trips, `strconv` parsers, `numeric_arith`,
  `range_ops`, the type-modifier proto round-trips), `expr` (`jsonb_get`,
  `jsonb_path`, `cast_string`, `like_pattern_compile` and `like_pattern_escape`,
  `agg_decompose`, `mir_scalar_reduce`, `timezone_convert`, `mfp_optimize`),
  `pgwire` (`codec_decode`, plus corpus dictionary and seeding), `pgcopy`,
  `pgrepr`, `pgtz`, `storage` (the four upsert targets), `storage-types`,
  `sql-parser`, `mysql-util`, `postgres-util`, `sql-server-util`,
  `persist-client` and `interchange`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@def-

def- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by stack #37980, which splits this into one PR per region with the cargo-fuzz oracle change at the top:

  1. persist: harden state diffs, part stats and spine fuel against malformed input #37971 persist — state diffs, part stats, spine fuel
  2. avro: bound an OCF block's object count and stop block bleed-through #37972 avro — OCF block object count and block bleed-through
  3. sql-parser: only start a map literal when a bracket follows MAP #37973 sql-parser — map literal
  4. expr: don't memoize a fallible operand out of an AND/OR #37974 expr — AND/OR memoization error suppression, unbounded OutOfDomain
  5. repr: reject corrupt proto shapes and out-of-domain type modifiers when decoding #37975 repr — proto decode: corrupt shapes and type-modifier domains
  6. repr: bound regex compile memory, not just the pattern's byte length #37976 repr — regex compile memory
  7. repr: fix strconv date/time and range parsing and rendering edge cases #37977 repr — strconv date/time and range parse/render
  8. pgrepr: reject numeric infinity as a binary parameter #37978 pgrepr — numeric infinity as a binary parameter
  9. tests: sharpen the cargo-fuzz oracles across the workspace #37979 tests — sharpen the cargo-fuzz oracles

Same content, same commits, rebased on main. Closing this one.

@def- def- closed this Jul 31, 2026
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