From d0f9240567c64b79019aa045388de160dd8dd3d7 Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Wed, 29 Jul 2026 22:48:29 +0000 Subject: [PATCH 1/4] expr: render an unbounded OutOfDomain instead of panicking `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) --- src/expr/src/scalar.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/expr/src/scalar.rs b/src/expr/src/scalar.rs index f2bd2c6635af7..a7f37a0d3629e 100644 --- a/src/expr/src/scalar.rs +++ b/src/expr/src/scalar.rs @@ -1968,7 +1968,13 @@ impl fmt::Display for EvalError { (Exclusive(lo), Inclusive(hi)) => { write!(f, "between {lo} exclusive and {hi} inclusive") } - (None, None) => panic!("invalid domain error"), + // No caller constructs an unbounded domain, but a corrupted or + // forged `ProtoEvalError` decodes into one. Render it instead of + // panicking: `DataflowErrorSer::Display` decodes errors straight + // out of a persist shard and Displays them on the index peek + // path, so a panicking arm here wedges the dataflow on every + // retry rather than producing a bad error message once. + (None, None) => write!(f, "in an unspecified range"), } } EvalError::ComplexOutOfRange(s) => { @@ -2461,6 +2467,19 @@ mod tests { use super::*; use crate::scalar::func::variadic::Coalesce; + /// An `OutOfDomain` with both limits unset is not constructible by any + /// caller, but it decodes out of corrupted or forged `ProtoEvalError` bytes, + /// and `DataflowErrorSer::Display` renders decoded errors on the peek path. + /// Rendering it must not panic. + #[mz_ore::test] + fn test_unbounded_out_of_domain_renders() { + let err = EvalError::OutOfDomain(DomainLimit::None, DomainLimit::None, "f".into()); + assert_eq!( + err.to_string(), + "function f is defined for numbers in an unspecified range" + ); + } + #[mz_ore::test] #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux` fn test_reduce() { From 51d0f4731ea6de9d1ecf124616cb214c59abd03a Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Thu, 30 Jul 2026 08:54:46 +0000 Subject: [PATCH 2/4] tests: sharpen the cargo-fuzz oracles across the workspace 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::`, 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::()`, 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 (#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::()` 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` 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 #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) --- .../pipeline.template.yml | 5 +- misc/python/materialize/cli/fmt.py | 61 +- rustfmt.toml | 5 + src/avro/fuzz/Cargo.toml | 4 + src/avro/fuzz/corpus.dict | 14 +- .../fuzz/fuzz_targets/avro_schema_parse.rs | 110 +++- src/avro/fuzz/fuzz_targets/reader_decode.rs | 95 ++- src/avro/fuzz/fuzz_targets/schema_resolve.rs | 289 +++++--- src/avro/fuzz/prepare-corpus.sh | 11 +- src/avro/src/schema.rs | 43 ++ src/catalog-protos/fuzz/Cargo.toml | 4 + .../fuzz/catalog_objects_serde_roundtrip.dict | 100 +++ .../catalog_objects_serde_roundtrip.rs | 107 +-- src/catalog-protos/fuzz/prepare-corpus.sh | 67 ++ src/expr/fuzz/fuzz_targets/agg_decompose.rs | 177 +++-- src/expr/fuzz/fuzz_targets/build_regex.rs | 93 ++- src/expr/fuzz/fuzz_targets/cast_string.rs | 2 +- .../eval_error_proto_roundtrip.rs | 53 +- src/expr/fuzz/fuzz_targets/jsonb_get.rs | 99 ++- src/expr/fuzz/fuzz_targets/jsonb_path.rs | 27 +- .../fuzz/fuzz_targets/like_pattern_compile.rs | 66 +- .../fuzz/fuzz_targets/like_pattern_escape.rs | 132 +++- src/expr/fuzz/fuzz_targets/mfp_optimize.rs | 158 +++-- .../fuzz/fuzz_targets/mir_scalar_reduce.rs | 113 +++- .../fuzz/fuzz_targets/timezone_convert.rs | 52 +- src/expr/fuzz/like_pattern_compile.dict | 20 + src/expr/src/scalar.rs | 38 ++ .../fuzz_targets/avro_decode_fuzzed_schema.rs | 211 ++++-- .../fuzz/fuzz_targets/json_encode.rs | 198 +++++- .../protobuf_decode_fuzzed_schema.rs | 138 ++-- .../mysql_table_desc_proto_roundtrip.rs | 137 ++-- .../fuzz_targets/rollup_proto_roundtrip.rs | 112 +++- .../state_diff_proto_roundtrip.rs | 101 ++- src/pgcopy/fuzz/fuzz_targets/copy_decode.rs | 81 ++- .../fuzz/fuzz_targets/value_decode_binary.rs | 228 +++++-- .../fuzz/fuzz_targets/value_decode_text.rs | 618 +++++++++++------- src/pgtz/fuzz/fuzz_targets/timezone_parse.rs | 187 ++++-- src/pgwire/fuzz/corpus.dict | 5 + src/pgwire/fuzz/fuzz_targets/codec_decode.rs | 213 +++++- src/pgwire/fuzz/prepare-corpus.sh | 32 +- src/pgwire/src/lib.rs | 12 +- .../postgres_table_desc_proto_roundtrip.rs | 179 +++-- src/repr/fuzz/Cargo.toml | 7 - .../fuzz_targets/acl_item_proto_roundtrip.rs | 14 +- .../column_type_proto_roundtrip.rs | 108 ++- .../fuzz_targets/interval_proto_roundtrip.rs | 76 --- src/repr/fuzz/fuzz_targets/numeric_arith.rs | 2 +- src/repr/fuzz/fuzz_targets/range_ops.rs | 13 +- .../relation_desc_proto_roundtrip.rs | 174 ++++- .../fuzz/fuzz_targets/row_arrow_roundtrip.rs | 19 +- .../fuzz/fuzz_targets/row_codec_roundtrip.rs | 13 +- .../fuzz/fuzz_targets/row_proto_roundtrip.rs | 3 +- .../scalar_type_proto_roundtrip.rs | 27 +- .../fuzz/fuzz_targets/strconv_parse_date.rs | 33 +- .../fuzz_targets/strconv_parse_interval.rs | 35 +- .../fuzz_targets/strconv_parse_numeric.rs | 19 +- .../fuzz/fuzz_targets/strconv_parse_range.rs | 17 +- .../fuzz/fuzz_targets/strconv_parse_time.rs | 56 +- .../fuzz_targets/strconv_parse_timestamp.rs | 74 ++- .../fuzz_targets/strconv_parse_timestamptz.rs | 72 +- src/repr/fuzz/prepare-corpus.sh | 191 ++++++ src/repr/fuzz/strconv_parse_timestamp.dict | 51 ++ src/repr/fuzz/strconv_parse_timestamptz.dict | 57 ++ src/repr/src/adt/interval.rs | 17 + src/repr/src/strconv.rs | 12 + src/sql-parser/fuzz/build.rs | 12 +- src/sql-parser/fuzz/fuzz_targets/grammar.rs | 69 +- .../fuzz/fuzz_targets/sql_roundtrip.rs | 190 +++++- .../sql_server_table_desc_proto_roundtrip.rs | 181 +++-- src/sql/src/session/vars/definitions.rs | 6 + .../fuzz/fuzz_targets/csv_decode.rs | 51 +- .../dataflow_error_proto_roundtrip.rs | 6 +- .../source_data_proto_roundtrip.rs | 9 +- ...xport_statement_details_proto_roundtrip.rs | 63 +- src/storage-types/src/sources.rs | 1 + .../fuzz/fuzz_targets/upsert_consolidate.rs | 6 +- .../fuzz/fuzz_targets/upsert_runtime.rs | 16 +- .../fuzz_targets/upsert_state_consolidate.rs | 6 +- .../fuzz_targets/upsert_value_roundtrip_v2.rs | 20 +- .../fuzz/fuzz_targets/full_optimizer_equiv.rs | 72 +- .../fuzz_targets/mir_relation_transforms.rs | 175 ++++- .../fuzz_targets/optimizer_symbolic_equiv.rs | 109 +-- src/transform/fuzz/src/lib.rs | 224 ++++++- test/cargo-fuzz/mzcompose.py | 33 +- 84 files changed, 5097 insertions(+), 1639 deletions(-) create mode 100644 src/catalog-protos/fuzz/catalog_objects_serde_roundtrip.dict create mode 100755 src/catalog-protos/fuzz/prepare-corpus.sh create mode 100644 src/expr/fuzz/like_pattern_compile.dict delete mode 100644 src/repr/fuzz/fuzz_targets/interval_proto_roundtrip.rs create mode 100755 src/repr/fuzz/prepare-corpus.sh create mode 100644 src/repr/fuzz/strconv_parse_timestamp.dict create mode 100644 src/repr/fuzz/strconv_parse_timestamptz.dict diff --git a/ci/release-qualification/pipeline.template.yml b/ci/release-qualification/pipeline.template.yml index 99960bdd7575c..70727dcd33f41 100644 --- a/ci/release-qualification/pipeline.template.yml +++ b/ci/release-qualification/pipeline.template.yml @@ -278,12 +278,9 @@ steps: composition: cargo-fuzz ci_builder: nightly args: - - --profile=fruitful + - --profile=all - --max-seconds=86400 - --wall-budget=84600 - # Step hard-times out at 1440min (86400s). --wall-budget ends fuzzing - # at 84600s, leaving 1800s; cap minimize at 1200s so the corpus - # upload has ~600s of headroom before the kill. - --minimize-timeout=1200 - --corpus-sync artifact_paths: diff --git a/misc/python/materialize/cli/fmt.py b/misc/python/materialize/cli/fmt.py index 7ea912bf98a08..9a12b7f631123 100644 --- a/misc/python/materialize/cli/fmt.py +++ b/misc/python/materialize/cli/fmt.py @@ -18,6 +18,12 @@ from materialize import MZ_ROOT from materialize.parallel_task import TaskSpec, run_parallel +# Rust sources live in more than one workspace, and `cargo metadata` only ever +# reports the one it is pointed at. The `src/*/fuzz` cargo-fuzz crates attach to +# the `test/cargo-fuzz` workspace, which the root workspace does not include, so +# without a second invocation here they go unformatted entirely. +RUST_MANIFESTS = ["Cargo.toml", "test/cargo-fuzz/Cargo.toml"] + def main() -> int: parser = argparse.ArgumentParser(prog="fmt") @@ -45,28 +51,43 @@ def _rustfmt_fn(*, check: bool): def run() -> tuple[bool, str]: ncpus = os.cpu_count() or 8 - result = subprocess.run( - ["cargo", "metadata", "--no-deps", "--format-version=1"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return False, result.stderr.strip() - - meta = json.loads(result.stdout) kinds = {"lib", "bin", "bench", "test", "example", "proc-macro", "custom-build"} - paths = [ - t["src_path"] - for pkg in meta["packages"] - for t in pkg["targets"] - if kinds & set(t["kind"]) - ] - if not paths: + # Keyed by edition: `gen` is an identifier in 2021 but a reserved keyword + # in 2024, so rustfmt cannot even parse a file at the wrong edition. + paths_by_edition: dict[str, list[str]] = {} + for manifest in RUST_MANIFESTS: + result = subprocess.run( + [ + "cargo", + "metadata", + "--no-deps", + "--format-version=1", + f"--manifest-path={manifest}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return False, result.stderr.strip() + + meta = json.loads(result.stdout) + for pkg in meta["packages"]: + for t in pkg["targets"]: + if kinds & set(t["kind"]): + paths_by_edition.setdefault(pkg["edition"], []).append( + t["src_path"] + ) + if not paths_by_edition: return True, "" # Split into batches and run rustfmt in parallel. - batch_size = math.ceil(len(paths) / ncpus) - batches = [paths[i : i + batch_size] for i in range(0, len(paths), batch_size)] + batches = [] + for edition, paths in paths_by_edition.items(): + batch_size = math.ceil(len(paths) / ncpus) + batches += [ + (edition, paths[i : i + batch_size]) + for i in range(0, len(paths), batch_size) + ] cmd_base = ["rustfmt", "--config", "error_on_line_overflow=true"] if check: @@ -74,11 +95,11 @@ def run() -> tuple[bool, str]: procs = [ subprocess.Popen( - cmd_base + batch, + cmd_base + [f"--edition={edition}"] + batch, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) - for batch in batches + for edition, batch in batches ] all_output = [] diff --git a/rustfmt.toml b/rustfmt.toml index f8dc60ca8e5d2..b6ab042396f4a 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1,8 @@ edition = "2024" +# Formatting style is repo-wide, independent of the edition a crate parses as. +# The `src/*/fuzz` crates are still edition 2021, and `bin/fmt` overrides +# `edition` per package so they parse at all. Without this pin they would also +# fall back to the 2021 *style*, which sorts imports differently. +style_edition = "2024" tab_spaces = 4 merge_derives = false diff --git a/src/avro/fuzz/Cargo.toml b/src/avro/fuzz/Cargo.toml index 96118872559ff..6072a0f3298ec 100644 --- a/src/avro/fuzz/Cargo.toml +++ b/src/avro/fuzz/Cargo.toml @@ -20,6 +20,10 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" mz-avro = { path = ".." } +# Version must match the root `Cargo.toml`'s. `avro_schema_parse` needs a +# `serde_json::Value` to reach `Schema::parse`, and that value has to be the same +# type `mz-avro` was compiled against. +serde_json = "1.0.150" [[bin]] name = "reader_decode" diff --git a/src/avro/fuzz/corpus.dict b/src/avro/fuzz/corpus.dict index 4740480132fd9..dcf3f2b00d664 100644 --- a/src/avro/fuzz/corpus.dict +++ b/src/avro/fuzz/corpus.dict @@ -1,10 +1,16 @@ -# libFuzzer dictionary for the Avro reader_decode target. +# libFuzzer dictionary for the Avro fuzz targets, resolved crate-wide by +# `dict_for` in test/cargo-fuzz/mzcompose.py. # # An Avro object-container file starts with the 4-byte magic "Obj\x01" followed # by a map of metadata (notably "avro.schema" and "avro.codec") and a 16-byte -# sync marker. Without the magic the decoder bails immediately, so the most -# valuable token by far is the magic itself; the rest let the mutator build -# plausible headers and schemas. +# sync marker. Without the magic the decoder bails immediately, so the magic and +# the schema JSON tokens let the mutator build plausible headers and schemas. +# +# These tokens pay off for the targets that consume their input as *bytes*: +# `avro_schema_parse`, and `reader_decode`'s raw-bytes branch. `reader_decode`'s +# container-building branch and `schema_resolve` instead read their input as an +# `arbitrary::Unstructured` recipe of generator choices, where a token is just +# more choice bytes and carries no structural signal. # Object-container magic. "Obj\x01" diff --git a/src/avro/fuzz/fuzz_targets/avro_schema_parse.rs b/src/avro/fuzz/fuzz_targets/avro_schema_parse.rs index 52cbe61c7a44c..e7970d02ebc04 100644 --- a/src/avro/fuzz/fuzz_targets/avro_schema_parse.rs +++ b/src/avro/fuzz/fuzz_targets/avro_schema_parse.rs @@ -16,20 +16,39 @@ //! //! `schema_resolve` only parses the narrow set of schemas it generates (no //! logical types, no named back-references, shallow), and the decode targets -//! cap nesting low. This one stresses the parser itself: it generates schema -//! JSON that can nest *past* the depth limit (so the guard must fire cleanly -//! rather than overflow the stack) and that re-references already-defined names -//! (recursive definitions, a distinct resolution path). +//! cap nesting low. This one stresses the parser itself: schemas that +//! re-reference already-defined names (recursive definitions, a distinct +//! resolution path), and nesting that straddles `MAX_SCHEMA_DEPTH`. +//! +//! Reaching that guard takes the *other* entry point. `Schema::from_str` runs +//! `serde_json::from_str` first, and serde_json's own recursion limit is also +//! 128 containers, so it rejects the schema one level before the avro guard can +//! fire: the deepest JSON text that survives is 127 wrappers, which is avro +//! depth 128, exactly at the limit and not over it. So the deep case goes +//! through `Schema::parse`, which takes an already-parsed `serde_json::Value`, +//! and the text case is bounded to nesting serde_json will accept rather than +//! generating JSON no avro code will ever see. //! //! It also drives the parser's *naming* and *validation* paths: //! * `namespace` fields and dotted/`a.b.C` names. The `FullName::from_parts` //! split logic, including the documented edge case where a name has dots //! *and* a `namespace` is also given (they may disagree). -//! * `aliases` arrays on named types, sometimes deliberately colliding with -//! another defined name or with the type's own name. -//! * structurally-valid-but-semantically-invalid schemas the parser is meant -//! to *reject* (not panic on): duplicate enum symbols, duplicate record -//! field names, decimal `scale > precision`, and `fixed` `size: 0`. +//! * a name that collides with an already-defined type's fullname, which +//! `alloc_name` must reject. +//! * `aliases` arrays on named types, sometimes colliding with another defined +//! name or with the type's own name. NOTE: parsing only *collects* aliases +//! into the `Name`, so a collision is indistinguishable from any other alias +//! here. Aliases only acquire meaning in `resolve_schemas`, which this target +//! does not call. They are generated to keep the attribute shape varied, not +//! as a validation path. +//! * semantically-invalid schemas, which must produce an error rather than a +//! panic: duplicate enum symbols, an enum `default` outside its symbols, and +//! `fixed` `size: 0` are all rejected. NOTE: two further shapes generated +//! here are *accepted* today, and are generated because the parser has to +//! survive them, not because it rejects them. `parse_record` has no +//! duplicate-field-name check, so a duplicate silently collapses in `lookup` +//! while both entries stay in `fields`; and `parse_bytes` catches a decimal +//! `scale > precision` error, logs it, and falls back to plain `bytes`. //! Parsing must never panic. It returns `Ok`/`Err`. #![no_main] @@ -100,11 +119,22 @@ fn gen_named_attrs( /// error rather than panic. fn gen_invalid(u: &mut Unstructured, counter: &mut u32) -> arbitrary::Result { *counter += 1; - Ok(match u.int_in_range(0u8..=4)? { - // Enum with duplicate symbols. - 0 => format!( - "{{\"type\":\"enum\",\"name\":\"E{counter}\",\"symbols\":[\"A\",\"B\",\"A\"]}}" + Ok(match u.int_in_range(0u8..=5)? { + // Two named types sharing a fullname. The names generated elsewhere carry + // a strictly increasing counter and so can never collide, which leaves + // `alloc_name`'s duplicate-fullname rejection unreachable; this is the one + // shape that reaches it. Both twins are declared bare, with no + // `namespace`, so the collision does not depend on how the enclosing type + // was named. + 5 => format!( + "{{\"type\":\"record\",\"name\":\"Outer{counter}\",\"fields\":[\ + {{\"name\":\"a\",\"type\":{{\"type\":\"record\",\"name\":\"Twin{counter}\",\"fields\":[]}}}},\ + {{\"name\":\"b\",\"type\":{{\"type\":\"record\",\"name\":\"Twin{counter}\",\"fields\":[]}}}}]}}" ), + // Enum with duplicate symbols. + 0 => { + format!("{{\"type\":\"enum\",\"name\":\"E{counter}\",\"symbols\":[\"A\",\"B\",\"A\"]}}") + } // Record with duplicate field names. 1 => format!( "{{\"type\":\"record\",\"name\":\"R{counter}\",\"fields\":[\ @@ -215,14 +245,60 @@ fn gen_type( }) } +/// Enough array layers to straddle `MAX_SCHEMA_DEPTH` (128) from both sides. +const MAX_WRAPPERS: u32 = 300; + fn run(mut u: Unstructured) -> arbitrary::Result<()> { let mut counter = 0u32; let mut defined = Vec::new(); - // Start deeper than MAX_SCHEMA_DEPTH (128) so the fuzzer can drive nesting - // past the limit and exercise the depth guard, not just shallow schemas. - let depth = u.int_in_range(1u32..=200)?; + // Bounded so the generated text always survives serde_json, which refuses + // the 128th nested container. `depth` counts generator levels, not + // containers, and a level spends up to three of them (a record is + // `{record}` → `[fields]` → `{field}` before recursing), with `gen_invalid` + // spending five at the leaf. So a schema nests at most `3 * depth + 2` + // containers, and 40 keeps the worst case at 122. Deep nesting is the + // wrapped path's job below, which is not subject to this limit at all. + let depth = u.int_in_range(1u32..=40)?; let schema = gen_type(&mut u, &mut counter, &mut defined, depth)?; - let _ = Schema::from_str(&schema); + + // Half the inputs go through the text entry point unchanged. The other half + // get wrapped to straddle `MAX_SCHEMA_DEPTH`, which is only reachable behind + // `Schema::parse`. Splitting rather than doing both keeps the cost at one JSON + // parse plus one schema parse per input, the same as parsing the text alone. + let wrappers = if u.int_in_range(0u8..=1)? == 0 { + 0 + } else { + u.int_in_range(1u32..=MAX_WRAPPERS)? + }; + if wrappers == 0 { + let _ = Schema::from_str(&schema); + return Ok(()); + } + + // Every generated schema is well-formed JSON by construction (all + // interpolated names are `R`/`E`/`F`/`AnAlias`/`Twin`), so a + // parse failure here is a generator bug, and swallowing it would silently + // skip the depth-guard half of this target. + let mut value: serde_json::Value = serde_json::from_str(&schema) + .unwrap_or_else(|e| panic!("generated schema {schema} must be valid JSON: {e}")); + + // `Schema::parse` takes an already-parsed value and so is not subject to + // serde_json's limit, which is what makes the guard reachable at all. The + // wrapper count falls on either side of it, exercising the accepted depths, + // the rejection, and the `self.depth` decrement on the guard's error path. + // Built by moving `value` into each new layer. `serde_json::json!` would + // deep-clone it instead (its leaf case goes through `to_value`), making the + // wrapping quadratic in the layer count. + for _ in 0..wrappers { + let mut layer = serde_json::Map::new(); + layer.insert( + "type".to_string(), + serde_json::Value::String("array".to_string()), + ); + layer.insert("items".to_string(), value); + value = serde_json::Value::Object(layer); + } + let _ = Schema::parse(&value); Ok(()) } diff --git a/src/avro/fuzz/fuzz_targets/reader_decode.rs b/src/avro/fuzz/fuzz_targets/reader_decode.rs index f64f3b34101de..5c308517db63c 100644 --- a/src/avro/fuzz/fuzz_targets/reader_decode.rs +++ b/src/avro/fuzz/fuzz_targets/reader_decode.rs @@ -32,12 +32,22 @@ //! * an unrecognized codec string: the `UnrecognizedCodec` header-parse //! error. (`snappy` is gated behind a Cargo feature that this fuzz crate //! does not enable, so it is intentionally not generated.) -//! We also corrupt the declared block framing: occasionally the object-count or -//! byte-size prefix is replaced with a huge or wildly inconsistent value, which -//! must be caught by the `safe_len` allocation guard rather than spinning or -//! over-allocating. And we still hit the structural error paths, occasionally a -//! corrupted trailing sync marker (the mismatch path) or a whole-file truncation -//! mid-block (the short-read path). Decoding must never panic any of these ways. +//! We also corrupt the declared block framing. `read_block_next` runs both the +//! object-count and the byte-size prefix through the `safe_len` allocation guard +//! before it touches the payload, so a magnitude past `MAX_ALLOCATION_BYTES` +//! never reaches anything downstream of that guard. The lies therefore straddle +//! it deliberately: +//! * a count or size past the budget, which `safe_len` must reject rather than +//! attempt a giant allocation. +//! * a count over the objects the block actually encodes but inside the +//! budget, which `safe_len` accepts. `bound_block_object_count` must then +//! reject any count the payload could not hold, and one it accepts must +//! terminate against the bytes present instead of spinning. +//! * a size over the bytes the block actually carries but inside the budget, +//! so `fill_buf` runs and takes the short-read path. +//! And we still hit the structural error paths, occasionally a corrupted trailing +//! sync marker (the mismatch path) or a whole-file truncation mid-block (another +//! short read). Decoding must never panic any of these ways. #![no_main] @@ -45,6 +55,12 @@ use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_avro::{Codec, Reader}; +/// The ceiling `util::safe_len` applies to every length read off the wire, from +/// the private `mz_avro::util::MAX_ALLOCATION_BYTES`. Only used to pick the +/// magnitude of a corrupted block prefix, so drift merely shifts which side of +/// the guard a generated prefix lands on. +const MAX_ALLOCATION_BYTES: i64 = 512 * 1024 * 1024; + /// A generated Avro type. Structured (not straight-to-JSON) so the object /// encoder can walk the same type. An array is one item type in the schema but /// N item values in a block. @@ -82,7 +98,11 @@ fn gen_ty(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Res 7 => Ty::Bytes, 8 => { *counter += 1; - let size = u.int_in_range(0u32..=24)?; + // Size 0 is rejected outright ("Fixed values require a positive size + // attribute"), which would fail the header parse and discard the + // input before a single block decodes. `avro_schema_parse` covers + // that rejection deliberately. + let size = u.int_in_range(1u32..=24)?; Ty::Fixed(*counter, size) } 9 => { @@ -101,7 +121,22 @@ fn gen_ty(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Res } 11 => Ty::Array(Box::new(gen_ty(u, counter, depth - 1)?)), 12 => Ty::Map(Box::new(gen_ty(u, counter, depth - 1)?)), - _ => Ty::Nullable(Box::new(gen_ty(u, counter, depth - 1)?)), + _ => { + // `Ty::Nullable` is the only union this target emits, and the parser + // rejects both `["null","null"]` and a union nested directly inside a + // union. Flatten a nested nullable and swap a bare `null` for a type + // that keeps the union valid, so the header still parses. Those two + // rejections belong to `avro_schema_parse`. Here they would only + // discard the input before any block decodes. + let mut inner = gen_ty(u, counter, depth - 1)?; + while let Ty::Nullable(nested) = inner { + inner = *nested; + } + if matches!(inner, Ty::Null) { + inner = Ty::Long; + } + Ty::Nullable(Box::new(inner)) + } }) } @@ -115,14 +150,16 @@ fn ty_to_json(ty: &Ty, out: &mut String) { Ty::Double => out.push_str("\"double\""), Ty::String => out.push_str("\"string\""), Ty::Bytes => out.push_str("\"bytes\""), - Ty::Fixed(n, size) => { - out.push_str(&format!("{{\"type\":\"fixed\",\"name\":\"F{n}\",\"size\":{size}}}")) - } + Ty::Fixed(n, size) => out.push_str(&format!( + "{{\"type\":\"fixed\",\"name\":\"F{n}\",\"size\":{size}}}" + )), Ty::Enum(n) => out.push_str(&format!( "{{\"type\":\"enum\",\"name\":\"E{n}\",\"symbols\":[\"A\",\"B\",\"C\"]}}" )), Ty::Record(n, fields) => { - out.push_str(&format!("{{\"type\":\"record\",\"name\":\"R{n}\",\"fields\":[")); + out.push_str(&format!( + "{{\"type\":\"record\",\"name\":\"R{n}\",\"fields\":[" + )); for (i, f) in fields.iter().enumerate() { if i > 0 { out.push(','); @@ -332,20 +369,36 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { } // The block framing prefixes. Usually honest, but occasionally we lie - // about the object count or the byte size, including absurdly large or - // negative (→ huge `usize`) values, to exercise the `safe_len` guard - // and the short-read handling, neither of which may panic. + // about the object count or the byte size. `read_block_next` runs both + // prefixes through `safe_len` before touching the payload, so a + // magnitude past `MAX_ALLOCATION_BYTES` only ever reaches that guard. + // The lies below therefore straddle it deliberately: over the budget to + // exercise the guard, and over the *encoded data* but inside the budget + // to exercise what the reader does with a framing it accepted. let (count_prefix, size_prefix) = match u.int_in_range(0u8..=9)? { // Honest framing (the common case). 0..=6 => (nobj, objs.len() as i64), - // Huge object count with the real byte size: the reader must cap - // the count via `safe_len` instead of trying to decode billions. - 7 => (u.int_in_range(1i64 << 40..=i64::MAX)?, objs.len() as i64), + 7 => match u.int_in_range(0u8..=3)? { + // More objects than the block encodes, drawn across the whole + // range `safe_len` accepts. Two further guards live in here and + // both must hold without spinning: `bound_block_object_count` + // rejects a count the payload could not hold, and a count it + // accepts has to terminate against the bytes actually present. + 0..=2 => ( + u.int_in_range(nobj + 1..=MAX_ALLOCATION_BYTES)?, + objs.len() as i64, + ), + // Past the budget: `safe_len` must reject before the block bound + // is ever consulted. + _ => (u.int_in_range(1i64 << 40..=i64::MAX)?, objs.len() as i64), + }, // Negative byte size (wraps to an enormous `usize`): `safe_len` // must reject it rather than attempt a giant allocation. 8 => (nobj, -u.int_in_range(1i64..=i64::MAX)?), - // Byte size far larger than the bytes actually present → short read. - _ => (nobj, objs.len() as i64 + (1i64 << 30)), + // More bytes than the block carries, but a size `safe_len` accepts, + // so `fill_buf` runs and takes the short-read path. A magnitude over + // the budget would stop at the guard and never get there. + _ => (nobj, objs.len() as i64 + u.int_in_range(1i64..=1i64 << 20)?), }; encode_long(count_prefix, &mut out); encode_long(size_prefix, &mut out); @@ -360,7 +413,7 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { } // Occasionally truncate the whole file mid-block (short-read path). - if !out.is_empty() && u.int_in_range(0u8..=7)? == 0 { + if u.int_in_range(0u8..=7)? == 0 { let keep = u.int_in_range(0usize..=out.len())?; out.truncate(keep); } diff --git a/src/avro/fuzz/fuzz_targets/schema_resolve.rs b/src/avro/fuzz/fuzz_targets/schema_resolve.rs index e0d555ba5d537..73c61937eb0ef 100644 --- a/src/avro/fuzz/fuzz_targets/schema_resolve.rs +++ b/src/avro/fuzz/fuzz_targets/schema_resolve.rs @@ -30,8 +30,16 @@ //! trailing field (absent from the writer) with a JSON `default`, driving //! the "reader field not in writer, use default" branch in `resolve_named`. //! * union matching. Multi-variant unions whose variants the resolver must -//! match up by type/name across writer and reader. -//! * enums with a `default` symbol. +//! match up across writer and reader, by type for the primitive variants +//! and by *name* for the record/enum/fixed ones. The reader rendering also +//! sometimes collapses a union down to the one variant the encoder +//! expresses (`ResolveUnionConcrete`) and sometimes wraps a concrete node +//! in a union (`ResolveConcreteUnion`), so all three union arms of +//! `SchemaResolver::resolve` are reachable, not just union-against-union. +//! * enums with a `default` symbol, whose reader rendering also drops +//! trailing symbols. A dropped symbol is what makes the `default` +//! load-bearing: it becomes an `Err` entry in the resolved enum that decode +//! substitutes the default for. //! We resolve writer-against-itself (identity), and both cross-directions. //! //! A panic is not the only failure mode, though. `resolve_schemas` can return @@ -44,16 +52,25 @@ //! sees nothing wrong. So beyond requiring no panic, we add a *decode* oracle: //! the reader rendering only ever widens the writer, so every node, every //! union branch included, has a valid reader target, and decoding a -//! writer-encoded value through the writer→reader resolved schema MUST succeed. -//! A deferred mismatch turns that decode into an error, which this target -//! treats as a finding. +//! writer-encoded value through the writer→reader resolved schema MUST succeed +//! and MUST consume every byte the writer wrote. A deferred mismatch turns that +//! decode into an error, which this target treats as a finding. +//! +//! Because the reader rendering only widens, both renderings are valid schema +//! JSON, identity resolution succeeds, and writer→reader resolution succeeds, +//! all by construction. Those are `expect`s rather than `if let Ok(..)` guards +//! on purpose. A promotion that regresses at a *non-union* position (a record +//! field, an array item, a map value) surfaces as a top-level `Err` from +//! `resolve_schemas`, not as a deferred per-variant `Err`, so a guard there +//! would switch the decode oracle off for exactly the inputs that found a bug. +//! Only the narrowing reader→writer direction may legitimately fail. #![no_main] use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_avro::schema::resolve_schemas; -use mz_avro::{from_avro_datum, Schema}; +use mz_avro::{Schema, from_avro_datum}; /// One of the primitive Avro types, ordered by promotability so the reader /// rendering can pick a "wider" target. `int` ⊑ `long` ⊑ `float` ⊑ `double`. @@ -69,7 +86,16 @@ enum Shape { /// A primitive that has no promotion (rendered identically on both sides). OtherPrim(&'static str), /// `[..]` union with N>=1 variants. - Union(Vec), + Union { + variants: Vec, + /// The variant the encoder expresses on the wire. Drawn from the fuzz + /// input: decoding a `ResolveUnionUnion` only ever consults the + /// `permutation` entry for the encoded index, so a branch fixed by the + /// generator would leave every other entry's deferred `Err` unobserved. + branch: usize, + /// Whether the reader rendering drops the union down to `branch` alone. + reader_collapse: bool, + }, Array(Box), Map(Box), Record { @@ -82,6 +108,10 @@ enum Shape { name: u32, /// Whether the reader rendering gives the enum a `default` symbol. reader_default: bool, + /// How many trailing symbols the reader rendering omits. Only ever + /// nonzero when `reader_default` is set: dropping a symbol the writer + /// can express is a widening exactly because the default catches it. + reader_dropped: u8, }, Fixed { name: u32, @@ -119,17 +149,42 @@ fn gen_shape(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary:: if variants.is_empty() { variants.push(Shape::Promotable(0)); } - Shape::Union(variants) + // Named variants get distinct names, and a union's duplicate check + // keys named variants by name, so any number of them is valid even + // when several are the same kind of named type. They are the only + // way to reach the name-keyed side of the resolver's variant + // matching. + for _ in 0..u.int_in_range(0u8..=2)? { + variants.push(gen_named(u, counter, depth - 1)?); + } + let branch = u.int_in_range(0..=variants.len() - 1)?; + Shape::Union { + variants, + branch, + // A starved `Unstructured` answers every `int_in_range` with the + // low end, so testing against the high end keeps collapsing + // rare rather than universal once the input runs out. + reader_collapse: u.int_in_range(0u8..=3)? == 3, + } } 3 => Shape::Array(Box::new(gen_shape(u, counter, depth - 1)?)), 4 => Shape::Map(Box::new(gen_shape(u, counter, depth - 1)?)), - 5 | 6 => { - *counter += 1; - let name = *counter; + _ => gen_named(u, counter, depth - 1)?, + }) +} + +/// Generate one *named* shape. Named types are what the resolver matches up by +/// name rather than by type, both at ordinary positions and as union variants. +fn gen_named(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Result { + let kind = u.int_in_range(0u8..=3)?; + *counter += 1; + let name = *counter; + Ok(match kind { + 0 | 1 => { let n = u.int_in_range(0u8..=3)?; let mut fields = Vec::with_capacity(n.into()); for _ in 0..n { - fields.push(gen_shape(u, counter, depth - 1)?); + fields.push(gen_shape(u, counter, depth)?); } Shape::Record { name, @@ -137,20 +192,22 @@ fn gen_shape(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary:: reader_extra_default: u.int_in_range(0u8..=1)? == 0, } } - 7 => { - *counter += 1; + 2 => { + let reader_default = u.int_in_range(0u8..=1)? == 0; Shape::Enum { - name: *counter, - reader_default: u.int_in_range(0u8..=1)? == 0, - } - } - _ => { - *counter += 1; - Shape::Fixed { - name: *counter, - size: u.int_in_range(1u8..=16)?, + name, + reader_default, + reader_dropped: if reader_default { + u.int_in_range(0u8..=2)? + } else { + 0 + }, } } + _ => Shape::Fixed { + name, + size: u.int_in_range(1u8..=16)?, + }, }) } @@ -169,7 +226,7 @@ fn render_writer(shape: &Shape, out: &mut String) { out.push_str(p); out.push('"'); } - Shape::Union(variants) => { + Shape::Union { variants, .. } => { out.push('['); for (i, v) in variants.iter().enumerate() { if i > 0 { @@ -218,13 +275,33 @@ fn render_writer(shape: &Shape, out: &mut String) { /// Render the *reader* version of `shape`: widens each promotable primitive to /// a (fuzz-chosen) wider type on the promotion chain, appends a defaulted -/// `extra` record field, and gives enums a `default` symbol. These are all the +/// `extra` record field, drops trailing enum symbols in favour of a `default`, +/// and rewrites unions into and out of concrete types. These are all the /// schema-evolution shapes `resolve_schemas` handles. +/// +/// `in_union` says whether `shape` sits directly inside a union, which forbids +/// wrapping it in another one. fn render_reader_promoted( u: &mut Unstructured, shape: &Shape, + in_union: bool, out: &mut String, ) -> arbitrary::Result<()> { + // Reading a concrete writer node as a union that contains a match for it is + // a widening, and the only way to reach `ResolveConcreteUnion`. Avro rejects + // a union directly inside a union, and `["null","null"]` is a duplicate + // variant, hence the two exclusions. Testing against the high end of the + // range keeps wrapping rare once the input is exhausted rather than + // wrapping every single node. + if !in_union + && !matches!(shape, Shape::Union { .. } | Shape::OtherPrim("null")) + && u.int_in_range(0u8..=3)? == 3 + { + out.push_str("[\"null\","); + render_reader_promoted(u, shape, true, out)?; + out.push(']'); + return Ok(()); + } match shape { Shape::Promotable(idx) => { // Choose a target at or after `idx` on the chain, a valid @@ -239,18 +316,35 @@ fn render_reader_promoted( out.push_str(p); out.push('"'); } - Shape::Union(variants) => { + Shape::Union { + variants, + branch, + reader_collapse, + } if *reader_collapse => { + // Drop the union down to the single variant the encoder expresses, + // so a union writer resolves against a concrete reader + // (`ResolveUnionConcrete`). That variant is rendered in its *writer* + // form on purpose. Resolution records the one writer variant that + // matches the reader's concrete type, and decoding then rejects any + // other encoded index. Since a union's variants are unique per type + // and per name, an exact rendering is what guarantees the match lands + // on `branch`. A widened one can select a different variant, turning + // a correct decode into a wrong-index error: `["int","long"]` with + // the `long` branch encoded, read as `"float"`, matches `int`, which + // comes first and promotes to `float` just as well. + render_writer(&variants[*branch], out); + } + Shape::Union { variants, .. } => { // Widen the promotable variants while keeping their types distinct. // Widening each independently could collapse two variants onto the // same type (`[int, long]` becoming `[long, long]`), which Avro - // rejects as a duplicate union type. That makes the whole reader - // schema unparseable, so the writer->reader decode oracle below is - // silently skipped for this input. We emit the non-promotable - // variants (only `null`, per `gen_shape`) first, then the - // promotables in ascending source order with strictly increasing - // targets. Reserving one chain slot for every later variant keeps a - // valid assignment reachable, since PROMO_CHAIN has one slot per - // possible source index. + // rejects as a duplicate union type, and an unparseable reader + // rendering fails the parse assertion in `run`. We emit the + // non-promotable variants (`null` and the named ones) first, then + // the promotables in ascending source order with strictly + // increasing targets. Reserving one chain slot for every later + // variant keeps a valid assignment reachable, since PROMO_CHAIN has + // one slot per possible source index. out.push('['); let mut written = 0; for v in variants @@ -260,7 +354,7 @@ fn render_reader_promoted( if written > 0 { out.push(','); } - render_reader_promoted(u, v, out)?; + render_reader_promoted(u, v, true, out)?; written += 1; } let mut promo: Vec = variants @@ -290,12 +384,12 @@ fn render_reader_promoted( } Shape::Array(item) => { out.push_str("{\"type\":\"array\",\"items\":"); - render_reader_promoted(u, item, out)?; + render_reader_promoted(u, item, false, out)?; out.push('}'); } Shape::Map(values) => { out.push_str("{\"type\":\"map\",\"values\":"); - render_reader_promoted(u, values, out)?; + render_reader_promoted(u, values, false, out)?; out.push('}'); } Shape::Record { @@ -311,7 +405,7 @@ fn render_reader_promoted( out.push(','); } out.push_str(&format!("{{\"name\":\"f{i}\",\"type\":")); - render_reader_promoted(u, f, out)?; + render_reader_promoted(u, f, false, out)?; out.push('}'); } if *reader_extra_default { @@ -325,9 +419,18 @@ fn render_reader_promoted( Shape::Enum { name, reader_default, + reader_dropped, } => { + // Symbols are dropped from the tail, which keeps `A` around as the + // default. A writer symbol the reader lacks becomes an `Err` entry + // in the resolved enum, and decoding it substitutes the default. + let symbols = match *reader_dropped { + 0 => "\"A\",\"B\",\"C\"", + 1 => "\"A\",\"B\"", + _ => "\"A\"", + }; out.push_str(&format!( - "{{\"type\":\"enum\",\"name\":\"N{name}\",\"symbols\":[\"A\",\"B\",\"C\"]" + "{{\"type\":\"enum\",\"name\":\"N{name}\",\"symbols\":[{symbols}]" )); if *reader_default { out.push_str(",\"default\":\"A\""); @@ -365,11 +468,11 @@ fn encode_blob(bytes: &[u8], out: &mut Vec) { /// Avro-binary-encode one value matching the *writer* rendering of `shape` /// (i.e. `render_writer`'s wire format), so it can be decoded back through a -/// resolved schema. For unions we deliberately pick a *promotable* branch: -/// that is the branch whose resolved decode walks the numeric-promotion path, -/// the exact spot where #37087 deferred a union match failure into the resolved -/// schema and re-raised it here at decode time. Every generated union has at -/// least one promotable variant, so the search never falls back. +/// resolved schema. A union is encoded at its `Shape::Union::branch`, which the +/// generator drew from the fuzz input: decoding consults only the resolved +/// permutation entry for the encoded index, so a deferred `Err` parked in any +/// other entry, the failure mode of #37087, is invisible unless the branch +/// varies. fn encode_writer_value( u: &mut Unstructured, shape: &Shape, @@ -396,13 +499,11 @@ fn encode_writer_value( encode_blob(&b, out); } }, - Shape::Union(variants) => { - let branch = variants - .iter() - .position(|v| matches!(v, Shape::Promotable(_))) - .unwrap_or(0); - encode_long(branch as i64, out); - encode_writer_value(u, &variants[branch], out)?; + Shape::Union { + variants, branch, .. + } => { + encode_long(*branch as i64, out); + encode_writer_value(u, &variants[*branch], out)?; } Shape::Array(item) => { let n = u.int_in_range(0i64..=3)?; @@ -446,6 +547,24 @@ fn encode_writer_value( Ok(()) } +/// Decode `value` through `resolved`, requiring both that the decode succeeds +/// and that it consumes every byte the writer wrote. Leftover bytes mean the +/// resolved schema drove the decoder over less than the writer's value, for +/// instance a promotion of the wrong width or a field the resolver dropped +/// without skipping its bytes, which the tail of a record would otherwise hide. +fn decode_fully(resolved: &Schema, value: &[u8], what: &str) { + let mut cursor = value; + match from_avro_datum(resolved, &mut cursor) { + Ok(_) => assert!( + cursor.is_empty(), + "decoding the writer's bytes through the {what} left {} of {} bytes unconsumed", + cursor.len(), + value.len(), + ), + Err(e) => panic!("decoding the writer's bytes through the {what} failed: {e}"), + } +} + fn run(mut u: Unstructured) -> arbitrary::Result<()> { let mut counter = 0u32; // The top level of an OCF/registry schema is virtually always a record. @@ -465,11 +584,17 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { let mut writer_json = String::new(); render_writer(&shape, &mut writer_json); let mut reader_json = String::new(); - render_reader_promoted(&mut u, &shape, &mut reader_json)?; + render_reader_promoted(&mut u, &shape, false, &mut reader_json)?; - let Ok(writer) = writer_json.parse::() else { - return Ok(()); - }; + // Both renderings are valid schema JSON by construction, so a parse failure + // is a generator bug. Reporting it beats skipping the input, which would + // turn the decode oracle below off without a trace. + let writer = writer_json + .parse::() + .unwrap_or_else(|e| panic!("writer rendering {writer_json} must parse: {e}")); + let reader = reader_json + .parse::() + .unwrap_or_else(|e| panic!("reader rendering {reader_json} must parse: {e}")); // Encode one value against the writer rendering. We decode it back through // the resolved schemas below. The writer only ever widens into the reader, @@ -481,28 +606,38 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { let mut writer_value = Vec::new(); encode_writer_value(&mut u, &shape, &mut writer_value)?; - // Identity resolution must succeed and decode the writer's own bytes. - if let Ok(resolved) = resolve_schemas(&writer, &writer) { - from_avro_datum(&resolved, &mut &writer_value[..]) - .expect("decode through identity-resolved schema must succeed"); - } - if let Ok(reader) = reader_json.parse::() { - // Writer→reader is the *widening* direction: it hits the promotion / - // default / union-match branches, must resolve, and the resolved schema - // must decode the writer's bytes. A deferred promotion mismatch turns - // this `expect` into the fuzzer's signal. - if let Ok(resolved) = resolve_schemas(&writer, &reader) { - from_avro_datum(&resolved, &mut &writer_value[..]).expect( - "decode through writer→reader resolved schema must succeed; a failure means \ - resolution deferred a fixable mismatch into the resolved schema (see #37087)", - ); - } - // The reverse (reader→writer) *narrows*, so its resolution may - // legitimately fail or defer a genuine mismatch. We only require that - // neither direction panics. - let _ = resolve_schemas(&reader, &writer); - let _ = resolve_schemas(&reader, &reader); - } + // Identity resolution is the control: a schema always resolves against + // itself, and the resolved form always decodes what the writer wrote. + let resolved = resolve_schemas(&writer, &writer) + .unwrap_or_else(|e| panic!("identity resolution of {writer_json} must succeed: {e}")); + decode_fully( + &resolved, + &writer_value, + &format!("identity resolution of {writer_json}"), + ); + + // Writer→reader is the *widening* direction: it hits the promotion / + // default / union-match branches, must resolve, and the resolved schema must + // decode the writer's bytes. A promotion the resolver rejects outright fails + // the `resolve_schemas` assertion, and one it accepts but defers into the + // resolved schema fails the decode. + let resolved = resolve_schemas(&writer, &reader).unwrap_or_else(|e| { + panic!("the reader rendering only widens, so resolving {writer_json} against {reader_json} must succeed: {e}") + }); + decode_fully( + &resolved, + &writer_value, + &format!("resolution of {writer_json} against {reader_json}"), + ); + + // The reverse (reader→writer) *narrows*, so its resolution may legitimately + // fail or defer a genuine mismatch. We only require that it does not panic. + // It is also the direction that reaches the no-match error paths of the + // asymmetric union arms: a node the reader wrapped and widened, say an `int` + // read as `["null","double"]`, has no match in reverse, because `double` + // does not narrow back to `int`. + let _ = resolve_schemas(&reader, &writer); + let _ = resolve_schemas(&reader, &reader); Ok(()) } diff --git a/src/avro/fuzz/prepare-corpus.sh b/src/avro/fuzz/prepare-corpus.sh index cfa2c6df73569..e2edcac2bef16 100755 --- a/src/avro/fuzz/prepare-corpus.sh +++ b/src/avro/fuzz/prepare-corpus.sh @@ -13,6 +13,14 @@ # container files so the fuzzer doesn't waste cycles bouncing off the # magic-header check. libFuzzer mutates these into deeper structural # variants while still hitting real decoder code paths. +# +# `reader_decode` does not read its input as a container file. It reads it as an +# `arbitrary::Unstructured` recipe, and only the branch selected by the first +# byte feeds the remaining bytes to `Reader` verbatim. A container file dropped +# in as-is is therefore consumed as generator choices and never reaches the +# magic-header check at all, so each seed is prefixed with the byte that selects +# that raw branch. `int_in_range(0..=3)` takes one byte from the front and +# returns it modulo 4, so a leading NUL selects branch 0. set -euo pipefail @@ -20,7 +28,8 @@ cd "$(dirname "$0")" mkdir -p corpus/reader_decode find corpus/reader_decode -maxdepth 1 -name 'seed_*.avro' -delete -cp ../benches/quickstop-null.avro corpus/reader_decode/seed_01_quickstop_null.avro +{ printf '\0'; cat ../benches/quickstop-null.avro; } \ + > corpus/reader_decode/seed_01_quickstop_null.avro echo "Seeded:" for d in corpus/*/; do diff --git a/src/avro/src/schema.rs b/src/avro/src/schema.rs index 8093dca672c91..3c24fb61fca25 100644 --- a/src/avro/src/schema.rs +++ b/src/avro/src/schema.rs @@ -2503,6 +2503,49 @@ mod tests { use super::*; + /// Wrap `"int"` in `n` layers of `{"type":"array","items":…}`. + fn nest_arrays(n: usize) -> Value { + let mut value = Value::String("int".to_string()); + for _ in 0..n { + let mut layer = Map::new(); + layer.insert("type".to_string(), Value::String("array".to_string())); + layer.insert("items".to_string(), value); + value = Value::Object(layer); + } + value + } + + #[mz_ore::test] + fn parse_rejects_nesting_past_the_depth_limit() { + // `MAX_SCHEMA_DEPTH` counts `parse_inner` levels, and the innermost leaf + // costs a level without being a JSON container, so `n` wrappers reach + // depth `n + 1`. + assert_ok!(Schema::parse(&nest_arrays(MAX_SCHEMA_DEPTH - 1))); + let err = Schema::parse(&nest_arrays(MAX_SCHEMA_DEPTH)) + .expect_err("nesting past the limit must be rejected"); + assert!( + err.to_string() + .contains(&format!("nesting depth exceeds limit {MAX_SCHEMA_DEPTH}")), + "unexpected error: {err}" + ); + } + + #[mz_ore::test] + fn from_str_cannot_reach_the_depth_limit() { + // Why the test above builds a `Value` rather than parsing text: `from_str` + // runs serde_json first, whose own recursion limit is also 128 + // containers, so it rejects the schema one level before the guard could + // fire. A depth-guard test written against text would be asserting + // serde_json's error, not ours. If this ever fails, the guard became + // reachable from text and deserves a test there too. + let text = serde_json::to_string(&nest_arrays(MAX_SCHEMA_DEPTH)).unwrap(); + let err = Schema::from_str(&text).expect_err("serde_json must reject this depth"); + assert!( + err.to_string().contains("recursion limit exceeded"), + "unexpected error: {err}" + ); + } + fn check_schema(schema: &str, expected: SchemaPiece) { let schema = Schema::from_str(schema).unwrap(); assert_eq!(&expected, schema.top_node().inner); diff --git a/src/catalog-protos/fuzz/Cargo.toml b/src/catalog-protos/fuzz/Cargo.toml index 09c93c65098da..4b8cb63def46d 100644 --- a/src/catalog-protos/fuzz/Cargo.toml +++ b/src/catalog-protos/fuzz/Cargo.toml @@ -16,6 +16,10 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" mz-catalog-protos = { path = "..", features = ["proptest"] } +# For `Jsonb`, so the oracle can assert the round trip the durable catalog +# actually performs (`StateUpdateKindJson` packs the serde value into a `Row`) +# rather than only the JSON text form, which never touches that stage. +mz-repr = { path = "../../repr" } proptest = "1.11.0" serde = "1.0.219" serde_json = "1.0.150" diff --git a/src/catalog-protos/fuzz/catalog_objects_serde_roundtrip.dict b/src/catalog-protos/fuzz/catalog_objects_serde_roundtrip.dict new file mode 100644 index 0000000000000..f9c640a620148 --- /dev/null +++ b/src/catalog-protos/fuzz/catalog_objects_serde_roundtrip.dict @@ -0,0 +1,100 @@ +# libFuzzer dictionary for the catalog_objects_serde_roundtrip target. +# +# The raw-bytes arm feeds the input to `serde_json::from_slice::`, which needs +# a complete JSON object with the exact field names and variant tags before it +# returns anything at all. The nested types (`ClusterValue`, `ItemValue`, +# `RoleValue`, `NetworkPolicyValue`, `ClusterReplicaValue`) are out of reach for +# byte mutation without them, so that arm would spend the whole run bailing on a +# failed decode. Only the short types (`ConfigValue`, `SettingValue`, `RoleId`) +# are reachable unaided. +# +# Structure. +"{}" +"[]" +":" +"," +"null" +"true" +"false" + +# `StateUpdateKind` is `#[serde(tag = "kind")]`, so its discriminant is a field. +"\"kind\"" +"\"key\"" +"\"value\"" + +# `StateUpdateKind` variants. +"\"AuditLog\"" +"\"Cluster\"" +"\"ClusterIntrospectionSourceIndex\"" +"\"ClusterReplica\"" +"\"Comment\"" +"\"Config\"" +"\"Database\"" +"\"DefaultPrivileges\"" +"\"FenceToken\"" +"\"GidMapping\"" +"\"IdAlloc\"" +"\"Item\"" +"\"NetworkPolicy\"" +"\"Role\"" +"\"RoleAuth\"" +"\"Schema\"" +"\"ServerConfiguration\"" +"\"Setting\"" +"\"SourceReferences\"" +"\"StorageCollectionMetadata\"" +"\"SystemPrivileges\"" +"\"TxnWalShard\"" +"\"UnfinalizedShard\"" + +# Field names reachable from the fuzzed types. +"\"acl_mode\"" +"\"attributes\"" +"\"auto_scaling_strategy\"" +"\"availability_zones\"" +"\"catalog_id\"" +"\"cluster_id\"" +"\"config\"" +"\"definition\"" +"\"entries\"" +"\"extra_versions\"" +"\"fingerprint\"" +"\"global_id\"" +"\"grantee\"" +"\"grantor\"" +"\"inherit\"" +"\"location\"" +"\"logging\"" +"\"membership\"" +"\"name\"" +"\"oid\"" +"\"owner_id\"" +"\"privileges\"" +"\"replication_factor\"" +"\"rules\"" +"\"schedule\"" +"\"schema_id\"" +"\"size\"" +"\"superuser\"" +"\"variant\"" +"\"vars\"" +"\"version\"" +"\"workload_class\"" +"\"create_sql\"" + +# Variant tags of the nested enums: `RoleId`/`GlobalId`, `ClusterVariant`, +# `ReplicaLocation`, `CatalogItem`, `RoleVar`, `ClusterSchedule`. +"\"System\"" +"\"User\"" +"\"Public\"" +"\"Predefined\"" +"\"Transient\"" +"\"Explain\"" +"\"IntrospectionSourceIndex\"" +"\"Managed\"" +"\"Unmanaged\"" +"\"Manual\"" +"\"Refresh\"" +"\"Flat\"" +"\"SqlSet\"" +"\"V1\"" diff --git a/src/catalog-protos/fuzz/fuzz_targets/catalog_objects_serde_roundtrip.rs b/src/catalog-protos/fuzz/fuzz_targets/catalog_objects_serde_roundtrip.rs index d2a9171f43fc9..e5559ffde3131 100644 --- a/src/catalog-protos/fuzz/fuzz_targets/catalog_objects_serde_roundtrip.rs +++ b/src/catalog-protos/fuzz/fuzz_targets/catalog_objects_serde_roundtrip.rs @@ -7,21 +7,34 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -//! Fuzz target: catalog object serde JSON round-trip is idempotent. The -//! catalog state is durable on-disk data, so a serde edge case that loses -//! information through a JSON round trip is a catalog-corruption risk. +//! Fuzz target: catalog object serde round-trips are lossless. The catalog +//! state is durable on-disk data, so an encoding edge case that loses +//! information is a catalog-corruption risk. +//! +//! The durable form is not JSON *text*. `StateUpdateKindJson` packs the serde +//! value into a `Jsonb`, i.e. an `mz_repr::Row`, and reads it back out through +//! `to_serde_json` + `from_value`. That JSONB leg is the one that can lose +//! information: every number becomes a `Datum::Numeric` and object keys are +//! deduplicated and reordered by the `Row` map encoding. So the oracle asserts +//! that leg, not just `to_vec`/`from_slice`, which never touches it. //! //! Two complementary input arms (the first byte picks the arm, the next byte -//! picks which catalog type to exercise): +//! picks which catalog type to exercise). Both arms dispatch over the same type +//! list in the same order, so a corpus entry's type byte means the same thing in +//! either arm: //! //! * **Structured arm.** Drives the catalog type's proptest `Arbitrary` //! (behind mz-catalog-protos' `proptest` feature) from the libFuzzer byte -//! stream to synthesize a *valid, deeply-populated* value, then asserts the -//! full `value -> JSON -> value -> JSON` chain is idempotent. We deliberately -//! target the genuinely nested catalog types: `ClusterValue` -//! (`RoleId` + `Vec` + the `ClusterConfig`/`ClusterVariant`/ -//! `ManagedCluster`/`ClusterSchedule` tree), `ItemValue` (the `CatalogItem` -//! enum + `GlobalId` enum + `Vec`), `RoleValue` (the +//! stream to synthesize a *valid, deeply-populated* value, then asserts both +//! round trips. The list leads with `StateUpdateKind`, the durable envelope +//! every catalog write goes through and the one 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 rather than straight +//! from the input. The rest are the genuinely nested values reached through +//! it: `ClusterValue` (`RoleId` + `Vec` + the +//! `ClusterConfig`/`ClusterVariant`/`ManagedCluster`/`ClusterSchedule` tree), +//! `ItemValue` (the `CatalogItem` enum + `GlobalId` enum + +//! `Vec`), `RoleValue` (the //! `RoleAttributes`/`RoleMembership`/`RoleVars`/`RoleVar` tree), //! `NetworkPolicyValue` (`Vec`), and `ClusterReplicaValue` //! (the `ReplicaConfig`/`ReplicaLocation` enum). Random JSON bytes almost @@ -29,15 +42,18 @@ //! serde branches actually get covered. //! * **Raw-bytes arm.** Deserializes arbitrary bytes straight into the type, //! exercising the deserializer against malformed/adversarial JSON input, -//! then re-serializes the recovered value. +//! then round-trips the recovered value. The nested types need a complete +//! object with the right field names and variant tags to get past the decode, +//! which is what `catalog_objects_serde_roundtrip.dict` supplies. #![no_main] use libfuzzer_sys::fuzz_target; use mz_catalog_protos::objects::{ ClusterConfig, ClusterReplicaValue, ClusterValue, ConfigValue, GidMappingValue, ItemValue, - MzAclItem, NetworkPolicyValue, RoleId, RoleValue, SettingValue, + MzAclItem, NetworkPolicyValue, RoleId, RoleValue, SettingValue, StateUpdateKind, }; +use mz_repr::adt::jsonb::Jsonb; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; @@ -69,17 +85,28 @@ where assert_idempotent(tree.current()); } -/// `value -> JSON -> value` must be the identity, and re-serializing must -/// produce byte-identical JSON. +/// `value -> encoded -> value` must be the identity, through both the JSON text +/// form and the JSONB form the durable catalog actually stores. fn assert_idempotent(orig: T) where T: serde::de::DeserializeOwned + serde::Serialize + PartialEq + std::fmt::Debug, { + // JSON text: what a `to_vec`/`from_slice` consumer sees. let json = serde_json::to_vec(&orig).expect("serialize of valid value must succeed"); let round: T = serde_json::from_slice(&json).expect("re-decode must round-trip"); assert_eq!(orig, round, "serde roundtrip changed value"); - let json2 = serde_json::to_vec(&round).expect("re-serialize must succeed"); - assert_eq!(json, json2, "serde re-serialize was not idempotent"); + + // The durable path, `StateUpdateKindJson::from_serde` / `try_to_serde`: pack + // the serde value into a `Row` and read it back. Numbers survive as + // `Datum::Numeric` only because every integer in these types fits in + // `Numeric`'s 39 digits (`u64::MAX` is 20) and `to_standard_notation_string` + // never emits exponent notation, which is a property worth asserting rather + // than assuming. + let value = serde_json::to_value(&orig).expect("serialize to a value must succeed"); + let jsonb = Jsonb::from_serde_json(value).expect("catalog value must pack as jsonb"); + let via_jsonb: T = serde_json::from_value(jsonb.as_ref().to_serde_json()) + .expect("jsonb must round-trip back into the catalog type"); + assert_eq!(orig, via_jsonb, "jsonb roundtrip changed value"); } /// Decode adversarial JSON bytes straight into `T`, then assert the recovered @@ -94,6 +121,28 @@ where assert_idempotent(orig); } +/// Dispatch `$arm` over the catalog types. One list for both arms, so the type +/// byte selects the same type either way and a corpus entry stays meaningful +/// across a one-bit change to the arm byte. +macro_rules! dispatch { + ($which:expr, $arm:ident, $rest:expr) => { + match $which % 12 { + 0 => $arm::($rest), + 1 => $arm::($rest), + 2 => $arm::($rest), + 3 => $arm::($rest), + 4 => $arm::($rest), + 5 => $arm::($rest), + 6 => $arm::($rest), + 7 => $arm::($rest), + 8 => $arm::($rest), + 9 => $arm::($rest), + 10 => $arm::($rest), + _ => $arm::($rest), + } + }; +} + fuzz_target!(|data: &[u8]| { let Some((&mode, rest)) = data.split_first() else { return; @@ -104,31 +153,9 @@ fuzz_target!(|data: &[u8]| { if mode & 1 == 0 { // Structured arm: synthesize a valid, deeply-nested value. - match which % 10 { - 0 => structured_roundtrip::(rest), - 1 => structured_roundtrip::(rest), - 2 => structured_roundtrip::(rest), - 3 => structured_roundtrip::(rest), - 4 => structured_roundtrip::(rest), - 5 => structured_roundtrip::(rest), - 6 => structured_roundtrip::(rest), - 7 => structured_roundtrip::(rest), - 8 => structured_roundtrip::(rest), - _ => structured_roundtrip::(rest), - } + dispatch!(which, structured_roundtrip, rest) } else { // Raw-bytes arm: decode adversarial JSON, then round-trip. - match which % 10 { - 0 => raw_roundtrip::(rest), - 1 => raw_roundtrip::(rest), - 2 => raw_roundtrip::(rest), - 3 => raw_roundtrip::(rest), - 4 => raw_roundtrip::(rest), - 5 => raw_roundtrip::(rest), - 6 => raw_roundtrip::(rest), - 7 => raw_roundtrip::(rest), - 8 => raw_roundtrip::(rest), - _ => raw_roundtrip::(rest), - } + dispatch!(which, raw_roundtrip, rest) } }); diff --git a/src/catalog-protos/fuzz/prepare-corpus.sh b/src/catalog-protos/fuzz/prepare-corpus.sh new file mode 100755 index 0000000000000..f3c7ffce90b99 --- /dev/null +++ b/src/catalog-protos/fuzz/prepare-corpus.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. +# +# prepare-corpus.sh: seed the catalog_objects_serde_roundtrip corpus with one +# valid document per catalog type for the raw-bytes arm. +# +# That arm feeds the input straight to `serde_json::from_slice::`, which +# returns nothing at all until the input is a complete object with the right +# field names and variant tags. For the nested types that is far out of reach of +# byte mutation starting from nothing: a run seeded only by libFuzzer itself +# keeps a corpus of 2-4 byte entries, because growing an input yields no new +# coverage until it parses, so there is no gradient to climb. The dictionary +# supplies the tokens, and these seeds supply the shape to insert them into. +# +# Each seed carries the two-byte prefix the target reads first: an odd `mode` +# byte to select the raw arm, then the type index. The JSON is deliberately +# minimal rather than a dump of a realistic value, so libFuzzer has short inputs +# to mutate. +# +# A seed that stops parsing (a field added to one of these types, say) costs +# only the coverage it was buying, never a false pass: the target's oracle runs +# on whatever it manages to decode. Field sets were taken from the types +# themselves, all twelve verified to deserialize at the time of writing. + +set -euo pipefail + +cd "$(dirname "$0")" + +corpus=corpus/catalog_objects_serde_roundtrip +mkdir -p "$corpus" +find "$corpus" -maxdepth 1 -name 'seed_*' -delete + +# `mode` byte 0x01 selects the raw arm; the second byte is the type index in the +# target's `dispatch!` list. +seed() { + local index="$1" name="$2" json="$3" + local file + file="$corpus/seed_$(printf '%02d' "$index")_$name" + printf '%b' "\\0001\\0$(printf '%03o' "$index")" > "$file" + printf '%s' "$json" >> "$file" +} + +cluster_value='{"name":"c","owner_id":"Public","privileges":[],"config":{"workload_class":null,"variant":"Unmanaged"}}' + +seed 0 state_update_kind "{\"kind\":\"Cluster\",\"key\":{\"id\":{\"User\":1}},\"value\":$cluster_value}" +seed 1 cluster_value "$cluster_value" +seed 2 item_value '{"schema_id":{"User":1},"name":"i","definition":{"V1":{"create_sql":"SELECT 1"}},"owner_id":"Public","privileges":[],"oid":1,"global_id":{"User":1},"extra_versions":[]}' +seed 3 role_value '{"name":"r","attributes":{"inherit":true,"superuser":null,"login":null,"auto_provision_source":null},"membership":{"map":[]},"vars":{"entries":[]},"oid":1}' +seed 4 network_policy_value '{"name":"n","rules":[{"name":"a","address":"0.0.0.0/0","action":"Allow","direction":"Ingress"}],"owner_id":"Public","privileges":[],"oid":1}' +seed 5 cluster_replica_value '{"cluster_id":{"User":1},"name":"r1","config":{"logging":{"log_logging":false,"interval":null},"location":{"Managed":{"size":"1","availability_zones":[],"internal":false,"billed_as":null,"pending":false}},"arrangement_compression":false},"owner_id":"Public"}' +seed 6 cluster_config '{"workload_class":null,"variant":"Unmanaged"}' +seed 7 gid_mapping_value '{"catalog_id":1,"global_id":1,"fingerprint":"f"}' +seed 8 mz_acl_item '{"grantee":"Public","grantor":{"User":1},"acl_mode":{"bitflags":0}}' +seed 9 role_id '{"User":1}' +seed 10 config_value '{"value":1}' +seed 11 setting_value '{"value":"s"}' + +echo "Seeded:" +printf " %-40s %4d seeds\n" "$corpus/" "$(find "$corpus" -maxdepth 1 -name 'seed_*' | wc -l)" diff --git a/src/expr/fuzz/fuzz_targets/agg_decompose.rs b/src/expr/fuzz/fuzz_targets/agg_decompose.rs index 95507c9dee885..ddb8c539a4581 100644 --- a/src/expr/fuzz/fuzz_targets/agg_decompose.rs +++ b/src/expr/fuzz/fuzz_targets/agg_decompose.rs @@ -19,38 +19,86 @@ //! * **Hierarchical re-aggregation** (min/max/any/all, idempotent aggregates //! whose output type equals their input type): `agg(whole)` must equal //! `agg([agg(chunk0), agg(chunk1), ...])` over the non-empty chunks of an -//! arbitrary partition. This is exactly how the bucketed hierarchical reduce -//! computes min/max, and it must hold even with nulls present in the data. +//! arbitrary partition, and it must hold even with nulls present in the data. +//! For min/max this is exactly how the bucketed hierarchical reduce works. +//! `any`/`all` are maintained accumulably in the dataflow instead +//! (`mz_compute_types::plan::reduce::reduction_type` groups them with the sums +//! and `Count`, and the accumulator keeps bool counts rather than partial +//! booleans), but both are idempotent bool monoid homomorphisms, so the same +//! law holds and pins down `eval`'s three-valued folding. //! * **Additive decomposition**: `count(whole)` must equal the sum of the //! per-chunk counts. Likewise `sum(whole)` must equal the sum of the //! per-chunk sums (this is exactly how accumulable maintenance combines //! partial sums across batches). We check the sum law for the *integer* sums //! (`SumInt32`/`SumInt64`), where the per-chunk combination is exact. +//! * **Expansion equivalence** (every aggregate): a row at multiplicity `k` must +//! aggregate exactly like `k` rows at multiplicity one. //! //! We generate a random multiset of nullable datums in one of several type //! groups, a random permutation of it, and a random partition into chunks, then //! check the applicable laws for each aggregate over the chosen group. //! +//! Every generated row carries a random *positive* multiplicity, because +//! `AggregateFunc::eval` consumes `(datum, diff)` pairs and dispatches on the +//! diff three ways: `count` sums the diffs, the signed integer sums scale each +//! value by its diff, and multiplicity-insensitive aggregates +//! (`AggregateFunc::ignores_multiplicity`) drop it. Constant folding is the only +//! caller that ever passes a diff other than one, because +//! `FoldConstants::fold_reduce_constant` consolidates the constant's rows before +//! handing them to `eval` while the dataflow's reduce renderers pass `Diff::ONE` +//! deliberately. So a divergence in that arithmetic is a silent wrong answer +//! reachable only through constant folding, and this is the only coverage of it. +//! +//! The expansion law is what actually pins that arithmetic down, and the +//! permutation and decomposition laws cannot substitute for it. Both of those +//! hold for *any* per-row function summed over the input, so they stay satisfied +//! if `count` counts rows instead of summing diffs, or if the sum drops its +//! `diff` factor: row-counting is additive too. Only comparing against the +//! expanded form distinguishes them. What no law here can detect is a change to +//! `ignores_multiplicity` membership for min/max, since routing those through +//! `expand_counts` instead is genuinely equivalent at positive diffs. +//! +//! Retractions are deliberately out of scope. The signed sums finalize +//! `accum == 0 && non_nulls == 0` to `Null`, which stops being additive once +//! diffs can cancel: `[(0, -1)]` and `[(0, +1)]` each finalize to `0` while +//! their union finalizes to `Null`. Covering retractions needs a law stated over +//! accumulators rather than over finalized datums. +//! //! Groups: `int4`/`int8`/`bool` (exact integers/booleans, plain equality //! oracle), `text` (lexicographic min/max), plus `float8` and `numeric`. The //! float/numeric groups exercise the `OrderedFloat`/`OrderedDecimal` ordering -//! used by min/max and feed in the tricky special values (`NaN`, `±Inf`, -//! `-0.0`). We only apply min/max to the float/numeric groups: floating-point -//! and bounded-decimal *sum* is not -//! associative under rounding, so an additive/permutation law over it would be -//! a generator artifact rather than a real product invariant. Datum equality is -//! `OrderedFloat`-based (so `NaN == NaN`), so it is a sound oracle for the -//! ordering aggregates even with NaN present. +//! used by min/max. `float8` feeds in the full set of IEEE-754 corners (`NaN`, +//! `±Inf`, `-0.0`); `numeric` feeds the specials a numeric datum can actually +//! hold, which is `NaN` plus equal-value-different-scale pairs (see +//! `SPECIAL_NUMERIC`). We only apply min/max to the float/numeric groups: +//! floating-point and bounded-decimal *sum* is not associative under rounding, +//! so an additive/permutation law over it would be a generator artifact rather +//! than a real product invariant. +//! +//! Datum equality is the oracle throughout, and it is sound for the ordering +//! aggregates because it agrees with the `Ord` that `max_datum`/`min_datum` +//! select by: `Datum`'s derived `PartialEq` delegates to `OrderedFloat` (so +//! `NaN == NaN`) and to `OrderedDecimal`, whose `PartialEq` is defined as +//! `cmp(..) == Equal`. So min/max returning any one of several mutually equal +//! candidates can never trip an assertion here. #![no_main] use libfuzzer_sys::arbitrary::{self, Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_expr::AggregateFunc; -use mz_repr::{Datum, Diff, RowArena}; +use mz_repr::{Datum, Diff, RowArena, strconv}; const MAX_ROWS: usize = 24; const MAX_CHUNKS: usize = 4; +/// Upper bound on a row's multiplicity. `MAX_ROWS * MAX_DIFF * 2^63` is far +/// inside `i128` and inside numeric's 39 digits of precision, so every partial +/// sum below is exact: `sum_signed_int_counted`'s wrapping arithmetic never +/// wraps, and `SumInt32`'s narrowing to `i64` never truncates. +const MAX_DIFF: u8 = 4; + +/// A generated input row: a datum paired with its multiplicity. +type Update = (Datum<'static>, Diff); #[derive(Clone, Copy)] enum Group { @@ -70,8 +118,8 @@ enum Group { /// (`"a"`/`"ab"`) where lexicographic ordering is subtle. const POOL_STR: &[&str] = &["", "a", "ab", "abc", "b", "Z", "z", "10", "9"]; -/// Which decomposition laws apply to an aggregate (permutation invariance always -/// applies and is checked separately). +/// Which decomposition law applies to an aggregate. Permutation invariance and +/// expansion equivalence apply to every aggregate and are checked separately. enum Law { /// Idempotent, output type == input type: `agg(whole) == agg(map(agg, parts))`. Hierarchical, @@ -97,6 +145,29 @@ const SPECIAL_F64: &[f64] = &[ f64::MAX, ]; +/// Numeric values worth probing. `Datum::from(i128)` only ever yields canonical, +/// finite, exponent-0 numerics, which makes the integer majority of this group a +/// relabelling of `Group::Int64` through a different comparison function. These +/// add the two datum shapes that actually exercise `OrderedDecimal`'s ordering: +/// `NaN` (whose `Ord` sorts above every finite value), and numerically equal +/// values at different scales, which `OrderedDecimal` reduces before comparing +/// and so calls equal despite differing bit patterns. Both min/max and the +/// equality oracle have to agree on those. +/// +/// Every entry must be a value a numeric *datum* can actually hold, otherwise a +/// panic here would be a generator artifact rather than a product bug. +/// `±Infinity` and `-0` are excluded for that reason: `strconv::parse_numeric` +/// rejects a non-overflow infinity outright, `numeric::munge_numeric` folds `-0` +/// to `0` and `-NaN` to `NaN`, and numeric arithmetic returns +/// `EvalError::FloatOverflow` rather than saturating to an infinity. `NaN` is the +/// one special that survives, via `'NaN'::numeric`. +const SPECIAL_NUMERIC: &[&str] = &["NaN", "0", "0.000", "100", "100.00", "1E+2", "-0.5"]; + +/// A positive multiplicity. See the module doc for why retractions are excluded. +fn gen_diff(u: &mut Unstructured) -> arbitrary::Result { + Ok(Diff::from(i64::from(u.int_in_range(1..=MAX_DIFF)?))) +} + fn gen_datum(u: &mut Unstructured, group: Group) -> arbitrary::Result> { if u.ratio(1u8, 5u8)? { return Ok(Datum::Null); @@ -124,10 +195,17 @@ fn gen_datum(u: &mut Unstructured, group: Group) -> arbitrary::Result { - // Integer-valued numerics keep min/max exact and easy to read. The - // point of the group is the `OrderedDecimal` comparison path, not - // fractional precision. - Datum::from(i128::from(i64::arbitrary(u)?)) + // Bias in the specials at the same ratio as the float group. The + // majority stays integer-valued, which keeps min/max exact and easy + // to read, but a fully random i64 never lands on a corner case. + if u.ratio(1u8, 2u8)? { + let i = u.int_in_range(0..=SPECIAL_NUMERIC.len() - 1)?; + Datum::from( + strconv::parse_numeric(SPECIAL_NUMERIC[i]).expect("literal numeric parses"), + ) + } else { + Datum::from(i128::from(i64::arbitrary(u)?)) + } } Group::Str => { let i = u.int_in_range(0..=POOL_STR.len() - 1)?; @@ -181,10 +259,7 @@ fn aggregates(group: Group) -> Vec<(AggregateFunc, Law)> { } /// A Fisher-Yates shuffle driven by the fuzz input. -fn shuffle( - u: &mut Unstructured, - input: &[Datum<'static>], -) -> arbitrary::Result>> { +fn shuffle(u: &mut Unstructured, input: &[Update]) -> arbitrary::Result> { let mut v = input.to_vec(); for i in (1..v.len()).rev() { let j = u.int_in_range(0..=i)?; @@ -195,15 +270,12 @@ fn shuffle( /// Randomly assign each input to one of `1..=MAX_CHUNKS` chunks (some may be /// empty). The chunks' concatenation is a permutation of the input multiset. -fn partition( - u: &mut Unstructured, - input: &[Datum<'static>], -) -> arbitrary::Result>>> { +fn partition(u: &mut Unstructured, input: &[Update]) -> arbitrary::Result>> { let k = u.int_in_range(1usize..=MAX_CHUNKS)?; let mut chunks = vec![Vec::new(); k]; - for &d in input { + for &update in input { let b = u.int_in_range(0..=k - 1)?; - chunks[b].push(d); + chunks[b].push(update); } Ok(chunks) } @@ -217,9 +289,9 @@ fn as_count(d: Datum) -> i64 { /// Decode an integer-sum result datum to an exact `i128`. `SumInt32` yields an /// `Int64`, `SumInt64` yields an (integer-valued) `Numeric`. An empty/all-null -/// chunk yields `Null` (returned as `None`). The values are bounded (<= 24 -/// inputs of at most i64 magnitude), so every partial sum fits an `i128` -/// exactly and the per-chunk combination below is lossless. +/// chunk yields `Null` (returned as `None`). The values are bounded (see +/// `MAX_DIFF`), so every partial sum fits an `i128` exactly and the per-chunk +/// combination below is lossless. fn as_sum(d: Datum) -> Option { match d { Datum::Null => None, @@ -244,37 +316,56 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { let n = u.int_in_range(0usize..=MAX_ROWS)?; let mut input = Vec::with_capacity(n); for _ in 0..n { - input.push(gen_datum(u, group)?); + input.push((gen_datum(u, group)?, gen_diff(u)?)); } let permuted = shuffle(u, &input)?; let chunks = partition(u, &input)?; + // NOTE: expansion is only faithful because every diff is >= 1. At diff 0 the + // row would vanish here while `ignores_multiplicity` aggregates still see it, + // so min/max would diverge for reasons that are not a product bug. + let expanded: Vec = input + .iter() + .flat_map(|&(d, diff)| { + let copies = usize::try_from(diff.into_inner()).expect("positive diff"); + std::iter::repeat((d, Diff::ONE)).take(copies) + }) + .collect(); let arena = RowArena::new(); for (agg, law) in aggregates(group) { - // `AggregateFunc::eval` consumes `(datum, multiplicity)` pairs. Each - // generated row has multiplicity one, so pair every datum with - // `Diff::ONE`. - let whole = agg.eval(input.iter().map(|&d| (d, Diff::ONE)), &arena); + let whole = agg.eval(input.iter().copied(), &arena); // Permutation invariance: order must never matter. - let shuffled = agg.eval(permuted.iter().map(|&d| (d, Diff::ONE)), &arena); + let shuffled = agg.eval(permuted.iter().copied(), &arena); assert_eq!( whole, shuffled, "{agg:?} is not permutation-invariant\n input = {input:?}\n permuted = {permuted:?}" ); + // Expansion equivalence: a row at multiplicity `k` must aggregate + // exactly like `k` rows at multiplicity one. + let unit_diffs = agg.eval(expanded.iter().copied(), &arena); + assert_eq!( + whole, unit_diffs, + "{agg:?} does not treat a diff of k like k unit rows\n input = {input:?}\n expanded = {expanded:?}" + ); + match law { Law::Hierarchical => { - // agg(whole) == agg(map(agg, non-empty chunks)). Empty chunks are - // skipped: an empty chunk aggregates to null, and for any/all - // (three-valued) a stray null would corrupt an otherwise false/true - // result (`false OR null = null`). Min/max absorb null as their - // identity, but skipping is correct for all of them. + // agg(whole) == agg(map(agg, non-empty chunks)). Skipping the + // empty chunks keeps the partials to the results of non-trivial + // work. It is a no-op for these aggregates: min/max aggregate an + // empty chunk to `Null` and filter nulls back out on the way in, + // and any/all fold from their identity (`False`/`True`), so an + // empty chunk contributes nothing either way. let partials: Vec = chunks .iter() .filter(|c| !c.is_empty()) - .map(|c| agg.eval(c.iter().map(|&d| (d, Diff::ONE)), &arena)) + .map(|c| agg.eval(c.iter().copied(), &arena)) .collect(); + // The partials are values, not updates, so each enters the + // re-aggregation once. Every aggregate under this law ignores + // multiplicity regardless. let reaggregated = agg.eval(partials.iter().map(|&d| (d, Diff::ONE)), &arena); assert_eq!( whole, reaggregated, @@ -285,7 +376,7 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { // count(whole) == sum(map(count, chunks)) let total: i64 = chunks .iter() - .map(|c| as_count(agg.eval(c.iter().map(|&d| (d, Diff::ONE)), &arena))) + .map(|c| as_count(agg.eval(c.iter().copied(), &arena))) .sum(); assert_eq!( as_count(whole), @@ -300,7 +391,7 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { // whole is also Null, so both sides are "no partials" and match. let partials: Vec = chunks .iter() - .filter_map(|c| as_sum(agg.eval(c.iter().map(|&d| (d, Diff::ONE)), &arena))) + .filter_map(|c| as_sum(agg.eval(c.iter().copied(), &arena))) .collect(); let combined: Option = if partials.is_empty() { None diff --git a/src/expr/fuzz/fuzz_targets/build_regex.rs b/src/expr/fuzz/fuzz_targets/build_regex.rs index a752cb1d2124e..7b8ec722eef1f 100644 --- a/src/expr/fuzz/fuzz_targets/build_regex.rs +++ b/src/expr/fuzz/fuzz_targets/build_regex.rs @@ -10,8 +10,12 @@ //! Fuzz target: `func::build_regex` compiles an untrusted regular expression //! (and flags) for the `regexp_*` SQL functions, and the result matches //! untrusted text. A user controls both, so a panic compiling or matching is a -//! real availability bug. The `regex` crate is size-limited, so an oversized -//! pattern returns an error rather than OOMing. Single-match ops +//! real availability bug. Two independent size limits guard that, and both are +//! under test here: the `regex` crate's `size_limit` bounds the *compiled* NFA, +//! and `MAX_REGEX_SIZE_BEFORE_COMPILATION` bounds `pattern.len()` before +//! compilation, because the memory a compile spends translating the pattern is +//! not something `size_limit` ever sees. Exceeding either must yield an error +//! rather than an OOM or a hang. Single-match ops //! (`is_match`/`find`/`captures`) run in linear time, but the all-matches ops //! (`replace_all`/`split`) re-scan from every match via `find_iter` and are //! superlinear in the text length for adversarial patterns, so the match text @@ -32,6 +36,10 @@ //! * occasional near-size-limit patterns built from nested counted quantifiers, //! which push the compiler toward its state-count / size limit (where it must //! return an error rather than hang or OOM) and toward deep AST nesting. +//! * rare very long patterns, built by repeating one small unit, which straddle +//! `MAX_REGEX_SIZE_BEFORE_COMPILATION`. These are the only way to reach the +//! compile-time memory cost, which scales with the *source* length and so is +//! invisible to every other arm (see `gen_long_pattern`). #![no_main] @@ -145,9 +153,12 @@ fn gen_regex( } /// Builds a deeply nested chain of counted quantifiers whose multiplied bounds -/// approach the regex crate's compiled-size limit, e.g. `(?:(?:a{40}){40}){40}`. -/// `build_regex` must reject this with an error (PatternTooLarge or the regex -/// crate's CompiledTooBig) rather than hang or OOM. +/// approach the regex crate's compiled-size limit, e.g. `(?:(?:a){40}){40}`. +/// +/// These patterns stay tiny in source form, a few dozen bytes, so +/// `MAX_REGEX_SIZE_BEFORE_COMPILATION` never fires on them. The regex crate's +/// own `size_limit` is what must reject them, with `CompiledTooBig`, rather than +/// hanging or OOMing. `gen_long_pattern` covers the other limit. fn gen_near_limit(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { let layers = u.int_in_range(2usize..=5)?; for _ in 0..layers { @@ -160,20 +171,64 @@ fn gen_near_limit(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<( Ok(()) } +/// Builds a pattern that is long *before* compilation by repeating one small +/// unit. +/// +/// The regex crate's `size_limit` bounds only the compiled NFA. Class-heavy +/// patterns spend their memory earlier, in `regex-syntax`'s HIR translation, +/// which that limit never sees, so cost scales with the pattern's *source* +/// length. Measured worst case is ~7.4 KB of peak RSS per pattern byte +/// (`\p{L}` repeated, under the `i` flag, where case-folding the Unicode class +/// is the multiplier), against ~0.35 KB per byte for a plain literal. That +/// makes `MAX_REGEX_SIZE_BEFORE_COMPILATION` the only guard on the vector, so +/// straddle it: draw a length on both sides of 1 MiB, exercising both the +/// `PatternTooLarge` rejection and the largest pattern that gets through. +/// +/// The length is synthesized from a handful of input bytes rather than read out +/// of the input, since libFuzzer's default `-max_len` of 4096 puts a pattern +/// this long out of reach of the raw arm. +fn gen_long_pattern(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { + let unit = *u.choose(&[ + "\\p{L}", + "\\p{Greek}", + "\\w", + "\\d", + "\\s", + "[a-c]", + "a", + "(?:a)", + ])?; + let bytes = u.int_in_range(4096usize..=1_100_000)?; + out.reserve(bytes + unit.len()); + // Round *up*, so a multi-byte unit can still land above the guard. + for _ in 0..bytes.div_ceil(unit.len()) { + out.push_str(unit); + } + Ok(()) +} + /// A replacement string mixing several capture-reference forms so the regex /// crate's interpolation runs against whatever captures the match produced: /// numbered (`$1`), named-braced (`${g0}`), the whole match (`$0`), a literal -/// `$$`, and an out-of-range index (`$99`, which interpolates to empty). -const REPLACEMENT: &str = "x$1-${g0}-$0-$$-$99y"; +/// `$$`, and an out-of-range index (`${99}`, which interpolates to empty). +/// +/// NOTE: the braces on `${99}` are load-bearing. An unbraced `$name` takes the +/// longest run of `[0-9A-Za-z_]`, so `$99y` would parse as the capture *name* +/// `99y` and take the name-lookup path instead of the index one, silently +/// swallowing the trailing literal. +const REPLACEMENT: &str = "x$1-${g0}-$0-$$-${99}y"; fn drive(pattern: &str, flags: &str, text: &str) { - let pattern = cap(pattern, 4096); // The all-matches ops below (`replace_all`, `split`) drive `find_iter`, // which re-scans from each match and is superlinear in the text length for // adversarial patterns. Coverage instrumentation amplifies that by orders of // magnitude, so keep the match text very short to stay within libFuzzer's - // per-unit timeout. Pattern length compiles fast and is not the amplifier, - // so it stays generous. + // per-unit timeout. + // + // The pattern is deliberately *not* capped. Its length is what drives + // compile-time memory, so capping it here would hide the very blowup this + // target is meant to reach, and `MAX_REGEX_SIZE_BEFORE_COMPILATION` is the + // guard under test. See `gen_long_pattern`. let text = cap(text, 16); let Ok(regex) = func::build_regex(pattern, flags) else { return; @@ -198,13 +253,17 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { return Ok(()); } let mut pattern = String::new(); - // Occasionally emit a near-size-limit nested-quantifier pattern (the - // compiler must reject it cleanly). Otherwise the structured generator. - if u.int_in_range(0u8..=7)? == 0 { - gen_near_limit(&mut u, &mut pattern)?; - } else { - let mut name_id = 0u32; - gen_regex(&mut u, 3, &mut name_id, &mut pattern)?; + // Occasionally emit an adversarially sized pattern, aimed at one of the two + // size limits, which the compiler must reject cleanly. The long-pattern arm + // legitimately costs seconds and gigabytes per unit, so it stays rare. + // Otherwise the structured generator. + match u.int_in_range(0u8..=15)? { + 0 | 1 => gen_near_limit(&mut u, &mut pattern)?, + 2 => gen_long_pattern(&mut u, &mut pattern)?, + _ => { + let mut name_id = 0u32; + gen_regex(&mut u, 3, &mut name_id, &mut pattern)?; + } } let mut text = String::new(); for _ in 0..u.int_in_range(0usize..=24)? { diff --git a/src/expr/fuzz/fuzz_targets/cast_string.rs b/src/expr/fuzz/fuzz_targets/cast_string.rs index c91eb7b8810c8..810a782bb5d4c 100644 --- a/src/expr/fuzz/fuzz_targets/cast_string.rs +++ b/src/expr/fuzz/fuzz_targets/cast_string.rs @@ -30,7 +30,7 @@ use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_expr::{func, Eval, MirScalarExpr, UnaryFunc}; +use mz_expr::{Eval, MirScalarExpr, UnaryFunc, func}; use mz_repr::{Datum, ReprScalarType, RowArena}; const HEX: &[u8] = b"0123456789abcdefABCDEF"; diff --git a/src/expr/fuzz/fuzz_targets/eval_error_proto_roundtrip.rs b/src/expr/fuzz/fuzz_targets/eval_error_proto_roundtrip.rs index e3ad1d4634199..68bfb7cca11f4 100644 --- a/src/expr/fuzz/fuzz_targets/eval_error_proto_roundtrip.rs +++ b/src/expr/fuzz/fuzz_targets/eval_error_proto_roundtrip.rs @@ -8,24 +8,30 @@ // by the Apache License, Version 2.0. //! Fuzz target: `EvalError` must survive a proto re-encode + re-decode with the -//! same value, exercising lossy proto conversions where the wire form decodes -//! into a value that doesn't round-trip back to itself. +//! same value, and the decoded value must then be renderable. This exercises +//! lossy proto conversions where the wire form decodes into a value that doesn't +//! round-trip back to itself, and decoded states that no Rust constructor +//! produces and that consumers therefore mishandle. //! //! Two input modes share the byte stream (the first byte selects the mode): //! //! * Mode A (Arbitrary): drive proptest's `Arbitrary` impl for `EvalError` //! from the libFuzzer byte stream to synthesize a *valid, deeply nested* -//! `EvalError` (one of 80+ variants, several carrying `char`, `usize`, -//! nested `NumericMaxScale`/`InvalidArrayError`/`DomainLimit` invariants). -//! We then assert `from_proto(into_proto(v)) == v`. Random bytes decoded as -//! a proto almost always yield near-empty/default messages, so this arm is -//! what actually reaches the interesting variants and their narrowing / -//! validation logic (`char::from_proto`, `usize`/`u32` casts, etc.). +//! `EvalError` (one of 80+ variants, several carrying `char`, `usize`, or a +//! nested `InvalidArrayError` / `ParseError` / `ParseHexError` / +//! `InvalidRangeError` / `DomainLimit`). We then assert +//! `from_proto(into_proto(v)) == v`. Random bytes decoded as a proto almost +//! always yield near-empty/default messages, so this arm is what reaches the +//! deeper variants at all, along with the `usize <-> u64` / `char <-> u32` +//! casts on their payloads. //! //! * Mode B (raw bytes): decode the remaining bytes directly as //! `ProtoEvalError` and, if it converts to Rust, assert it round-trips. -//! Kept for robustness against hand-crafted / malformed wire forms that the -//! structured generator would never produce. +//! Mode A always starts from an already-valid Rust value, so it structurally +//! cannot reach the *rejection* branches of `char::from_proto` / +//! `usize::from_proto`, nor any state that no constructor builds. Only this +//! arm can, which is why it stays even though most inputs decode to +//! something trivial. #![no_main] @@ -36,16 +42,25 @@ use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; use prost::Message; -/// Assert that a `EvalError` survives encode -> decode -> into_rust unchanged. +/// Assert that a `EvalError` survives encode -> decode -> into_rust unchanged, +/// and that the decoded value is renderable. fn assert_roundtrip(orig: &EvalError) { let proto = >::from_rust(orig); let bytes = proto.encode_to_vec(); - let decoded = ProtoEvalError::decode(bytes.as_slice()) - .expect("re-encode of valid EvalError must decode"); + let decoded = + ProtoEvalError::decode(bytes.as_slice()).expect("re-encode of valid EvalError must decode"); let round: EvalError = decoded .into_rust() .expect("re-encoded EvalError must convert back to Rust"); assert_eq!(orig, &round, "EvalError changed across proto roundtrip"); + + // Round-trip identity alone would certify a decoded value that aborts the + // process the moment anything looks at it. `DataflowErrorSer::Display` + // decodes untrusted bytes and Displays them on the index peek path, so a + // panicking `Display`/`detail`/`hint` arm is a dataflow crash loop. + let _ = round.to_string(); + let _ = round.detail(); + let _ = round.hint(); } /// Decode `data` directly as a `ProtoEvalError`. If it converts to a Rust @@ -78,11 +93,13 @@ fuzz_target!(|data: &[u8]| { } let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed); let mut runner = TestRunner::new_with_rng(Config::default(), rng); - let value = ::arbitrary() - .new_tree(&mut runner) - .expect("valuetree") - .current(); - assert_roundtrip(&value); + let Ok(tree) = + ::arbitrary().new_tree(&mut runner) + else { + // A generator rejection is a harness limit, not a product bug. + return; + }; + assert_roundtrip(&tree.current()); } else { // Mode B: raw wire bytes. raw_bytes_arm(rest); diff --git a/src/expr/fuzz/fuzz_targets/jsonb_get.rs b/src/expr/fuzz/fuzz_targets/jsonb_get.rs index cebff0629b2cb..c354d6b395d68 100644 --- a/src/expr/fuzz/fuzz_targets/jsonb_get.rs +++ b/src/expr/fuzz/fuzz_targets/jsonb_get.rs @@ -23,6 +23,13 @@ //! generate the access key/index from that same set, so the accessors hit real //! fields/elements (the success + traversal paths) as well as missing ones, and //! the index includes out-of-range and extreme values for the array-bounds path. +//! +//! Two caps on the generated document are deliberate rather than oversights. +//! String values draw from a fragment set that covers escapes and non-ASCII but +//! omits the NUL escape (SQL-475), and nesting stops at depth 5 (SQL-515). Both +//! of the excluded shapes hit bugs that are tracked already and that this target +//! has no oracle for anyway, and a target that reproduces a known crash on every +//! run buries whatever else it would have found. #![no_main] @@ -30,13 +37,33 @@ use std::str::FromStr; use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_expr::{func, Eval, MirScalarExpr}; +use mz_expr::{Eval, MirScalarExpr, func}; use mz_repr::adt::jsonb::Jsonb; use mz_repr::{Datum, ReprScalarType, RowArena}; /// Object keys, kept to a small set so generated access keys hit real fields. const KEYS: &[&str] = &["a", "b", "c", "x"]; +/// Fragments of a JSON string value, spliced raw between the quotes, so escape +/// sequences appear here already escaped. Past plain ASCII these cover what +/// `->>` has to re-escape when `jsonb_stringify` renders the accessed element +/// back to text: quote, backslash, a control character, and non-ASCII in both +/// `\u` and raw UTF-8 form, including a surrogate pair. +const STRING_PARTS: &[&str] = &[ + "a", + "z", + "0", + " ", + "\\\"", + "\\\\", + "\\n", + "\\u0001", + "\\u00e9", + "é", + "\\ud83d\\ude00", + "😀", +]; + fn gen_json(u: &mut Unstructured, depth: u32, out: &mut String) -> arbitrary::Result<()> { let leaf = depth == 0 || u.is_empty(); match if leaf { @@ -54,7 +81,7 @@ fn gen_json(u: &mut Unstructured, depth: u32, out: &mut String) -> arbitrary::Re 3 => { out.push('"'); for _ in 0..u.int_in_range(0usize..=4)? { - out.push(*u.choose(&['a', 'z', '0', ' '])?); + out.push_str(u.choose(STRING_PARTS)?); } out.push('"'); } @@ -87,15 +114,31 @@ fn gen_json(u: &mut Unstructured, depth: u32, out: &mut String) -> arbitrary::Re Ok(()) } -fn run(mut u: Unstructured) -> arbitrary::Result<()> { - let mut json = String::new(); - gen_json(&mut u, 5, &mut json)?; - let Ok(jsonb) = Jsonb::from_str(&json) else { - return Ok(()); - }; - let value = jsonb.as_ref().into_datum(); - let arena = RowArena::new(); +/// Evaluates `expr`, requiring that it neither panic nor error. +/// +/// The four accessors under test are infallible, they return `Option`, not +/// `Result`, and both operands are `Literal(Ok(..))`, which cannot error. So the +/// only `Err` reachable here is the `EvalError::Internal` that the eager binary +/// dispatch raises when a literal's `Datum`/`ReprScalarType` does not match the +/// function's declared input type. Nothing checks that match at compile time, so +/// an `Err` means this harness built an invalid expression and every eval below +/// short-circuits before reaching the code under test, leaving a target that +/// exercises nothing yet still reports clean. +fn eval_ok(expr: MirScalarExpr, arena: &RowArena) { + let res = expr.eval(&[], arena); + assert!(res.is_ok(), "harness built an invalid expression: {res:?}"); +} +fn run(mut u: Unstructured) -> arbitrary::Result<()> { + // The access key and index are drawn before the document, and the order + // matters. `gen_json` scales its appetite with the input it is handed and + // drains the buffer on anything up to libFuzzer's default `-max_len=4096`, + // and `Unstructured` does not error once exhausted, it returns the low end of + // every range. Drawing these afterwards therefore pins them to `""` and `0` + // for the vast majority of executions, which unfuzzes half the input space. + // `gen_json` degrades gracefully on the remainder, it emits `null` when + // starved. + // // Key: usually a real field name (hit), sometimes a miss / arbitrary string. let key_buf; let key: &str = if u.int_in_range(0u8..=2)? == 0 { @@ -116,27 +159,39 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { i64::from(u.int_in_range(-3i32..=8)?) }; + let mut json = String::new(); + gen_json(&mut u, 5, &mut json)?; + let Ok(jsonb) = Jsonb::from_str(&json) else { + return Ok(()); + }; + let value = jsonb.as_ref().into_datum(); + let arena = RowArena::new(); + let key_expr = || MirScalarExpr::literal_ok(Datum::String(key), ReprScalarType::String); let index_expr = || MirScalarExpr::literal_ok(Datum::Int64(index), ReprScalarType::Int64); let jsonb_expr = || MirScalarExpr::literal_ok(value, ReprScalarType::Jsonb); // jsonb -> '' (object field access, returns jsonb) - let _ = jsonb_expr() - .call_binary(key_expr(), func::JsonbGetString) - .eval(&[], &arena); + eval_ok( + jsonb_expr().call_binary(key_expr(), func::JsonbGetString), + &arena, + ); // jsonb ->> '' (object field access, returns text via jsonb_stringify) - let _ = jsonb_expr() - .call_binary(key_expr(), func::JsonbGetStringStringify) - .eval(&[], &arena); + eval_ok( + jsonb_expr().call_binary(key_expr(), func::JsonbGetStringStringify), + &arena, + ); // jsonb -> (array element access, returns jsonb) - let _ = jsonb_expr() - .call_binary(index_expr(), func::JsonbGetInt64) - .eval(&[], &arena); + eval_ok( + jsonb_expr().call_binary(index_expr(), func::JsonbGetInt64), + &arena, + ); // jsonb ->> (array element access, returns text via jsonb_stringify) - let _ = jsonb_expr() - .call_binary(index_expr(), func::JsonbGetInt64Stringify) - .eval(&[], &arena); + eval_ok( + jsonb_expr().call_binary(index_expr(), func::JsonbGetInt64Stringify), + &arena, + ); Ok(()) } diff --git a/src/expr/fuzz/fuzz_targets/jsonb_path.rs b/src/expr/fuzz/fuzz_targets/jsonb_path.rs index 9750217628710..76c95f6180fbf 100644 --- a/src/expr/fuzz/fuzz_targets/jsonb_path.rs +++ b/src/expr/fuzz/fuzz_targets/jsonb_path.rs @@ -38,7 +38,7 @@ use std::str::FromStr; use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_expr::{func, Eval, MirScalarExpr}; +use mz_expr::{Eval, MirScalarExpr, func}; use mz_repr::adt::array::{ArrayDimension, InvalidArrayError}; use mz_repr::adt::jsonb::Jsonb; use mz_repr::{Datum, ReprScalarType, RowArena}; @@ -119,14 +119,15 @@ fn gen_json(u: &mut Unstructured, depth: u32, out: &mut String) -> arbitrary::Re } fn run(mut u: Unstructured) -> arbitrary::Result<()> { - let mut json = String::new(); - gen_json(&mut u, 5, &mut json)?; - let Ok(jsonb) = Jsonb::from_str(&json) else { - return Ok(()); - }; - let value = jsonb.as_ref().into_datum(); - let arena = RowArena::new(); - + // The path is drawn before the document, and the order matters. `gen_json` + // scales its appetite with the input it is handed and drains the buffer on + // anything up to libFuzzer's default `-max_len=4096`, and `Unstructured` does + // not error once exhausted, it returns the low end of every range. Drawing + // the path afterwards therefore pins `n` to 0 for the vast majority of + // executions, and `jsonb #> '{}'` returns the document unchanged, so the walk + // this target exists for would never run. `gen_json` degrades gracefully on + // the remainder, it emits `null` when starved. + // // Path components: object keys (hits), array indices ("0".."3"), and an // occasional miss, so the walk descends real structure and also dead-ends. let n = u.int_in_range(0usize..=5)?; @@ -139,6 +140,14 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { }); } + let mut json = String::new(); + gen_json(&mut u, 5, &mut json)?; + let Ok(jsonb) = Jsonb::from_str(&json) else { + return Ok(()); + }; + let value = jsonb.as_ref().into_datum(); + let arena = RowArena::new(); + let dims = if path.is_empty() { Vec::new() } else { diff --git a/src/expr/fuzz/fuzz_targets/like_pattern_compile.rs b/src/expr/fuzz/fuzz_targets/like_pattern_compile.rs index 119c00fe5e15f..7cfe239eb5ce5 100644 --- a/src/expr/fuzz/fuzz_targets/like_pattern_compile.rs +++ b/src/expr/fuzz/fuzz_targets/like_pattern_compile.rs @@ -14,18 +14,29 @@ //! //! 1. Compiling an arbitrary pattern and matching arbitrary text never panics. //! 2. A pattern built by escaping every wildcard/escape character in `text` is -//! a pure literal, and `LIKE` is anchored, so it must match exactly `text`. +//! a pure literal, and `LIKE` is anchored, so it must match exactly `text`: +//! `text` matches, and `text` with a character appended does not. //! //! A pattern/text pair of random Unicode rarely contains the wildcard/escape //! metacharacters or the literal overlap that drives the interesting code, so it //! plateaus on shallow shapes. Instead we mostly draw both the pattern and the //! match text from a tiny shared alphabet of literals plus the LIKE -//! metacharacters `%`, `_`, and `\`. Generating *multiple* `%` is deliberate: at -//! two-or-more `%` (`many_subpatterns > 1`) `compile` abandons the backtracking -//! string matcher and routes to the linear-time regex engine, a boundary the -//! string-only inputs never reach. Drawing the text from the same alphabet means -//! it actually matches the wildcards, exercising the `%`-suffix-search / -//! backtracking and the regex path on real (not always-empty) matches. +//! metacharacters `%`, `_`, and `\`. Generating *several* `%` interleaved with +//! literals is deliberate, because that is what crosses the routing boundary +//! between the two matcher implementations: `compile` abandons the backtracking +//! string matcher for the linear-time regex engine once two or more `%` +//! subpatterns carry a *non-empty* suffix (so `%a%b`, but not `%%%%`, which +//! collapses into one subpattern, and not `%a%`, whose trailing `%` has an empty +//! suffix), or once the subpattern count exceeds `MAX_SUBPATTERNS`, which the +//! `%`-heavy weighting reaches most often in practice. +//! +//! Drawing the text from the same alphabet means it actually matches the +//! wildcards, exercising the `%`-suffix-search / backtracking and the regex path +//! on real (not always-empty) matches. The alphabet carries multi-byte +//! characters so byte offsets and character counts diverge: the `%` backtracking +//! in `is_match_subpatterns` mixes character counting with byte indexing and +//! walks back over char boundaries by byte, and with a single-byte alphabet that +//! walk degenerates to one iteration and can never expose an off-by-one. #![no_main] @@ -34,11 +45,14 @@ use libfuzzer_sys::fuzz_target; use mz_expr::like_pattern; /// Literals shared between pattern and text, so wildcards match real characters. -const LITERALS: &[char] = &['a', 'b', 'c']; +/// `é` (2 bytes) and `漢` (3 bytes) make byte offsets and character counts +/// diverge, so the char-boundary walk in `is_match_subpatterns` runs for more +/// than one iteration. +const LITERALS: &[char] = &['a', 'b', 'c', 'é', '漢']; /// Builds a LIKE pattern over the literal alphabet plus the `%`/`_`/`\` -/// metacharacters, weighted toward emitting several `%` so the >1-`%` regex -/// routing boundary is crossed. +/// metacharacters, weighted toward emitting several `%` so patterns land on both +/// sides of the string-matcher/regex routing boundary. fn gen_pattern(u: &mut Unstructured) -> arbitrary::Result { let mut p = String::new(); let n = u.int_in_range(0usize..=16)?; @@ -53,7 +67,7 @@ fn gen_pattern(u: &mut Unstructured) -> arbitrary::Result { // reachable when this is the last iteration. p.push('\\'); if u.int_in_range(0u8..=3)? != 0 { - p.push(*u.choose(&['%', '_', '\\', 'a'])?); + p.push(*u.choose(&['%', '_', '\\', 'a', 'é'])?); } } _ => p.push(*u.choose(LITERALS)?), @@ -96,11 +110,31 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { } literal.push(c); } - if let Ok(matcher) = like_pattern::compile(&literal, case_insensitive) { - assert!( - matcher.is_match(&text), - "literal LIKE pattern {literal:?} must match its source text {text:?}" - ); + match like_pattern::compile(&literal, case_insensitive) { + Ok(matcher) => { + assert!( + matcher.is_match(&text), + "literal LIKE pattern {literal:?} must match its source text {text:?}" + ); + // And nothing else: the pattern is anchored at both ends and every + // one of its characters consumes exactly one text character, under + // `ILIKE` too, since simple case folding maps a character to other + // single characters. So appending anything must break the match. This + // is the direction that catches over-matching regressions, such as a + // lost anchor in the regex or an escaped metacharacter that falls + // back to wildcard semantics. + let longer = format!("{text}a"); + assert!( + !matcher.is_match(&longer), + "literal LIKE pattern {literal:?} must not match {longer:?}" + ); + } + // Every escape sequence in `literal` is terminated by construction, so + // the documented pattern-length limit is the only legitimate rejection. + Err(e) => assert!( + literal.len() > 8 << 10, + "valid literal pattern {literal:?} failed to compile: {e}" + ), } Ok(()) } diff --git a/src/expr/fuzz/fuzz_targets/like_pattern_escape.rs b/src/expr/fuzz/fuzz_targets/like_pattern_escape.rs index 81ffb17964753..00b87491e13db 100644 --- a/src/expr/fuzz/fuzz_targets/like_pattern_escape.rs +++ b/src/expr/fuzz/fuzz_targets/like_pattern_escape.rs @@ -10,10 +10,27 @@ //! Fuzz target: `like_pattern::normalize_pattern` rewrites a `LIKE ... ESCAPE c` //! pattern (custom escape character) into the default-escape form before //! compilation. The user controls both the pattern and the escape character, so -//! the custom-escape rewrite, a char-by-char parser with escape state, must -//! not panic on any input, and its output must still compile and match. This is -//! the `ESCAPE`-clause path that `like_pattern_compile` (default escape only) -//! never exercises. +//! the custom-escape rewrite, a char-by-char parser with escape state, must not +//! panic on any input. This is the `ESCAPE`-clause path that +//! `like_pattern_compile` (default escape only) never exercises. +//! +//! `normalize_pattern` has no slicing, no byte indexing and no fallible +//! arithmetic, so a panic-only harness over it can never fail. The bug class it +//! can actually carry is a miscompilation in the escape state machine, a +//! dropped, doubled or mis-paired escape. Two oracles pin that down: +//! +//! 1. Every output of `normalize_pattern` emits the default escape `\` only as +//! part of a completed two-character escape, so it is well-formed +//! default-escape form and must compile. The size limit is the only +//! legitimate rejection. See the exception for the identity passthrough at +//! the check itself. +//! 2. Escaping every metacharacter *and* the escape character itself with the +//! custom escape character yields a pure literal pattern. `LIKE` is +//! anchored, so after normalization it must match exactly its own source +//! text: `text` matches, and `text` with a character appended does not. A +//! lost `\` turns an escaped `%`/`_` back into a wildcard and the appended +//! direction fires. A spurious or mis-paired `\` breaks the match and the +//! first direction fires. //! //! With a fully arbitrary escape char and a fully arbitrary pattern, the escape //! char almost never coincides with a character actually in the pattern, so the @@ -29,45 +46,116 @@ use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; +use mz_expr::EvalError; use mz_expr::like_pattern::{self, EscapeBehavior}; -/// Shared alphabet for the pattern and the escape char, so the escape char +/// Shared alphabet for the pattern and the match text, so the escape char /// frequently matches characters in the pattern and the consume/unterminated /// branches fire. Includes the LIKE metacharacters and a few literals. const ALPHA: &[char] = &['%', '_', '\\', 'a', 'b', 'c']; -fn gen_pattern(u: &mut Unstructured) -> arbitrary::Result { - let mut p = String::new(); +/// Alphabet for the escape char: `ALPHA` minus the default escape `\`. +/// `EscapeBehavior::Char('\\')` is an identity passthrough that never enters the +/// custom-escape state machine, so drawing it here would spend executions on a +/// two-line branch. `\` stays in `ALPHA` because a `\` in the *pattern* is what +/// drives the doubling branch of the rewrite. +const ESCAPE_ALPHA: &[char] = &['%', '_', 'a', 'b', 'c']; + +fn gen_over_alpha(u: &mut Unstructured) -> arbitrary::Result { + let mut s = String::new(); for _ in 0..u.int_in_range(0usize..=20)? { - p.push(*u.choose(ALPHA)?); + s.push(*u.choose(ALPHA)?); } - Ok(p) + Ok(s) } fn run(mut u: Unstructured) -> arbitrary::Result<()> { let case_insensitive = u.arbitrary()?; - let (pattern, escape_char) = if u.int_in_range(0u8..=3)? == 0 { - // Some fully-arbitrary inputs keep the raw-Unicode reject coverage. - (u.arbitrary::()?, u.arbitrary::()?) + let (pattern, escape_char, text) = if u.int_in_range(0u8..=3)? == 0 { + // Some fully-arbitrary inputs keep the raw-Unicode reject coverage, and + // give `is_match` text long enough to expose the super-linear + // backtracking in the string matcher. + ( + u.arbitrary::()?, + u.arbitrary::()?, + u.arbitrary::()?, + ) } else { - (gen_pattern(&mut u)?, *u.choose(ALPHA)?) + // Text from the same alphabet so the compiled matcher can match. + ( + gen_over_alpha(&mut u)?, + *u.choose(ESCAPE_ALPHA)?, + gen_over_alpha(&mut u)?, + ) }; - // Text from the same literal-ish alphabet so the compiled matcher can match. - let mut text = String::new(); - for _ in 0..u.int_in_range(0usize..=20)? { - text.push(*u.choose(ALPHA)?); - } for behavior in [EscapeBehavior::Char(escape_char), EscapeBehavior::Disabled] { let Ok(normalized) = like_pattern::normalize_pattern(&pattern, behavior) else { continue; }; - // The rewritten pattern is in default-escape form and must compile and - // match arbitrary text without panicking. - if let Ok(matcher) = like_pattern::compile(&normalized, case_insensitive) { - let _ = matcher.is_match(&text); + // `normalize_pattern` emits `\` only as part of a completed escape pair, + // so its output is well-formed default-escape form and the documented + // size limit is the only legitimate rejection. Anything else, notably + // the `EvalError::Internal` that `build_regex` raises on a regex it + // failed to build, means the rewrite produced a pattern the compiler + // cannot read. `EscapeBehavior::Char('\\')` is an identity passthrough, + // so there a lone trailing `\` from the input pattern reaches `compile` + // unchanged and rejecting it is correct. + let passthrough = matches!(behavior, EscapeBehavior::Char('\\')); + match like_pattern::compile(&normalized, case_insensitive) { + Ok(matcher) => { + let _ = matcher.is_match(&text); + } + Err(EvalError::LikePatternTooLong) => {} + Err(EvalError::UnterminatedLikeEscapeSequence) if passthrough => {} + Err(e) => panic!( + "normalize_pattern({pattern:?}, {behavior:?}) = {normalized:?} \ + failed to compile: {e:?}" + ), + } + } + + // Escaping every metacharacter in `text` with `escape_char`, plus + // `escape_char` itself, yields a pattern that normalizes to a pure literal. + // The construction holds for every `escape_char`, including when it is + // itself `%`, `_` or `\`: `escape_char` is only ever emitted as the + // immediate prefix of the character it escapes, so `normalize_pattern`'s + // left-to-right pairing lines up exactly with it and no unescaped wildcard + // survives. It also can never end in a dangling escape, which is why + // normalization here must succeed. + let mut literal = String::with_capacity(2 * text.len()); + for c in text.chars() { + if matches!(c, '%' | '_' | '\\') || c == escape_char { + literal.push(escape_char); + } + literal.push(c); + } + let normalized = like_pattern::normalize_pattern(&literal, EscapeBehavior::Char(escape_char)) + .expect("every escape in `literal` is terminated by construction"); + match like_pattern::compile(&normalized, case_insensitive) { + Ok(matcher) => { + assert!( + matcher.is_match(&text), + "literal pattern {literal:?} (escape {escape_char:?}) normalized to \ + {normalized:?} must match its source text {text:?}" + ); + // And nothing else: the pattern is anchored at both ends and every + // one of its characters consumes exactly one text character, under + // `ILIKE` too, since simple case folding maps a character to other + // single characters. So appending anything must break the match. + let longer = format!("{text}a"); + assert!( + !matcher.is_match(&longer), + "literal pattern {literal:?} (escape {escape_char:?}) normalized to \ + {normalized:?} must not match {longer:?}" + ); } + Err(EvalError::LikePatternTooLong) => {} + Err(e) => panic!( + "literal pattern {literal:?} (escape {escape_char:?}) normalized to \ + {normalized:?} failed to compile: {e:?}" + ), } Ok(()) } diff --git a/src/expr/fuzz/fuzz_targets/mfp_optimize.rs b/src/expr/fuzz/fuzz_targets/mfp_optimize.rs index 1294d8397671e..2788fed6afa64 100644 --- a/src/expr/fuzz/fuzz_targets/mfp_optimize.rs +++ b/src/expr/fuzz/fuzz_targets/mfp_optimize.rs @@ -33,13 +33,15 @@ //! twice-optimized plan, which can repair a first-pass miscompile that //! production `into_plan`-on-raw callers still execute. `optimize` only //! iterates while the expression size strictly decreases, so a fresh second -//! call is not guaranteed to be a no-op. The oracle is one-directional, -//! mirroring the contract optimize actually owes: optimize is allowed to -//! *drop* an error or a row that the raw MFP would reject, because it removes -//! unused map expressions and reorders predicates. But for every input row the -//! raw MFP passes through cleanly with output `out`, the optimized plan must -//! also pass it through with the byte-identical `out`. (When the raw MFP errors -//! or filters a row we assert nothing.) +//! call is not guaranteed to be a no-op. The oracle constrains both +//! directions of the filter, and neither direction of an error: optimize is +//! allowed to *drop* an error, because `remove_undemanded` deletes unused +//! (possibly fallible) map expressions, and it is allowed to *introduce* one, +//! because sorting predicates by position can surface an error the raw order +//! rejected the row before reaching. So when either side errors we assert +//! nothing. Otherwise: a row the raw MFP passes cleanly with output `out` the +//! optimized plan must pass with the byte-identical `out`, and a row the raw +//! MFP cleanly filters the optimized plan must not pass. //! //! * **Temporal lowering.** Add predicates of the form `mz_now() e` (and //! conjunctions of them) over a bounded `mz_timestamp` expression `e`, then @@ -51,18 +53,22 @@ //! at `T`" (`lower <= T < upper`, with the non-temporal predicates also //! passing) agrees with a substitution reference: the same MFP with every //! `mz_now()` replaced by the literal `mz_timestamp` `T`, evaluated -//! non-temporally. The compared timestamps are kept well below `u64::MAX` so -//! `StepMzTimestamp` (the `+1` the lowering inserts for `<=`/`>`/`=`) never -//! overflows, which keeps the substitution equivalence exact. As above the -//! oracle is one-directional: if the reference errors at `T` we assert -//! nothing. +//! non-temporally. The probed times are a random sample plus the interval's +//! own endpoints, because an off-by-one in the operator translation is +//! observable at an endpoint and nowhere else. The compared timestamps are +//! kept well below `u64::MAX` so `StepMzTimestamp` (the `+1` the lowering +//! inserts for `<=`/`>`/`=`) never overflows, which keeps the substitution +//! equivalence exact. As above the oracle is one-directional: if the reference +//! errors at `T` we assert nothing. #![no_main] +use std::collections::{BTreeMap, BTreeSet}; + use libfuzzer_sys::arbitrary::{self, Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_expr::{ - func, Eval, EvalError, MapFilterProject, MirScalarExpr, SafeMfpPlan, UnmaterializableFunc, + Eval, EvalError, MapFilterProject, MirScalarExpr, SafeMfpPlan, UnmaterializableFunc, func, }; use mz_repr::{Datum, Diff, ReprScalarType, Row, RowArena, Timestamp}; @@ -275,9 +281,13 @@ fn gen_ts_expr(u: &mut Unstructured, cols: &[Ty]) -> arbitrary::Result MirScalarExpr { MirScalarExpr::literal_ok( @@ -335,13 +345,14 @@ fn reference_present(plan: &mz_expr::SafeMfpPlan, row: &Row) -> Option { } } +type Interval = Option<(Timestamp, Option)>; + /// Read the validity interval a temporal plan assigns to `row`, evaluating once /// with `time = 0`, `diff = +1`, and an always-valid frontier. Returns: /// * `Err(())` if evaluation errored, /// * `Ok(None)` if the (non-temporal part of the) plan rejected the row, /// * `Ok(Some((lower, upper)))` for the half-open interval `[lower, upper)` /// (`upper == None` means unbounded above). -type Interval = Option<(Timestamp, Option)>; fn temporal_interval(plan: &mz_expr::MfpPlan, row: &Row) -> Result { let arena = RowArena::new(); let mut datums: Vec = row.iter().collect(); @@ -440,24 +451,45 @@ fn run_nontemporal( for _ in 0..ROWS_PER_MFP { let row = gen_input_row(u, types)?; - // Only a row the raw MFP passes through cleanly constrains the optimized - // plan. An error or a filtered row lets optimize legitimately differ. - let Some(Some(out_ref)) = eval_raw(&mfp, &row) else { + // A raw error constrains nothing: optimize may legitimately drop it. + let Some(raw) = eval_raw(&mfp, &row) else { continue; }; let arena = RowArena::new(); let mut datums_p: Vec = row.iter().collect(); let mut buf_p = Row::default(); - match safe_opt.evaluate_into(&mut datums_p, &arena, &mut buf_p) { - Ok(Some(out)) => assert_eq!( + // Every panic prints both MFPs: which rewrite `optimize` got wrong is not + // recoverable from the row alone, and the crash artifact only replays + // under a `cargo fuzz` build. + let opt_mfp: &MapFilterProject = &safe_opt; + let opt = safe_opt.evaluate_into(&mut datums_p, &arena, &mut buf_p); + match (raw, opt) { + (Some(out_ref), Ok(Some(out))) => assert_eq!( &out_ref, out, - "optimize changed the projected output\n row = {row:?}\n out_ref = {out_ref:?}\n out_opt = {out:?}" + "optimize changed the projected output\n row = {row:?}\n out_ref = {out_ref:?}\n out_opt = {out:?}\n mfp = {mfp:?}\n optimized = {opt_mfp:?}" + ), + (Some(_), Ok(None)) => panic!( + "optimize filtered out a row the raw MFP passed\n row = {row:?}\n mfp = {mfp:?}\n optimized = {opt_mfp:?}" ), - Ok(None) => panic!("optimize filtered out a row the raw MFP passed\n row = {row:?}"), - Err(e) => panic!( - "optimize errored on a row the raw MFP passed cleanly\n row = {row:?}\n err = {e:?}" + (Some(_), Err(e)) => panic!( + "optimize errored on a row the raw MFP passed cleanly\n row = {row:?}\n err = {e:?}\n mfp = {mfp:?}\n optimized = {opt_mfp:?}" ), + // The raw MFP cleanly filtered the row, so the optimized plan must + // not pass it. `optimize` never drops a predicate beyond + // `predicates.dedup()` on exact copies, and no rewrite can turn a + // predicate's `false`/`null` into `true`. Reordering only changes + // which predicate is reached first. The one rewrite that does change + // a value, replacing a mapped expression `e` with `#c` when a + // predicate `#c = e` is present, is value-preserving on any row where + // that predicate holds, and on a row where it does not the predicate + // itself is still there to reject the row. + (None, Ok(Some(out))) => panic!( + "optimize passed a row the raw MFP cleanly filtered\n row = {row:?}\n out_opt = {out:?}\n mfp = {mfp:?}\n optimized = {opt_mfp:?}" + ), + // Filtered on both sides, or the optimized plan errored where the raw + // order rejected the row first. Both are allowed. + (None, Ok(None)) | (None, Err(_)) => {} } } Ok(()) @@ -476,31 +508,53 @@ fn run_temporal( return Ok(()); }; - // Sample the logical times to probe, and build each substitution reference - // plan once (it depends only on `t`, not on the row). A reference plan that - // fails to lower is dropped: we simply assert nothing at that time. - let mut times: Vec<(u64, mz_expr::SafeMfpPlan)> = Vec::with_capacity(TIMES_PER_MFP); + // Logical times probed against every row. + let mut sampled = Vec::with_capacity(TIMES_PER_MFP); for _ in 0..TIMES_PER_MFP { - let t = u.int_in_range(0u64..=TS_BOUND + 2)?; - if let Some(ref_plan) = reference_plan(&mfp, t) { - times.push((t, ref_plan)); - } + sampled.push(u.int_in_range(0u64..=TS_BOUND + 2)?); } + // A substitution reference depends only on `t`, not on the row, so it is + // built once per time and shared across rows. `None` records that lowering + // rejected it, meaning we assert nothing at that time. The map is filled + // lazily because the interval endpoints probed below are row-dependent. + let mut refs: BTreeMap> = BTreeMap::new(); + for _ in 0..ROWS_PER_MFP { let row = gen_input_row(u, types)?; - let interval = temporal_interval(&plan, &row); // A plan that errors on this row lets the lowered plan legitimately // differ from the substitution reference, so assert nothing. - let interval = match interval { - Err(()) => continue, - Ok(iv) => iv, + let Ok(interval) = temporal_interval(&plan, &row) else { + continue; }; - for (t, ref_plan) in × { - // One-directional: only constrain the plan when the reference is a - // clean pass/fail. A reference error lets the lowered plan differ. - let Some(present_ref) = reference_present(ref_plan, &row) else { + let mut times: BTreeSet = sampled.iter().copied().collect(); + // Probe the interval's own endpoints, not just sampled times. An + // off-by-one in the lowering (a missing or spurious `StepMzTimestamp`, + // or a wrong width for `mz_now() = e`) moves an endpoint by one, so it + // is observable at that endpoint and at no other time. Sampled times + // reach an endpoint only incidentally, and mostly the endpoint 0, which + // is what every draw decodes to once a short input leaves `Unstructured` + // with no data. A nonzero endpoint needs the sample to hit it exactly. + if let Some((lower, upper)) = &interval { + for t in [Some(u64::from(*lower)), upper.map(u64::from)] { + let Some(t) = t else { continue }; + times.insert(t); + if let Some(before) = t.checked_sub(1) { + times.insert(before); + } + } + } + + for t in times { + let ref_plan = refs.entry(t).or_insert_with(|| reference_plan(&mfp, t)); + // One-directional: only constrain the plan when the reference lowers + // and is a clean pass/fail. A reference error lets the lowered plan + // differ. + let Some(present_ref) = ref_plan + .as_ref() + .and_then(|ref_plan| reference_present(ref_plan, &row)) + else { continue; }; let present_plan = match &interval { @@ -508,7 +562,7 @@ fn run_temporal( // failed): it is absent at every `t`. None => false, Some((lower, upper)) => { - let tt = Timestamp::new(*t); + let tt = Timestamp::new(t); *lower <= tt && upper.map(|up| tt < up).unwrap_or(true) } }; @@ -563,6 +617,24 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { .filter(filters) .project(projection); + // Skip the shape of the open bug CLU-137. `optimize` reduces every map + // expression and predicate, and reduce's generic error propagation replaces a + // non-strict `And`/`Or`/`ErrorIfNull` with any operand's literal error even + // though `eval` would have absorbed it. All three of this oracle's arms are + // exposed: the folded error makes a passing row error, the literal is typed + // non-nullable so a nullability-dependent rewrite above it can yield a + // different projected value, and a predicate folded that way can drop a row + // outright. This target's existing tolerance does not cover any of that, since + // it only forgives an error on a row the raw MFP *filtered*. + if mfp + .expressions + .iter() + .chain(mfp.predicates.iter().map(|(_, p)| p)) + .any(|e| e.could_hit_nonstrict_error_fold()) + { + return Ok(()); + } + if temporal { run_temporal(u, mfp, &types) } else { diff --git a/src/expr/fuzz/fuzz_targets/mir_scalar_reduce.rs b/src/expr/fuzz/fuzz_targets/mir_scalar_reduce.rs index cc002a60a841d..975bd23ab40e6 100644 --- a/src/expr/fuzz/fuzz_targets/mir_scalar_reduce.rs +++ b/src/expr/fuzz/fuzz_targets/mir_scalar_reduce.rs @@ -37,17 +37,36 @@ //! batch of random rows. The check is mostly one-directional: reduce is allowed //! to *eliminate* a runtime error (e.g. `If(c, x, x)` becomes `x`, dropping `c`, //! and `x AND false` becomes `false`), so we only require that a successful -//! `Ok(v)` result is preserved exactly. The exception is non-strict AND/OR -//! error absorption, where the oracle allows a reduced error if the original -//! row succeeded by absorbing an operand error. Every value type compared is -//! exact (no float/numeric normalization), so equality is the right oracle. +//! `Ok(v)` result is preserved exactly. Every value type compared is exact (no +//! float/numeric normalization), so equality is the right oracle. +//! +//! The exception is a row that absorbs a non-strict AND/OR operand error, the +//! shape of the open bug CLU-137: `Or::eval` returns `true` the moment it sees a +//! true operand and drops any error it collected, while reduce's error +//! propagation replaces the whole call with the operand's literal error. Such a +//! row is skipped outright. +//! +//! NOTE: this carve-out used to be gated on the reduced side also being an error, +//! on the reasoning that "an absorbed error cannot resurface as a value". That is +//! false, and the fuzzer falsified it: once the absorbed error becomes a +//! `Literal(Err)`, every rewrite downstream is reading a value the original never +//! produced, and some of them yield an `Ok` rather than propagating. The observed +//! case reduced `CastBoolToString(IsNull(If(..)))` from `Ok("true")` to a literal +//! `Ok("false")`, with `Or([Literal(Err), Column(3)])` folding to `Literal(Err)` +//! where it evaluates to `Ok(True)`. The wrong value is downstream of CLU-137, +//! not a second defect, so the whole row goes. +//! +//! Leaf and atom distributions are tuned against the folds rather than left +//! uniform, because a literal-heavy expression is annihilated by constant +//! folding, null propagation, or error propagation long before it reaches the +//! rewrites above. See `gen_leaf` and `gen_bool_atoms`. #![no_main] use libfuzzer_sys::arbitrary::{self, Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_expr::func::variadic::{And, Or}; -use mz_expr::{func, Eval, EvalError, MirScalarExpr, VariadicFunc}; +use mz_expr::{Eval, EvalError, MirScalarExpr, VariadicFunc, func}; use mz_repr::{Datum, ReprColumnType, ReprScalarType, Row, RowArena}; // Column layout: a contiguous block per type. Columns are nullable. @@ -137,10 +156,20 @@ fn nonstr_datum(u: &mut Unstructured, ty: Ty) -> arbitrary::Result arbitrary::Result { let st = scalar_ty(ty); - Ok(match u.int_in_range(0u8..=3)? { - 0 => { + Ok(match u.int_in_range(0u8..=5)? { + 0..=2 => { let col = match ty { Ty::Int => COL_INT0 + u.int_in_range(0..=N_INT - 1)?, Ty::Long => COL_LONG0 + u.int_in_range(0..=N_LONG - 1)?, @@ -149,14 +178,14 @@ fn gen_leaf(u: &mut Unstructured, ty: Ty) -> arbitrary::Result { }; MirScalarExpr::column(col) } - 1 => match ty { + 3 => match ty { Ty::Str => { let s = gen_string(u)?; MirScalarExpr::literal_ok(Datum::String(&s), st) } _ => MirScalarExpr::literal_ok(nonstr_datum(u, ty)?, st), }, - 2 => MirScalarExpr::literal_null(st), + 4 => MirScalarExpr::literal_null(st), // An error literal exercises reduce's error propagation/ordering. _ => MirScalarExpr::literal(Err(EvalError::DivisionByZero), st), }) @@ -173,11 +202,27 @@ fn gen_bool_atoms(u: &mut Unstructured, depth: u32) -> arbitrary::Result arbitrary::Result { - match u.int_in_range(0u8..=4)? { + match u.int_in_range(0u8..=7)? { 0 => { let cond = gen_expr(u, Ty::Bool, d)?; let then = gen_expr(u, Ty::Long, d)?; let els = gen_expr(u, Ty::Long, d)?; Ok(cond.if_then_else(then, els)) } + // int8 arithmetic, mirroring the int4 vocabulary: `%` divides by + // zero and negate/abs overflow at `i64::MIN`. 1 => Ok(gen_expr(u, Ty::Long, d)? .call_binary(gen_expr(u, Ty::Long, d)?, func::AddInt64)), 2 => Ok(gen_expr(u, Ty::Long, d)? .call_binary(gen_expr(u, Ty::Long, d)?, func::SubInt64)), 3 => Ok(gen_expr(u, Ty::Long, d)? .call_binary(gen_expr(u, Ty::Long, d)?, func::MulInt64)), + 4 => Ok(gen_expr(u, Ty::Long, d)? + .call_binary(gen_expr(u, Ty::Long, d)?, func::ModInt64)), + 5 => Ok(gen_expr(u, Ty::Long, d)?.call_unary(func::NegInt64)), + 6 => Ok(gen_expr(u, Ty::Long, d)?.call_unary(func::AbsInt64)), // Casts that produce an int8. _ => match u.int_in_range(0u8..=1)? { 0 => Ok(gen_expr(u, Ty::Int, d)?.call_unary(func::CastInt32ToInt64)), @@ -347,6 +398,25 @@ fn gen_row(u: &mut Unstructured) -> arbitrary::Result { Ok(row) } +/// Does evaluating `expr` on this row absorb an `And`/`Or` operand error? +/// +/// This is the per-row refinement of `MirScalarExpr::could_hit_nonstrict_error_fold`, +/// which answers the same question for a whole expression. The coarse predicate is +/// what the plan-level oracles use; here a row-precise answer is worth the extra +/// code, because the CLU-137 shape is common in this generator and skipping every +/// expression carrying it would cost most of the comparisons. +/// +/// It models `And` and `Or`, the only non-strict variadics this target generates. +/// `ErrorIfNull`, the third one the shared predicate knows about, is absent from +/// the vocabulary above; adding it there needs a matching arm here. +/// +/// NOTE: this deliberately evaluates *every* operand of an `And`/`Or`, even +/// though `And::eval` short-circuits on the first `false` and so may never reach +/// a later erroring operand. Reduce's error propagation is equally +/// position-insensitive (it fires on any literal-error operand, wherever it +/// sits), so a short-circuit-aware version here would report "no absorption" for +/// rows whose reduced form is nonetheless an error, and the oracle would flag +/// them. fn absorbs_and_or_operand_error<'a>( expr: &'a MirScalarExpr, datums: &[Datum<'a>], @@ -413,12 +483,15 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { let folded = reduced.eval(&datums, &arena); // The invariant is one-directional. `reduce` is permitted to eliminate // a runtime error, for example when `reduce_if` collapses `If(c, x, x)` - // to `x` and drops `c`. So an `Err` original may become anything. The - // known exception in the other direction: non-strict AND/OR error - // absorption, where a successful `false AND ` or - // `true OR ` may reduce through the absorbed error. Outer - // expressions can then fold that error into another value, for example - // `IS NULL` reducing to `false`, so skip the whole known shape. + // to `x` and drops `c`. So an `Err` original may become anything. + // + // A row that absorbed a non-strict AND/OR operand error is skipped + // entirely, not merely allowed to reduce to an error. Reduce's error + // propagation is not absorption-aware (CLU-137), so from the moment it + // installs that `Literal(Err)` the reduced expression is evaluating + // something the original never computed, and the divergence surfaces as a + // wrong *value* as readily as an error. See the module doc for the case + // that showed it. if original.is_ok() { if absorbs_and_or_operand_error(&expr, &datums, &arena) { continue; diff --git a/src/expr/fuzz/fuzz_targets/timezone_convert.rs b/src/expr/fuzz/fuzz_targets/timezone_convert.rs index 80f7651d63f7e..7dc0d154d8afb 100644 --- a/src/expr/fuzz/fuzz_targets/timezone_convert.rs +++ b/src/expr/fuzz/fuzz_targets/timezone_convert.rs @@ -19,6 +19,11 @@ //! barely run. So most of the time we pick a real IANA zone *with DST* (and a //! few fixed offsets) so the transition math actually executes. A minority arm //! still feeds an arbitrary string to keep the parser's reject paths covered. +//! +//! The result is packed into a `Row` and read back rather than dropped: the +//! leap-second bugs this math is prone to produce an out-of-contract +//! `NaiveTime` that `chrono` hands back without complaint, and only panic once +//! something decodes a `Row` holding it. Evaluating alone would miss them. #![no_main] @@ -28,7 +33,7 @@ use libfuzzer_sys::fuzz_target; use mz_expr::{Eval, MirScalarExpr, UnaryFunc, func}; use mz_pgtz::timezone::{Timezone, TimezoneSpec}; use mz_repr::adt::timestamp::CheckedTimestamp; -use mz_repr::{Datum, ReprScalarType, RowArena}; +use mz_repr::{Datum, ReprScalarType, Row, RowArena}; /// Real zones whose offsets shift (DST / sub-hour / historical), plus a couple /// of fixed offsets, the inputs that actually exercise the conversion math. @@ -46,6 +51,11 @@ const ZONES: &[&str] = &[ "UTC", "+05:30", "-08", + // Sub-minute offset. The whole-minute offsets above can only ever move a + // leap second to another `:59`, so they never reach the folding branch in + // `checked_{add,sub}_with_leapsecond`. The named zones do reach it, but + // only at their pre-standardization LMT offsets. + "+00:00:01", ]; fn run(u: &mut Unstructured) -> arbitrary::Result<()> { @@ -67,8 +77,32 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { }; tz }; - let secs = u.int_in_range(-8_000_000_000_000i64..=8_000_000_000_000)?; - let nanos = u.int_in_range(0u32..=999_999_999)?; + // 3-in-4 draws land in 1900-2100, where the DST transitions of the zones + // above actually live. A uniform draw over the whole representable range + // (±253,000 years) would hit an ambiguous or nonexistent local time with + // probability ~5e-8. The wide arm bottoms out at `LOW_DATE` (-4713-12-31, + // -210_863_606_400): `CheckedTimestamp` rejects everything below that, so + // drawing down to chrono's own limit instead would leave roughly half of + // all iterations evaluating nothing at all. + let mut secs = if u.int_in_range(0u8..=3)? != 0 { + u.int_in_range(-2_208_988_800i64..=4_102_444_800)? + } else { + u.int_in_range(-210_863_606_400i64..=8_000_000_000_000)? + }; + // chrono encodes a leap second as `nanos >= 1_000_000_000`, and only accepts + // that encoding when the second-of-minute is 59. That encoding is exactly + // what the folding in `checked_{add,sub}_with_leapsecond` exists for, so + // construct it deliberately rather than hoping a uniform `nanos` draw lands + // there (it never can) or that `secs` happens to be aligned. + let nanos = if bool::arbitrary(u)? { + // `DateTime::from_timestamp` derives the second-of-day as + // `secs.rem_euclid(86_400)`, and `86_400 % 60 == 0`, so aligning `secs` + // on `rem_euclid(60) == 59` satisfies chrono's precondition. + secs = secs - secs.rem_euclid(60) + 59; + u.int_in_range(1_000_000_000u32..=1_999_999_999)? + } else { + u.int_in_range(0u32..=999_999_999)? + }; let Some(dt) = DateTime::from_timestamp(secs, nanos) else { return Ok(()); }; @@ -78,14 +112,20 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { if let Ok(ts) = CheckedTimestamp::from_timestamplike(dt.naive_utc()) { let expr = MirScalarExpr::literal_ok(Datum::Timestamp(ts), ReprScalarType::Timestamp) .call_unary(UnaryFunc::TimezoneTimestamp(func::TimezoneTimestamp(tz))); - let _ = expr.eval(&[], &arena); + if let Ok(d) = expr.eval(&[], &arena) { + let _ = Row::pack_slice(&[d]).unpack_first(); + } } // TIMESTAMPTZ `AT TIME ZONE tz` -> TIMESTAMP. if let Ok(tstz) = CheckedTimestamp::from_timestamplike(dt) { let expr = MirScalarExpr::literal_ok(Datum::TimestampTz(tstz), ReprScalarType::TimestampTz) - .call_unary(UnaryFunc::TimezoneTimestampTz(func::TimezoneTimestampTz(tz))); - let _ = expr.eval(&[], &arena); + .call_unary(UnaryFunc::TimezoneTimestampTz(func::TimezoneTimestampTz( + tz, + ))); + if let Ok(d) = expr.eval(&[], &arena) { + let _ = Row::pack_slice(&[d]).unpack_first(); + } } Ok(()) } diff --git a/src/expr/fuzz/like_pattern_compile.dict b/src/expr/fuzz/like_pattern_compile.dict new file mode 100644 index 0000000000000..68ef0f068b31d --- /dev/null +++ b/src/expr/fuzz/like_pattern_compile.dict @@ -0,0 +1,20 @@ +# libFuzzer dictionary for the like_pattern_compile target, resolved by +# `dict_for` in test/cargo-fuzz/mzcompose.py. +# +# These tokens pay off only on the target's fully-arbitrary arm, where the input +# bytes become the pattern and the text directly. Random Unicode there almost +# never contains a LIKE metacharacter, so that arm stays on trivial literal +# patterns without help. On the structured arm the input is a recipe of generator +# choices, and a token is just more choice bytes carrying no structural signal. + +# The metacharacters, and an escape sequence. +"%" +"_" +"\\" +"\\%" +# Two `%` with non-empty suffixes: the shape that routes to the regex engine. +"%a%b" +# Multi-byte literals, so byte offsets and character counts diverge in the +# `%` backtracking: é (U+00E9, 2 bytes) and 漢 (U+6F22, 3 bytes). +"\xc3\xa9" +"\xe6\xbc\xa2" diff --git a/src/expr/src/scalar.rs b/src/expr/src/scalar.rs index a7f37a0d3629e..3c8ac5425592e 100644 --- a/src/expr/src/scalar.rs +++ b/src/expr/src/scalar.rs @@ -1385,6 +1385,44 @@ impl VisitChildren for MirScalarExpr { } impl MirScalarExpr { + /// Reports whether this expression contains a non-strict variadic call with an + /// operand that could error, the shape of the open bug CLU-137. + /// + /// `And`, `Or` and `ErrorIfNull` do not evaluate every operand: `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 `ErrorIfNull` + /// evaluates its message operand only when the first operand is NULL. Yet + /// `reduce_call_variadic`'s generic error propagation replaces the whole call + /// with any operand's literal error, wherever it sits. So `reduce` can turn a + /// row the expression should evaluate into an error, and, because that literal + /// is typed non-nullable, it can go on to license a nullability-dependent + /// rewrite and yield a different *value* rather than an error. + /// + /// Fuzz oracles that compare evaluation across `reduce` use this to skip the + /// shape rather than rediscover CLU-137 on every run. It lives here, next to + /// the fold it describes, so the several oracles that need it cannot drift + /// apart on which functions count as non-strict. + /// + /// Deliberately conservative: it asks whether an operand *could* error rather + /// than whether it already holds a literal error, because `reduce` folds a + /// column-free fallible operand (`1 / 0`) to a literal error first and absorbs + /// it after. + pub fn could_hit_nonstrict_error_fold(&self) -> bool { + let mut hit = false; + self.visit_pre(|e| { + if let MirScalarExpr::CallVariadic { func, exprs } = e { + let non_strict = matches!( + func, + VariadicFunc::And(_) | VariadicFunc::Or(_) | VariadicFunc::ErrorIfNull(_) + ); + if non_strict && exprs.iter().any(|operand| operand.could_error()) { + hit = true; + } + } + }); + hit + } + /// Iterates through references to child expressions. pub fn children(&self) -> impl DoubleEndedIterator { let mut first = None; diff --git a/src/interchange/fuzz/fuzz_targets/avro_decode_fuzzed_schema.rs b/src/interchange/fuzz/fuzz_targets/avro_decode_fuzzed_schema.rs index 73aa7da8de494..12f646698c7dd 100644 --- a/src/interchange/fuzz/fuzz_targets/avro_decode_fuzzed_schema.rs +++ b/src/interchange/fuzz/fuzz_targets/avro_decode_fuzzed_schema.rs @@ -25,9 +25,20 @@ //! and *Avro-binary-encode a random value against that same type*, so the body //! is valid by construction and the decoder walks all the way through. Coverage //! guidance then learns which byte streams produce which shapes. We don't lose -//! the error-path coverage a random body gave, though: a quarter of the inputs -//! feed the raw remaining bytes, and others truncate or single-byte-corrupt the -//! valid encoding. Either way, an accepted schema must never panic. +//! the error-path coverage a random body gave, though: up to a quarter of the +//! inputs feed the raw remaining bytes, and others truncate or +//! single-byte-corrupt the valid encoding. Either way, an accepted schema must +//! never panic. +//! +//! NOTE: every "don't build a valid body" branch must be gated on there being +//! input left. `Unstructured::int_in_range` does not fail on an exhausted +//! `Unstructured`, it returns the *low end* of the range forever, and schema +//! generation consumes input greedily, so an ungated `int_in_range(0..=3)? == 0` +//! branch is taken by every exhausted input. That funnelled the majority of +//! executions into a zero-length body (`take_rest` on nothing, or +//! truncate-to-zero), which fails at the first `read_exact` and walks none of +//! the deep decode logic this target exists to reach. Exhaustion must fall +//! through to the clean-encoding path, never select a degenerate one. //! //! Beyond "never panic", we add one error oracle. Most bodies *should* be //! rejected (random bytes, truncations, an out-of-range `enum` index, an @@ -37,8 +48,21 @@ //! (plain scalars, `fixed`, and structural composites over them, see //! `decode_infallible`), the decoder is round-tripping bytes it *must* accept, //! so there a decode error is a real bug and we assert success. A panic-only -//! oracle never notices a "valid input wrongly rejected" regression (cf. -//! #37087's deferred union-promotion error). +//! oracle never notices a "valid input wrongly rejected" regression. +//! +//! NOTE: the scope of that oracle is the *identity-resolution* decode path only. +//! We build the decoder with `WriterSchemaProvider::None`, for which +//! `AvroSchemaResolver::resolve` hands back the reader schema verbatim, so no +//! `SchemaPiece::Resolve*` piece is ever constructed. The deferred +//! union-promotion class of regression (#37087), where a failed writer/reader +//! variant pairing is stored as an `Err` inside the resolved union and re-raised +//! at decode time, lives entirely in that resolution machinery and cannot be +//! reached from here. It is covered by +//! `src/avro/fuzz/fuzz_targets/schema_resolve.rs`, which fuzzes `resolve_schemas` +//! against `from_avro_datum`. Reaching it through the interchange-side +//! Row-packing decoder would need a separate target that builds the resolved +//! schema with `resolve_schemas` and hands it to `AvroFlatDecoder` directly, +//! since `Decoder` offers no way to inject a writer schema without a registry. //! //! A fraction of inputs instead drive a round-trip *correctness* oracle //! (`run_roundtrip`): a decode that succeeds but yields the *wrong* datum slips @@ -49,10 +73,12 @@ //! //! Rather than freeze the schema-dependent knobs at one value each, we vary the //! ones that drive distinct decode arithmetic: `decimal` precision (1..=39, the -//! `NUMERIC_DATUM_MAX_PRECISION` boundary), scale (0..=precision, where -//! `parse_decimal` rejects scale > precision and `twos_complement_be_to_numeric` -//! interprets it), and the backing `fixed` size, so the two's-complement byte -//! run and the precision/scale interaction are not pinned. The decimal +//! `NUMERIC_DATUM_MAX_PRECISION` boundary, further capped by the backing `fixed` +//! size so `parse_fixed` does not silently demote the decimal to a plain +//! `fixed`), scale (0..=precision, where `parse_decimal` rejects scale > +//! precision and `twos_complement_be_to_numeric` interprets it), and the backing +//! `fixed` size, so the two's-complement byte run and the precision/scale +//! interaction are not pinned. The decimal //! *value* bytes are likewise biased (see `push_twos_complement` / //! `gen_decimal_len`) toward the patterns that stress that arithmetic rather //! than only uniform-random runs: the empty run (== 0), all-`0x00`/`0xFF` sign @@ -61,10 +87,23 @@ //! `json` logical field (a `string` tagged `connect.name:io.debezium.data.Json`) //! whose body is real JSON text, reaching the `AvroFlatDecoder::json` -> //! `JsonbPacker` path that a plain string never touches. And multi-variant -//! *essential* unions like `["int","string"]`, accepted only as a record field -//! (each non-null variant expands to its own nullable column) and rejected -//! elsewhere, exercising `get_union_columns`' field-invention/expansion logic -//! that the `["null", T]` nullability pattern alone never reaches. +//! *essential* unions like `["int","string"]`, which occupy one column per +//! non-null variant, exercising the multi-column `AvroFlatDecoder::union_branch` +//! arithmetic (one `Datum` pushed per non-null variant, `Datum::Null` for every +//! unselected one) that the two-branch `["null", T]` pattern never reaches. +//! +//! NOTE: this target stops at `parse_schema`. `Decoder::new` -> +//! `AvroSchemaResolver::new` -> `parse_schema` is the whole chain, so none of +//! `src/interchange/src/avro/schema.rs`'s SQL-side validation runs here: +//! `validate_schema_1` / `validate_schema_2` / `get_union_columns` are reachable +//! only from `schema_to_relationdesc`, which nothing here calls. The union +//! column-name invention (`get_union_columns`' `format!("{}{}", n, i + 1)`, the +//! `UNKNOWN_COLUMN_NAME` fallback, the resulting `RelationDesc`) is therefore +//! untested by this target. Where the generator does keep to a validator rule +//! (essential unions only as record fields, decimal precision <= 39) the reason +//! is that production runs `schema_to_relationdesc` before ever building a +//! `Decoder`, so those are the only schemas the decoder sees in practice. It is +//! not that this target would reject the others. #![no_main] @@ -125,22 +164,39 @@ enum Ty { Nullable(Box), /// A multi-variant *essential* union of non-null variants, optionally with a /// leading `null`: e.g. `["int","string"]` or `["null","int","string"]`. - /// `validate_schema_2` rejects this everywhere except as a record field, - /// where `get_union_columns` expands it to one nullable column per non-null - /// variant. Stored as (has_null, variants). Generated only as a record field. + /// At decode time `AvroFlatDecoder::union_branch` packs one `Datum` per + /// non-null variant. Stored as (has_null, variants). Generated only as a + /// record field, because that is the only position the SQL validator accepts + /// it in and therefore the only one production reaches (this target does not + /// run that validator, see the module docs). Avro itself would accept the + /// same union anywhere a type is expected. EssentialUnion(bool, Vec), } -/// Generate a `decimal`'s precision/scale. `parse_decimal` requires -/// `0 <= scale <= precision` and the SQL validator caps precision at -/// `NUMERIC_DATUM_MAX_PRECISION` (39). Pick within those bounds so the schema is -/// accepted and we vary the decode arithmetic across the whole legal range. -fn gen_decimal_params(u: &mut Unstructured) -> arbitrary::Result<(u32, u32)> { - let precision = u.int_in_range(1u32..=39)?; +/// Materialize's `NUMERIC_DATUM_MAX_PRECISION`. The SQL Avro validator caps +/// `decimal` precision here, and production only hands the decoder schemas that +/// passed it, so stay within it even though this target never runs that check. +const MAX_DECIMAL_PRECISION: u32 = 39; + +/// Generate a `decimal`'s precision/scale with precision in +/// `1..=max_precision`. `parse_decimal` requires `0 <= scale <= precision`, so +/// pick the scale from the chosen precision and vary the decode arithmetic +/// across the whole legal range. +fn gen_decimal_params(u: &mut Unstructured, max_precision: u32) -> arbitrary::Result<(u32, u32)> { + let precision = u.int_in_range(1u32..=max_precision)?; let scale = u.int_in_range(0u32..=precision)?; Ok((precision, scale)) } +/// The largest `decimal` precision `parse_fixed` will honour over a `size`-byte +/// `fixed`, mirroring its own bound. Past it the logical type is silently +/// demoted to a plain `fixed` (a `warn!`, not a parse error), so the schema +/// stops exercising the decimal path and the harness is left believing a +/// `DecimalFixed` node is a decimal when the decoder sees a `fixed`. +fn max_precision_over_fixed(size: u32) -> u32 { + ((f64::from(8 * size - 1)) * 2f64.log10()).floor() as u32 +} + /// Generate one syntactically valid Avro type. `counter` keeps named types /// (record/enum/fixed) unique within the schema, since duplicate names make Avro /// schema parsing fail, wasting the whole input. This never returns an @@ -164,15 +220,19 @@ fn gen_ty(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Res _ => Ty::Bytes, }, 1 => { - let (p, s) = gen_decimal_params(u)?; + let (p, s) = gen_decimal_params(u, MAX_DECIMAL_PRECISION)?; Ty::DecimalBytes(p, s) } 2 => { *counter += 1; - let (p, s) = gen_decimal_params(u)?; // `fixed` requires a positive size. Allow runs both shorter and // longer than the canonical 16/24 to vary the two's-complement path. let size = u.int_in_range(1u32..=40)?; + // Bound the precision by the byte run, else `parse_fixed` demotes + // the decimal to a plain `fixed` and this node stops testing the + // decimal path at all (`Ty::Fixed` already covers plain runs). + let max = max_precision_over_fixed(size).min(MAX_DECIMAL_PRECISION); + let (p, s) = gen_decimal_params(u, max)?; Ty::DecimalFixed(*counter, size, p, s) } 3 => Ty::Date, @@ -181,7 +241,9 @@ fn gen_ty(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Res 6 => Ty::Uuid, 7 => { *counter += 1; - let size = u.int_in_range(0u32..=24)?; + // `parse_fixed` rejects a non-positive size outright, which would + // fail `Decoder::new` and discard the whole input, schema and body. + let size = u.int_in_range(1u32..=24)?; Ty::Fixed(*counter, size) } 8 => { @@ -201,7 +263,18 @@ fn gen_ty(u: &mut Unstructured, counter: &mut u32, depth: u32) -> arbitrary::Res } 11 => Ty::Array(Box::new(gen_ty(u, counter, depth - 1)?)), 12 => Ty::Map(Box::new(gen_ty(u, counter, depth - 1)?)), - _ => Ty::Nullable(Box::new(gen_ty(u, counter, depth - 1)?)), + _ => { + // `UnionSchema::new` rejects `["null",["null",T]]` ("Unions may not + // directly contain a union"), which would fail `Decoder::new` and + // discard the whole input. Collapse rather than re-roll, so the + // input the inner type already consumed is not wasted either. + let inner = gen_ty(u, counter, depth - 1)?; + if matches!(inner, Ty::Nullable(_)) { + inner + } else { + Ty::Nullable(Box::new(inner)) + } + } }) } @@ -377,7 +450,11 @@ fn encode_json(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> } 5 => "[]".into(), 6 => "{}".into(), - 7 => format!("[{},{},null]", u.arbitrary::()?, u.arbitrary::()?), + 7 => format!( + "[{},{},null]", + u.arbitrary::()?, + u.arbitrary::()? + ), // Not valid JSON: exercise the decoder's BadJson error path. _ => "{".into(), }; @@ -392,7 +469,11 @@ fn encode_json(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> /// (`len <= 17`), the `negate_twos_complement_le` path (any negative value), and /// the wide-representation precision-overflow handling. Most of the time it /// still emits a uniform-random run so coverage guidance keeps exploring. -fn push_twos_complement(u: &mut Unstructured, len: usize, out: &mut Vec) -> arbitrary::Result<()> { +fn push_twos_complement( + u: &mut Unstructured, + len: usize, + out: &mut Vec, +) -> arbitrary::Result<()> { let fill = match u.int_in_range(0u8..=9)? { 0 => 0x00u8, // zero / positive sign-extension 1 => 0xFF, // -1 / negative sign-extension @@ -459,7 +540,11 @@ fn encode_value(u: &mut Unstructured, ty: &Ty, out: &mut Vec) -> arbitrary:: // The fixed-backed decimal reads exactly `size` bytes (no length prefix), // so only the byte pattern varies. Ty::DecimalFixed(_, size, _, _) => { - push_twos_complement(u, usize::try_from(*size).expect("fixed size fits usize"), out)?; + push_twos_complement( + u, + usize::try_from(*size).expect("fixed size fits usize"), + out, + )?; } Ty::Json => encode_json(u, out)?, Ty::Uuid => { @@ -703,10 +788,11 @@ fn run_roundtrip(u: &mut Unstructured) -> arbitrary::Result<()> { } schema.push_str("]}"); - let Ok(mut decoder) = Decoder::new(&schema, &[], WriterSchemaProvider::None, "fuzz".into()) else { - // A record of plain scalars always validates. If not, nothing to check. - return Ok(()); - }; + // `Decoder::new` only parses the schema, and this one is built from a fixed + // template over plain scalars, so a parse failure is a harness bug rather + // than an input we should skip. + let mut decoder = Decoder::new(&schema, &[], WriterSchemaProvider::None, "fuzz".into()) + .expect("plain-scalar record schema parses"); let mut body = Vec::new(); for (val, nullable) in &cols { @@ -728,7 +814,9 @@ fn run_roundtrip(u: &mut Unstructured) -> arbitrary::Result<()> { "Avro decode produced the wrong row for a plain-scalar record\nschema = {schema}", ), Ok(Err(e)) => panic!("plain-scalar record failed to decode (schema = {schema}): {e}"), - Err(e) => panic!("plain-scalar record hit a transient decode error (schema = {schema}): {e}"), + Err(e) => { + panic!("plain-scalar record hit a transient decode error (schema = {schema}): {e}") + } } Ok(()) } @@ -741,8 +829,8 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { return run_roundtrip(&mut u); } - // Top-level reader schema: a record whose fields span everything - // `validate_schema_2` accepts. + // Top-level reader schema: a record whose fields span every single-column + // shape the decoder handles. let mut counter = 0u32; let nfields = u.int_in_range(1u8..=8)?; let mut fields = Vec::with_capacity(nfields.into()); @@ -757,37 +845,51 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { // No CSR client and confluent_wire_format = false, so decode is // self-contained (no network) over the generated reader schema. - let Ok(mut decoder) = Decoder::new(&schema, &[], WriterSchemaProvider::None, "fuzz".into()) else { + let Ok(mut decoder) = Decoder::new(&schema, &[], WriterSchemaProvider::None, "fuzz".into()) + else { return Ok(()); }; - // Body: usually a valid encoding (so the decoder runs deep), but a quarter - // of the time the raw remaining bytes, and otherwise a valid encoding - // occasionally truncated or single-byte corrupted. The non-valid forms keep - // the decoder's error paths covered: short read, bad length/union tag, - // inconsistent content. + // Body: usually a valid encoding (so the decoder runs deep), but up to a + // quarter of the time the raw remaining bytes, and otherwise a valid + // encoding occasionally truncated or single-byte corrupted. The non-valid + // forms keep the decoder's error paths covered: short read, bad length/union + // tag, inconsistent content. + // Both "don't build a valid body" decisions are gated on input remaining, + // because an exhausted `Unstructured` returns the low end of every range and + // would otherwise steer the majority of executions into an empty body (see + // the module docs). // `assert_success` is set only when the body is a *clean* (uncorrupted) // encoding of a type tree every node of which is guaranteed to decode for // any value the encoder can produce (see `decode_infallible`). In that one // case the decoder is round-tripping bytes it must accept, so a decode // *error* is a real bug. The panic-only oracle that the random-bytes, // truncated, corrupted, and value-validated (`enum`/`decimal`/`json`/…) - // arms rely on would silently miss it, the same class of regression as the - // deferred union-promotion error in #37087. + // arms rely on would silently miss it. This covers the identity-resolution + // decode path only, see the module docs for the resolved-schema gap. let mut assert_success = false; - let body = if u.int_in_range(0u8..=3)? == 0 { + // Only take the raw tail when there actually is one to feed. + let body = if u.len() >= 8 && u.int_in_range(0u8..=3)? == 0 { u.take_rest().to_vec() } else { let mut b = Vec::new(); encode_value(&mut u, &row, &mut b)?; - match u.int_in_range(0u8..=9)? { - 0 if !b.is_empty() => { - let keep = u.int_in_range(0usize..=b.len())?; + let corruption = if u.is_empty() { + None + } else { + Some(u.int_in_range(0u8..=9)?) + }; + match corruption { + // `0..=len - 1`, not `0..=len`: a `keep == len` draw would leave the + // body untouched, so the arm would not always truncate. + Some(0) if !b.is_empty() => { + let keep = u.int_in_range(0usize..=b.len() - 1)?; b.truncate(keep); } - 1 if !b.is_empty() => { + // A nonzero mask, so the arm always flips at least one bit. + Some(1) if !b.is_empty() => { let i = u.int_in_range(0usize..=b.len() - 1)?; - b[i] ^= u.arbitrary::()?; + b[i] ^= u.int_in_range(1u8..=u8::MAX)?; } // Uncorrupted body: if its type is guaranteed decodable, the decode // below must succeed. @@ -809,6 +911,15 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { "decoder rejected a clean, in-range Avro body whose every field is guaranteed \ decodable; such a round-trip must succeed", ); + // `decode` reads through `bytes`, so a clean body must be consumed in + // full. A length-accounting bug that under-consumes while still packing + // a well-formed `Row` passes every other oracle here. + assert!( + bytes.is_empty(), + "decoder left {} of {} bytes of a clean Avro body unconsumed (schema = {schema})", + bytes.len(), + body.len(), + ); } Ok(()) } diff --git a/src/interchange/fuzz/fuzz_targets/json_encode.rs b/src/interchange/fuzz/fuzz_targets/json_encode.rs index f660328db5424..6bbdf039c839d 100644 --- a/src/interchange/fuzz/fuzz_targets/json_encode.rs +++ b/src/interchange/fuzz/fuzz_targets/json_encode.rs @@ -16,6 +16,13 @@ //! that can't be serialized, corrupts/halts a sink, so encoding then serializing //! must never panic for any well-typed row. //! +//! The blast radius is wider than the sinks. `TypedDatum::json` is also the +//! result encoder for the HTTP `/api/sql` and WebSocket SQL APIs, which carry no +//! `catch_unwind`, so a panic there aborts `environmentd` under +//! `install_enhanced_handler` rather than just failing one sink task. Those +//! callers pass `JsonNumberPolicy::ConvertNumberToString`, while +//! `encode_datums_as_json` hardcodes `KeepAsNumber`, so we drive both. +//! //! Beyond scalars, we generate *composite* column types (`List`, `Map`, //! `Record`, multi-dimensional `Array`, and `Jsonb`) plus the remaining scalar //! shapes with their own encode logic (`Uuid`, `Char`/`VarChar` padding, @@ -33,15 +40,16 @@ use chrono::{DateTime, NaiveTime, Utc}; use libfuzzer_sys::arbitrary::{self, Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_interchange::json::encode_datums_as_json; +use mz_interchange::encode::TypedDatum; +use mz_interchange::json::{JsonNumberPolicy, ToJson, encode_datums_as_json}; use mz_repr::adt::char::CharLength; use mz_repr::adt::date::Date; use mz_repr::adt::interval::Interval; use mz_repr::adt::jsonb::JsonbPacker; -use mz_repr::adt::mz_acl_item::{AclItem, MzAclItem}; -use mz_repr::adt::range::Range; +use mz_repr::adt::mz_acl_item::{AclItem, AclMode, MzAclItem}; +use mz_repr::adt::range::{Range, RangeLowerBound, RangeUpperBound}; use mz_repr::adt::system::Oid; -use mz_repr::adt::timestamp::CheckedTimestamp; +use mz_repr::adt::timestamp::{CheckedTimestamp, HIGH_DATE, LOW_DATE}; use mz_repr::adt::varchar::VarCharMaxLength; use mz_repr::role_id::RoleId; use mz_repr::strconv::parse_numeric; @@ -67,29 +75,40 @@ enum GType { Array(Vec, SqlScalarType), } +/// Draw a `DateTime` inside `CheckedTimestamp`'s bounds. +/// +/// Drawing from the full `i64` (or even `±8e12`) second range spends most of the +/// budget on values `from_timestamplike` rejects, and never lands on the +/// boundary days. Drawing from `[LOW_DATE 00:00:00, HIGH_DATE 23:59:59]` instead +/// makes every draw valid, so the two conversions below cannot fail: `secs` is +/// well inside chrono's own year range, and `nanos` cannot carry past the end of +/// `HIGH_DATE`. +fn gen_ts(u: &mut Unstructured) -> arbitrary::Result> { + let low = LOW_DATE + .and_hms_opt(0, 0, 0) + .expect("midnight is a valid time") + .and_utc() + .timestamp(); + let high = HIGH_DATE + .and_hms_opt(23, 59, 59) + .expect("23:59:59 is a valid time") + .and_utc() + .timestamp(); + let secs = u.int_in_range(low..=high)?; + let nanos = u.int_in_range(0u32..=999_999_999)?; + Ok(DateTime::from_timestamp(secs, nanos).expect("secs drawn within chrono's range")) +} + fn gen_naive_ts( u: &mut Unstructured, ) -> arbitrary::Result> { - let secs = u.int_in_range(-8_000_000_000_000i64..=8_000_000_000_000)?; - let nanos = u.int_in_range(0u32..=999_999_999)?; - Ok(DateTime::from_timestamp(secs, nanos) - .and_then(|d| CheckedTimestamp::from_timestamplike(d.naive_utc()).ok()) - .unwrap_or_else(|| { - CheckedTimestamp::from_timestamplike( - DateTime::from_timestamp(0, 0).unwrap().naive_utc(), - ) - .unwrap() - })) + Ok(CheckedTimestamp::from_timestamplike(gen_ts(u)?.naive_utc()) + .expect("drawn within CheckedTimestamp's bounds")) } fn gen_utc_ts(u: &mut Unstructured) -> arbitrary::Result>> { - let secs = u.int_in_range(-8_000_000_000_000i64..=8_000_000_000_000)?; - let nanos = u.int_in_range(0u32..=999_999_999)?; - Ok(DateTime::from_timestamp(secs, nanos) - .and_then(|d| CheckedTimestamp::from_timestamplike(d).ok()) - .unwrap_or_else(|| { - CheckedTimestamp::from_timestamplike(DateTime::from_timestamp(0, 0).unwrap()).unwrap() - })) + Ok(CheckedTimestamp::from_timestamplike(gen_ts(u)?) + .expect("drawn within CheckedTimestamp's bounds")) } /// Generate a scalar type (a leaf of the composite tree, and the element type of @@ -117,11 +136,27 @@ fn gen_scalar_type(u: &mut Unstructured) -> arbitrary::Result { 18 => SqlScalarType::MzTimestamp, 19 => SqlScalarType::Uuid, 20 => { - let length = CharLength::try_from(i64::from(u.int_in_range(1u8..=12)?)).ok(); + // An unqualified `bpchar` (`length: None`) reaches `format_str_pad`'s + // no-padding branch, which a length-qualified `Char` never does. + let length = if u.ratio(1u8, 4u8)? { + None + } else { + Some( + CharLength::try_from(i64::from(u.int_in_range(1u8..=12)?)) + .expect("1..=12 is a valid char length"), + ) + }; SqlScalarType::Char { length } } 21 => { - let max_length = VarCharMaxLength::try_from(i64::from(u.int_in_range(1u8..=12)?)).ok(); + let max_length = if u.ratio(1u8, 4u8)? { + None + } else { + Some( + VarCharMaxLength::try_from(i64::from(u.int_in_range(1u8..=12)?)) + .expect("1..=12 is a valid varchar max length"), + ) + }; SqlScalarType::VarChar { max_length } } // Range over Int32. `unwrap_range().to_string()` is the only logic. @@ -160,7 +195,15 @@ fn gen_type(u: &mut Unstructured, depth: u32) -> arbitrary::Result { let ndims = u.int_in_range(1usize..=3)?; let mut dims = Vec::with_capacity(ndims); for _ in 0..ndims { - dims.push(u.int_in_range(0usize..=3)?); + // A single zero-length axis collapses the cardinality to 0, so + // `encode_array` walks no elements at all. Drawing lengths + // uniformly from `0..=3` made that the outcome for ~45% of + // arrays, so keep it reachable but rare. + dims.push(if u.ratio(1u8, 16u8)? { + 0 + } else { + u.int_in_range(1usize..=3)? + }); } GType::Array(dims, gen_scalar_type(u)?) } @@ -201,6 +244,26 @@ fn to_scalar_type(ty: >ype) -> SqlScalarType { } /// Build a small, valid JSON string of bounded depth for the `Jsonb` path. +/// +/// TODO: Generate serde_json's arbitrary-precision magic key here once SS-157 is +/// fixed. `JsonbPacker`'s `KeyClassifier` recognises the object key +/// `$serde_json::private::Number` and runs the payload through +/// `strconv::parse_numeric`, which accepts positive quiet NaN, bypassing the +/// `cast_jsonbable_to_jsonb` guard that the SQL cast path relies on. That is the +/// only route by which a non-finite numeric enters a `Jsonb`, and +/// `JsonbRef::to_serde_json` then panics on it, so the literal +/// `{"$serde_json::private::Number":"NaN"}` (and that value nested inside an +/// array or object, to cover `JsonbDatum::serialize`'s list/map recursion) is +/// what this generator has to produce to guard the fix. It is left out until the +/// fix lands, because this target is in the `FRUITFUL` set and would otherwise +/// report the same known crash on every release-qualification run. +/// +/// Adding it also requires relaxing the `pack_str` call in `push_typed`: `"NaN"` +/// is the only payload `parse_numeric` accepts, so `Infinity`, `sNaN`, `1e5000` +/// and the like make `pack_str` return `Err`, which today's `.expect()` would +/// turn into a *harness* panic. `pack_str` collects its commands before touching +/// the packer, so a rejection leaves the packer untouched and a valid +/// `Datum::JsonNull` can be substituted instead. fn gen_json_text(u: &mut Unstructured) -> arbitrary::Result { Ok(match u.int_in_range(0u8..=7)? { 0 => "null".into(), @@ -244,9 +307,9 @@ fn push_scalar( // represent, exactly the encoding edge we want to exercise. SqlScalarType::Float32 => packer.push(Datum::Float32(f32::arbitrary(u)?.into())), SqlScalarType::Float64 => packer.push(Datum::Float64(f64::arbitrary(u)?.into())), - SqlScalarType::String - | SqlScalarType::VarChar { .. } - | SqlScalarType::Char { .. } => packer.push(Datum::String(<&str>::arbitrary(u)?)), + SqlScalarType::String | SqlScalarType::VarChar { .. } | SqlScalarType::Char { .. } => { + packer.push(Datum::String(<&str>::arbitrary(u)?)) + } SqlScalarType::Bytes => packer.push(Datum::Bytes(<&[u8]>::arbitrary(u)?)), SqlScalarType::Numeric { .. } => { let s = format!("{}.{}", i64::arbitrary(u)?, u32::arbitrary(u)?); @@ -254,8 +317,10 @@ fn push_scalar( packer.push(Datum::Numeric(n)); } SqlScalarType::Date => { - let d = Date::from_pg_epoch(i32::arbitrary(u)?) - .unwrap_or_else(|_| Date::from_pg_epoch(0).unwrap()); + // Drawing from the full `i32` day range was rejected ~68% of the + // time and never reached the boundary days. + let days = u.int_in_range(Date::LOW_DAYS..=Date::HIGH_DAYS)?; + let d = Date::from_pg_epoch(days).expect("days drawn within Date's bounds"); packer.push(Datum::Date(d)); } SqlScalarType::Time => { @@ -275,14 +340,57 @@ fn push_scalar( packer.push(Datum::MzTimestamp(Timestamp::from(u64::arbitrary(u)?))) } SqlScalarType::Uuid => packer.push(Datum::Uuid(Uuid::from_u128(u.arbitrary::()?))), - // The empty range is always valid and its `to_string` is exercised. - SqlScalarType::Range { .. } => packer.push(Datum::Range(Range { inner: None })), + // `Display for Range` short-circuits the empty range to the constant + // "empty", so only a finite range reaches `RangeInner`/`RangeBound`'s + // Display, where the actual rendering logic lives. + SqlScalarType::Range { .. } => { + if u.ratio(1u8, 8u8)? { + packer.push(Datum::Range(Range { inner: None })); + } else { + // `RangeBound::new` maps `Datum::Null` to an infinite bound. + let (a, b) = (i32::arbitrary(u)?, i32::arbitrary(u)?); + let lower = RangeLowerBound::new( + if u.ratio(1u8, 8u8)? { + Datum::Null + } else { + Datum::Int32(a.min(b)) + }, + bool::arbitrary(u)?, + ); + let upper = RangeUpperBound::new( + if u.ratio(1u8, 8u8)? { + Datum::Null + } else { + Datum::Int32(a.max(b)) + }, + bool::arbitrary(u)?, + ); + // `push_range` canonicalizes before it touches the packer, so a + // rejected range (an exclusive bound whose step oversteps + // `i32`) leaves the packer untouched and we can substitute the + // empty range. + if packer.push_range(Range::new(Some((lower, upper)))).is_err() { + packer.push(Datum::Range(Range { inner: None })); + } + } + } + // `AclMode::empty()` renders no privilege bits at all, so fuzz the mode. + // `from_bits_truncate` drops undefined bits, which is what the row + // encoding round-trips. SqlScalarType::MzAclItem => { - let item = MzAclItem::empty(RoleId::User(u.arbitrary::()?), RoleId::Public); + let item = MzAclItem { + grantee: RoleId::User(u.arbitrary::()?), + grantor: RoleId::Public, + acl_mode: AclMode::from_bits_truncate(u64::arbitrary(u)?), + }; packer.push(Datum::MzAclItem(item)); } SqlScalarType::AclItem => { - let item = AclItem::empty(Oid(u.arbitrary::()?), Oid(u.arbitrary::()?)); + let item = AclItem { + grantee: Oid(u.arbitrary::()?), + grantor: Oid(u.arbitrary::()?), + acl_mode: AclMode::from_bits_truncate(u64::arbitrary(u)?), + }; packer.push(Datum::AclItem(item)); } // `gen_scalar_type` never produces other types. @@ -306,7 +414,10 @@ fn push_typed( packer.push(Datum::Null); } else { let text = gen_json_text(u)?; - // The text is always valid JSON by construction. + // The text is always valid JSON by construction, and every + // number it contains is one `strconv::parse_numeric` accepts. + // See `gen_json_text`'s TODO before generating a payload that + // `pack_str` can reject. JsonbPacker::new(packer) .pack_str(&text) .expect("generated valid json"); @@ -381,7 +492,9 @@ fn push_typed( // The dims and element count match by construction, so this can't // return an error. If it somehow does, fall back to an empty array. if packer.try_push_array(&array_dims, elems).is_err() { - packer.try_push_array(&[], std::iter::empty::()).unwrap(); + packer + .try_push_array(&[], std::iter::empty::()) + .unwrap(); } } } @@ -420,7 +533,20 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { // Encode the row's datums as JSON (the sink path) and serialize the result. // Neither step may panic for any well-typed row. let value = encode_datums_as_json(row.iter(), &columns); - let _ = serde_json::to_vec(&value); + // Serialize the way the sink does: `JsonEncoder::encode_unchecked` calls + // `Value::to_string`, and `ToString` panics when the `Display` impl errors, + // which `Display for Value` does for any serialization failure. So a + // serialization error is a *panic* in production, where `to_vec` would only + // have returned an `Err` that an oracle could not see. + let _ = value.to_string(); + + // `encode_datums_as_json` hardcodes `KeepAsNumber`. The HTTP `/api/sql` and + // WebSocket SQL result encoders pass `ConvertNumberToString`, which recurses + // through every composite arm, so drive that policy too. + for (datum, (_, typ)) in row.iter().zip(&columns) { + let value = TypedDatum::new(datum, typ).json(&JsonNumberPolicy::ConvertNumberToString); + let _ = value.to_string(); + } Ok(()) } diff --git a/src/interchange/fuzz/fuzz_targets/protobuf_decode_fuzzed_schema.rs b/src/interchange/fuzz/fuzz_targets/protobuf_decode_fuzzed_schema.rs index c2cf3c6a0e91b..61668ebaee589 100644 --- a/src/interchange/fuzz/fuzz_targets/protobuf_decode_fuzzed_schema.rs +++ b/src/interchange/fuzz/fuzz_targets/protobuf_decode_fuzzed_schema.rs @@ -23,13 +23,15 @@ //! repeated -> list, nested message -> record). Instead we keep the schema //! structured (`MsgDef`/`FieldTy`), build the `FileDescriptorSet` from it, and //! protobuf-binary-encode a valid body for the top message against that same -//! structure, bounding recursion through cyclic message refs by emitting empty -//! nested messages at the depth limit. Both wire formats are exercised: the -//! Confluent variant gets the 5-byte schema-registry header prepended so its -//! body decodes just as deeply. We keep the error-path coverage a random body -//! gave, though: a quarter of the inputs feed the raw remaining bytes, and -//! others truncate or single-byte-corrupt the valid encoding. Neither -//! validation nor decoding may panic. +//! structure, bounding recursion through cyclic message refs by omitting +//! message fields entirely once the depth limit is reached. The wire format is +//! fuzzer-chosen per input: the Confluent variant gets the six-byte +//! schema-registry header prepended (magic byte, schema id, message index) so +//! its body decodes just as deeply, and one input in eight instead lets the +//! fuzzer pick those bytes so the header's rejection arms stay covered. We keep +//! the error-path coverage a random body gave, though: a quarter of the inputs +//! feed the raw remaining bytes, and others truncate or single-byte-corrupt the +//! valid encoding. Neither validation nor decoding may panic. //! //! Beyond the well-formed-schema happy path, we drive the descriptor-validation //! surface harder. Some schemas grow a `map` field (a repeated @@ -157,7 +159,8 @@ fn gen_schema(u: &mut Unstructured) -> arbitrary::Result<(Vec, Vec)> Ok((msgs, enum_nvals)) } -fn field_proto(f: &FieldDef) -> FieldDescriptorProto { +/// Build the descriptor entry for the `fi`th field of a message. +fn field_proto(fi: usize, f: &FieldDef) -> FieldDescriptorProto { let (ty, type_name) = match &f.ty { FieldTy::Int32 => (Type::Int32, None), FieldTy::Int64 => (Type::Int64, None), @@ -186,7 +189,14 @@ fn field_proto(f: &FieldDef) -> FieldDescriptorProto { Label::Optional }; FieldDescriptorProto { - name: Some(format!("f{}", f.number - 1)), + // NOTE: the name must come from the position, not from `f.number`. A + // name derived from the number makes every duplicate number a duplicate + // *name* too, and `DescriptorPool::decode` rejects the name clash first + // ("camel-case name of field 'f0' conflicts with field 'f0'"). The + // duplicate-number descriptor this target means to generate would then + // never exist, and the reject rate would swallow a large share of all + // inputs before any Materialize code runs. + name: Some(format!("f{fi}")), number: Some(f.number), label: Some(label as i32), r#type: Some(ty as i32), @@ -219,7 +229,12 @@ fn build_fds(msgs: &[MsgDef], enum_nvals: &[u8]) -> Vec { .enumerate() .map(|(mi, m)| DescriptorProto { name: Some(format!("M{mi}")), - field: m.fields.iter().map(field_proto).collect(), + field: m + .fields + .iter() + .enumerate() + .map(|(fi, f)| field_proto(fi, f)) + .collect(), ..Default::default() }) .collect(); @@ -286,6 +301,24 @@ fn encode_len_delimited(number: i32, bytes: &[u8], out: &mut Vec) { out.extend_from_slice(bytes); } +/// Pick a value for a field of enum `E{j}`, whose declared values are +/// `0..nvals`. +/// +/// One draw in eight is `nvals` itself, which is out of range. prost decodes it +/// fine (proto3 enums are open, the number survives as `Value::EnumNumber`), +/// but `pack_value` then fails the row with "unknown enum value" and +/// `pack_message` propagates that, so every field *after* the enum goes +/// unpacked. Keeping it a minority leaves most bodies packing in full while +/// still covering the rejection. +fn enum_value(enum_nvals: &[u8], j: u8, u: &mut Unstructured) -> arbitrary::Result { + let nvals = u64::from(enum_nvals[usize::from(j)]); + if u.int_in_range(0u8..=7)? == 0 { + Ok(nvals) + } else { + Ok(u.int_in_range(0..=nvals - 1)?) + } +} + /// Encode one occurrence of a field (tag + value). fn encode_field( msgs: &[MsgDef], @@ -330,10 +363,7 @@ fn encode_field( } FieldTy::Enum(j) => { encode_tag(n, 0, out); - // Valid values are 0..nvals. `nvals` itself is one out-of-range - // index (proto3 open enums accept it, the decoder maps it). - let nvals = u64::from(enum_nvals[usize::from(*j)]); - encode_varint(u.int_in_range(0u64..=nvals)?, out); + encode_varint(enum_value(enum_nvals, *j, u)?, out); } // wire type 1: 64-bit. FieldTy::Fixed64 => { @@ -379,12 +409,12 @@ fn encode_field( encode_len_delimited(n, &b, out); } FieldTy::Message(r) => { - // Bound recursion through cyclic refs: at the depth limit emit an - // empty (all-defaults) nested message. + // Recursion through cyclic message refs terminates because + // `encode_message` gives a message field zero occurrences once + // `depth` reaches 0, so this arm only ever runs with `depth > 0` + // and `depth - 1` cannot underflow. let mut nested = Vec::new(); - if depth > 0 { - encode_message(msgs, enum_nvals, usize::from(*r), depth - 1, u, &mut nested)?; - } + encode_message(msgs, enum_nvals, usize::from(*r), depth - 1, u, &mut nested)?; encode_len_delimited(n, &nested, out); } FieldTy::Map => { @@ -453,10 +483,7 @@ fn encode_scalar_value( encode_varint(((v << 1) ^ (v >> 63)) as u64, out); } FieldTy::Bool => encode_varint(u.int_in_range(0u64..=1)?, out), - FieldTy::Enum(j) => { - let nvals = u64::from(enum_nvals[usize::from(*j)]); - encode_varint(u.int_in_range(0u64..=nvals)?, out); - } + FieldTy::Enum(j) => encode_varint(enum_value(enum_nvals, *j, u)?, out), FieldTy::Fixed64 => out.extend_from_slice(&u.arbitrary::()?.to_le_bytes()), FieldTy::Sfixed64 => out.extend_from_slice(&u.arbitrary::()?.to_le_bytes()), FieldTy::Double => out.extend_from_slice(&u.arbitrary::()?.to_le_bytes()), @@ -484,7 +511,8 @@ fn encode_message( for f in &msg.fields { // Repeated fields get 0..=3 occurrences, singular fields 0..=1 // (proto3 fields are optional). Message fields at the depth limit are - // omitted entirely to break cycles. + // omitted entirely to break cycles. `encode_field` relies on that: it + // decrements `depth` unguarded for a message field. let max = if matches!(f.ty, FieldTy::Message(_)) && depth == 0 { 0 } else if f.repeated { @@ -522,6 +550,35 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { let root = usize::from(u.int_in_range(0u8..=u8::try_from(msgs.len() - 1).unwrap())?); let root_name = format!("fuzz.M{root}"); + // The wire format is chosen per input rather than both being run over the + // same body. Once `extract_protobuf_header` has stripped the prefix the two + // paths are byte-identical, so running both would just rebuild the whole + // descriptor pool (the most expensive step here) to repeat the same decode. + let confluent_wire_format = bool::arbitrary(&mut u)?; + + // The Confluent protobuf prefix is magic byte 0, a 4-byte big-endian schema + // id, then a message-index array. `extract_protobuf_header` accepts only the + // single-byte `0` form (the file's first message), so a well-formed prefix + // is SIX zero bytes, not five. At five the body's own first byte stands in + // for the message index, and for a structured body that byte is always a + // field tag `(number << 3) | wire_type` with `number >= 1`, hence always + // `>= 8`, hence always rejected. The body would never once be decoded. + // + // One input in eight lets the fuzzer pick the prefix instead, so the header's + // rejection arms (bad magic, nonzero message index, buffer too short to hold + // magic + schema id, or to hold a message index after them) stay covered + // rather than depending on the accident of a body starting with a zero. + let confluent_prefix = if u.int_in_range(0u8..=7)? == 0 { + let len = u.int_in_range(0usize..=6)?; + let mut p = Vec::with_capacity(len); + for _ in 0..len { + p.push(u.arbitrary::()?); + } + p + } else { + vec![0u8; 6] + }; + // Body for the root message: usually a valid encoding (so prost decodes // through to the Row conversion), but a quarter of the time the raw remaining // bytes, and otherwise a valid encoding occasionally truncated or single-byte @@ -546,25 +603,20 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { b }; - for confluent_wire_format in [false, true] { - let Ok(descriptors) = DecodedDescriptors::from_bytes(&fds, root_name.clone()) else { - return Ok(()); - }; - let Ok(mut decoder) = Decoder::new(descriptors, confluent_wire_format) else { - return Ok(()); - }; - // The Confluent variant strips a 5-byte schema-registry header (magic - // byte 0 + 4-byte schema id) before the body. Prepend one so its body - // decodes just as deeply as the raw variant. - let payload = if confluent_wire_format { - let mut p = vec![0u8; 5]; - p.extend_from_slice(&body); - p - } else { - body.clone() - }; - let _ = decoder.decode(&payload); - } + let Ok(descriptors) = DecodedDescriptors::from_bytes(&fds, root_name) else { + return Ok(()); + }; + let Ok(mut decoder) = Decoder::new(descriptors, confluent_wire_format) else { + return Ok(()); + }; + let payload = if confluent_wire_format { + let mut p = confluent_prefix; + p.extend_from_slice(&body); + p + } else { + body + }; + let _ = decoder.decode(&payload); Ok(()) } diff --git a/src/mysql-util/fuzz/fuzz_targets/mysql_table_desc_proto_roundtrip.rs b/src/mysql-util/fuzz/fuzz_targets/mysql_table_desc_proto_roundtrip.rs index c11cae3e28352..ee3f705201200 100644 --- a/src/mysql-util/fuzz/fuzz_targets/mysql_table_desc_proto_roundtrip.rs +++ b/src/mysql-util/fuzz/fuzz_targets/mysql_table_desc_proto_roundtrip.rs @@ -14,7 +14,7 @@ //! Input generation is split across three arms keyed off the first input //! byte so a single byte stream exercises all of them over time: //! -//! 1. **Valid-value arm.** A 32-byte seed (drawn from the input) drives +//! 1. **Valid-value arm.** A 32-byte seed (zero-padded from the input) drives //! proptest's `Arbitrary for MySqlTableDesc` to build a *structurally //! valid, deeply-populated* descriptor. Non-empty columns with real //! `SqlColumnType`s, every `MySqlColumnMeta` variant, and a populated @@ -24,11 +24,12 @@ //! decodes to near-empty messages). //! //! 2. **Duplicate/unsorted-keys arm.** Crafts a `ProtoMySqlTableDesc` -//! whose `keys` field (a repeated proto `Vec`) contains duplicates and -//! out-of-order entries to probe the classic `Vec -> BTreeSet -> Vec` -//! round-trip trap: the *first* decode normalizes (dedups + sorts), so -//! we assert the conversion is a *fixed point* afterwards rather than -//! byte-identical to the crafted input. +//! whose `keys` field (a repeated proto `Vec`) contains duplicates in +//! non-sorted order, then asserts the two properties the +//! `Vec -> BTreeSet` decode owes its callers: the decoded descriptor does +//! not depend on the wire order, and the duplicates collapse. The +//! conversion itself must succeed, because the crafted proto is valid by +//! construction. //! //! 3. **Raw-bytes arm.** Decode arbitrary bytes and, if they happen to form a //! valid descriptor, check the proto round-trip is stable. This guards @@ -36,13 +37,32 @@ #![no_main] +use std::sync::OnceLock; + use libfuzzer_sys::fuzz_target; -use mz_mysql_util::{MySqlKeyDesc, MySqlTableDesc, ProtoMySqlKeyDesc, ProtoMySqlTableDesc}; +use mz_mysql_util::{MySqlTableDesc, ProtoMySqlKeyDesc, ProtoMySqlTableDesc}; use mz_proto::{ProtoType, RustType}; -use proptest::strategy::{Strategy, ValueTree}; +use proptest::strategy::{BoxedStrategy, Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; use prost::Message; +// `Arbitrary::arbitrary()` rebuilds the entire boxed strategy graph on every +// call: `SqlScalarType`'s ~31-variant `Union` plus a second copy of it for +// `Array`, the `prop_recursive` wrapper, and a `.*` regex compile per +// `any::()` leaf. `Config::default()` re-reads the process environment. +// Both are per-process constants, so pay for them once instead of once per +// execution. libFuzzer runs a single execution at a time per process, so a +// `thread_local` suffices for the non-`Sync` strategy. +thread_local! { + static DESC_STRATEGY: BoxedStrategy = + ::arbitrary().boxed(); +} + +fn config() -> Config { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(Config::default).clone() +} + /// Assert that a `MySqlTableDesc` survives a full Rust round-trip through /// its proto representation unchanged, including a re-encode/decode of the /// wire bytes. @@ -54,13 +74,17 @@ fn assert_rust_roundtrip(orig: &MySqlTableDesc) { let round: MySqlTableDesc = proto2 .into_rust() .expect("re-encoded MySqlTableDesc must convert back to Rust"); - assert_eq!(orig, &round, "MySqlTableDesc changed across proto roundtrip"); + assert_eq!( + orig, &round, + "MySqlTableDesc changed across proto roundtrip" + ); } /// Decode `bytes` as a proto, and if it is a valid descriptor, assert the -/// proto round-trip is stable. Used by both the crafted and raw-bytes arms, -/// where the *first* decode may normalize a `Vec`-shaped field into a -/// `BTreeSet`, so we only require idempotence from that normalized value on. +/// proto round-trip is stable. Used by the raw-bytes arm, where a decode or +/// conversion failure is a legitimate outcome, and where the *first* decode may +/// normalize a `Vec`-shaped field into a `BTreeSet`, so we only require +/// idempotence from that normalized value on. fn check_decoded(bytes: &[u8]) { let Ok(proto) = ProtoMySqlTableDesc::decode(bytes) else { return; @@ -101,63 +125,72 @@ fn craft_unsorted_dup_keys(data: &[u8]) -> ProtoMySqlTableDesc { } } +/// Encode a hand-built proto, decode the wire bytes, and convert to Rust. Every +/// step must succeed: the input is structurally valid by construction, so a +/// failure is the bug the crafted arm exists to catch, for example a +/// `from_proto` hardened to reject duplicate or unsorted wire keys. +fn decode_crafted(proto: &ProtoMySqlTableDesc) -> MySqlTableDesc { + ProtoMySqlTableDesc::decode(proto.encode_to_vec().as_slice()) + .expect("hand-built proto must decode") + .into_rust() + .expect("duplicate/unsorted keys must convert") +} + fuzz_target!(|data: &[u8]| { - // Reserve the first byte as a mode selector and the next 32 bytes as the - // proptest seed. Everything after that feeds the raw-bytes / crafting - // logic so a single input can drive any arm. + // The first byte selects the arm, everything after it is that arm's input. + // The arms overlap in the byte stream on purpose: only one of them runs per + // execution, so reading them all from byte 1 keeps every mutation live for + // whichever arm the mode byte picks. The proptest seed is zero-padded rather + // than all-or-nothing, otherwise every input shorter than 33 bytes would + // reuse the all-zero seed and libFuzzer grows inputs up from empty. let mode = data.first().copied().unwrap_or(0); + let tail = data.get(1..).unwrap_or(&[]); let mut seed = [0u8; 32]; - let seed_src = data.get(1..33).unwrap_or(&[]); - seed[..seed_src.len()].copy_from_slice(seed_src); - let rest = data.get(33..).unwrap_or(&[]); + let n = tail.len().min(32); + seed[..n].copy_from_slice(&tail[..n]); match mode % 3 { 0 => { // Valid-value arm: drive proptest's Arbitrary from the seed. - let mut runner = TestRunner::new_with_rng( - Config::default(), - TestRng::from_seed(RngAlgorithm::ChaCha, &seed), - ); - let value = match ::arbitrary() - .new_tree(&mut runner) - { + let mut runner = + TestRunner::new_with_rng(config(), TestRng::from_seed(RngAlgorithm::ChaCha, &seed)); + let value = match DESC_STRATEGY.with(|s| s.new_tree(&mut runner)) { Ok(tree) => tree.current(), Err(_) => return, }; assert_rust_roundtrip(&value); - - // The proptest-built value is already normalized (its keys come - // from a BTreeSet). Sanity-check that the BTreeSet semantics hold - // after a wire decode by encoding and re-decoding, then confirm - // re-collecting the keys into a fresh set is idempotent. - let decoded: MySqlTableDesc = value - .into_proto() - .encode_to_vec() - .as_slice() - .pipe(ProtoMySqlTableDesc::decode) - .expect("decode") - .into_rust() - .expect("into_rust"); - let recollected: std::collections::BTreeSet = - decoded.keys.iter().cloned().collect(); - assert_eq!(decoded.keys, recollected, "key set not idempotent"); } 1 => { // Duplicate/unsorted-keys arm. - let proto = craft_unsorted_dup_keys(rest); - check_decoded(proto.encode_to_vec().as_slice()); + let proto = craft_unsorted_dup_keys(tail); + let decoded = decode_crafted(&proto); + // The wire field is a repeated Vec and the Rust field a BTreeSet, so + // decoding must be insensitive to the wire order and must collapse + // the duplicates rather than keep them or reject the message. + // + // NOTE: rotate, don't reverse. `craft_unsorted_dup_keys` lays the + // keys out as a palindrome, so reversing them would re-encode the + // very same wire bytes and assert nothing. + let mut permuted = proto; + permuted.keys.rotate_left(1); + assert_eq!( + decoded, + decode_crafted(&permuted), + "wire key order changed the decoded descriptor" + ); + // `craft_unsorted_dup_keys` builds exactly two distinct keys (their + // `columns` differ in length, so they can never compare equal), + // each repeated twice. + assert_eq!( + decoded.keys.len(), + 2, + "duplicate wire keys did not collapse" + ); + assert_rust_roundtrip(&decoded); } _ => { // Raw-bytes arm: decode arbitrary bytes directly. - check_decoded(rest); + check_decoded(tail); } } }); - -/// Tiny extension trait so the valid-value arm can read top-to-bottom. -trait Pipe: Sized { - fn pipe(self, f: impl FnOnce(Self) -> R) -> R { - f(self) - } -} -impl Pipe for T {} diff --git a/src/persist-client/fuzz/fuzz_targets/rollup_proto_roundtrip.rs b/src/persist-client/fuzz/fuzz_targets/rollup_proto_roundtrip.rs index 2eb1b38d52892..45a8741c3bd81 100644 --- a/src/persist-client/fuzz/fuzz_targets/rollup_proto_roundtrip.rs +++ b/src/persist-client/fuzz/fuzz_targets/rollup_proto_roundtrip.rs @@ -10,7 +10,11 @@ //! Fuzz target: exercises `ProtoRollup` decoding and the `Rollup` //! `from_proto` conversion. A rollup is a full state snapshot read from blob on //! load, so a decoder panic on a corrupted/crafted blob makes the shard -//! unrecoverable. +//! unrecoverable. NOTE: an `Err` is not a *good* outcome on that path either. +//! `UntypedState::decode` `.expect()`s the conversion, so a rejected rollup +//! still aborts the process. What this target buys is a decoder that fails +//! predictably and boundedly (no hang, no OOM, no wild slicing) plus a working +//! error path for the CLI/inspect callers that do surface it. //! //! Decoding *random* bytes as a protobuf almost never yields a `ProtoRollup` //! that survives `into_rust`: the conversion needs a well-formed shard id @@ -21,15 +25,20 @@ //! `ProtoRollup` on the protobuf wire from fuzzer-chosen parameters. The first //! byte selects a mode: //! -//! * mode 0: feed the remaining bytes straight to `ProtoRollup::decode` -//! (the robustness arm: must never panic, and any value that -//! converts must survive a proto re-encode round trip losslessly). +//! * mode 0: feed the remaining bytes straight to `ProtoRollup::decode` (the +//! robustness arm: must never panic, and any value that converts must be +//! *stable* under a further round trip). Arbitrary bytes can be non-canonical, +//! so this arm can only assert stability, not losslessness. Mode 1 covers that. //! * mode 1: synthesize a valid rollup *with* inlined diffs whose bounds -//! satisfy the invariants, decode it (the happy path that the invariant -//! checks must accept), and round-trip it. +//! satisfy the invariants, then require that it converts and that the +//! conversion is *lossless*: the hand-built message is canonical, so +//! re-encoding the converted value must reproduce it field for field. //! * mode 2: synthesize a valid rollup, then *perturb a single invariant //! field* (drop the rollups map, or shift `diffs.lower`/`diffs.upper` off -//! the expected seqno). `from_proto` must reject it with `Err`, never panic. +//! the expected seqno). `from_proto` must reject it with an `Err` that names +//! the perturbed field, never panic. Matching the message matters: a bare +//! `is_err()` would keep passing if the rollup started being rejected for +//! some unrelated reason, hiding that the mutation stopped being tested. #![no_main] @@ -219,7 +228,11 @@ fn build_rollup(u: &mut Unstructured, mutate: Option) -> Vec { } else { None }; - put_bytes(&mut buf, 16, &rollups_entry(*seqno, &hollow_rollup(&key, sz))); + put_bytes( + &mut buf, + 16, + &rollups_entry(*seqno, &hollow_rollup(&key, sz)), + ); } } @@ -257,9 +270,11 @@ fn build_rollup(u: &mut Unstructured, mutate: Option) -> Vec { _ => {} } - // Pick in-range diff seqnos only for the un-mutated/drop-rollups cases. - // `from_proto` does not bound-check individual diff seqnos, so this is - // about producing realistic content rather than satisfying an invariant. + // Diff seqnos start at `lower`, which is the shifted one for + // `ShiftLower`, so the mutated cases can place them outside the true + // range. That is harmless: `from_proto` checks only the `lower`/`upper` + // bounds and never the individual diff seqnos, so this is about + // producing realistic content rather than satisfying an invariant. let mut diffs: Vec<(u64, Vec)> = Vec::new(); if state_seqno > latest_rollup_seqno { let span = upper.saturating_sub(lower).min(4); @@ -275,16 +290,29 @@ fn build_rollup(u: &mut Unstructured, mutate: Option) -> Vec { buf } -fn roundtrip(proto: ProtoRollup) { +/// Round trip whatever converts, tolerating inputs that don't. +/// +/// Only for mode 0, where arbitrary bytes legitimately fail to convert. A mode +/// that builds a rollup it *knows* is valid must require the conversion instead, +/// or a newly-rejected shape silently turns the arm into a no-op. +fn roundtrip_lenient(proto: ProtoRollup) { let orig: Rollup = match proto.into_rust() { Ok(v) => v, Err(_) => return, }; + roundtrip_from(orig.into_proto()); +} - let proto2: ProtoRollup = orig.into_proto(); +/// Asserts a canonical proto (already an `into_proto` output) is stable under a +/// further encode / decode / convert / re-encode. +/// +/// Both sides of the comparison are post-`into_proto`, so this only proves +/// `into_proto ∘ from_proto` is idempotent. Losslessness of the *first* pass is +/// mode 1's job, since only a hand-built canonical input can assert it. +fn roundtrip_from(proto2: ProtoRollup) { let bytes2 = proto2.encode_to_vec(); - let proto3 = ProtoRollup::decode(bytes2.as_slice()) - .expect("re-encode of valid Rollup must decode"); + let proto3 = + ProtoRollup::decode(bytes2.as_slice()).expect("re-encode of valid Rollup must decode"); let round: Rollup = proto3 .into_rust() .expect("re-encoded Rollup must convert back to Rust"); @@ -306,33 +334,63 @@ fuzz_target!(|data: &[u8]| { let Ok(proto) = ProtoRollup::decode(rest) else { return; }; - roundtrip(proto); + roundtrip_lenient(proto); } 1 => { // Valid-rollup arm: the synthesized message satisfies the diff - // invariants and must decode + round-trip. + // invariants, so it must convert, and convert losslessly. let mut u = Unstructured::new(rest); let bytes = build_rollup(&mut u, None); let proto = ProtoRollup::decode(bytes.as_slice()).expect("hand-built ProtoRollup must decode"); - roundtrip(proto); + // Every shape `build_rollup` emits is valid, so rejection here is a + // find, not a reason to skip the arm. + let orig: Rollup = proto + .clone() + .into_rust() + .expect("synthesized rollup must convert"); + let proto2: ProtoRollup = orig.into_proto(); + // The strong oracle: the input is already canonical, so a field the + // conversion drops or defaults shows up right here. Comparing two + // `into_proto` outputs (as `roundtrip_from` does) cannot see that, + // and `Rollup::into_proto` reaches through `self.state.state...` + // rather than destructuring, so a field added to `State` and wired + // into `from_proto` but forgotten in `into_proto` compiles fine and + // is then silently lost from every rollup written. + // + // The one legitimate normalization: an absent `applier_version` + // decodes as "infinitely old" and re-encodes as 0.0.0. + let mut expected = proto; + if expected.applier_version.is_empty() { + expected.applier_version = "0.0.0".into(); + } + assert_eq!( + expected, proto2, + "field lost across ProtoRollup -> Rollup -> ProtoRollup" + ); + roundtrip_from(proto2); } _ => { // Invariant-violation arm: break exactly one invariant and require - // `from_proto` to reject (not panic). + // `from_proto` to reject it, naming that invariant (not panic, and + // not fail for some unrelated reason). let mut u = Unstructured::new(rest); - let mutation = match u.u8() % 3 { - 0 => Mutation::DropRollups, - 1 => Mutation::ShiftLower, - _ => Mutation::ShiftUpper, + let (mutation, expected_err) = match u.u8() % 3 { + 0 => (Mutation::DropRollups, "no rollups"), + 1 => (Mutation::ShiftLower, "diffs lower"), + _ => (Mutation::ShiftUpper, "diffs upper"), }; let bytes = build_rollup(&mut u, Some(mutation)); - let proto = ProtoRollup::decode(bytes.as_slice()) - .expect("hand-built ProtoRollup must decode"); + let proto = + ProtoRollup::decode(bytes.as_slice()).expect("hand-built ProtoRollup must decode"); let result: Result, _> = proto.into_rust(); + let err = result.err().expect( + "Rollup with a broken diff-bounds invariant must be rejected by from_proto", + ); + let msg = err.to_string(); assert!( - result.is_err(), - "Rollup with a broken diff-bounds invariant must be rejected by from_proto" + msg.contains(expected_err), + "expected a rejection mentioning {expected_err:?}, got: {msg}" ); } } diff --git a/src/persist-client/fuzz/fuzz_targets/state_diff_proto_roundtrip.rs b/src/persist-client/fuzz/fuzz_targets/state_diff_proto_roundtrip.rs index c3ca90b360095..d616a98bc5dec 100644 --- a/src/persist-client/fuzz/fuzz_targets/state_diff_proto_roundtrip.rs +++ b/src/persist-client/fuzz/fuzz_targets/state_diff_proto_roundtrip.rs @@ -23,15 +23,22 @@ //! This target therefore hand-builds the columnar encoding on the protobuf wire //! from fuzzer-chosen parameters. The first byte selects a mode: //! -//! * mode 0: feed the remaining bytes straight to `ProtoStateDiff::decode` -//! (the robustness arm: must never panic, and any value that -//! converts must survive a proto re-encode round trip losslessly). +//! * mode 0: feed the remaining bytes straight to `ProtoStateDiff::decode` (the +//! robustness arm: must never panic, and any value that converts must be +//! *stable* under a further round trip). Arbitrary bytes can be non-canonical +//! (`Antichain` minimization, the `DeprecatedRollups` remap), so this arm can +//! only assert stability, not losslessness. //! * mode 1: synthesize a *valid* columnar diff over a mix of fields and //! insert/update/delete diff types, with self-consistent slice counts and -//! lengths, then decode + round-trip it. +//! lengths. The conversion must accept it, and must keep every diff: a +//! dropped or defaulted diff is invisible to the stability oracle. //! * mode 2: synthesize a diff whose columnar bookkeeping is *inconsistent* //! (slice count or byte length mismatch, or an unknown field/diff-type enum). //! `from_proto`'s `validate()`/iter must reject it with `Err`, never panic. +//! A declared byte length is the one class that can get *past* `validate()`: +//! lengths that sum past `u64::MAX` wrap (overflow checks are off in +//! release/optimized builds) and can match the real `data_bytes` length, so +//! the corruptions cover that edge explicitly. #![no_main] @@ -193,6 +200,10 @@ struct ColumnarDiff { diff_type: u64, /// key slice followed by 1 (insert/delete) or 2 (update) value slices. slices: Vec>, + /// Replaces the `data_lens` entry written for the *last* slice, while the + /// real slice bytes still go into `data_bytes`. The only way to express a + /// declared length that disagrees with the bytes actually present. + len_override: Option, } /// Picks a field + diff type + correctly-shaped key/value slices. @@ -212,12 +223,12 @@ fn gen_diff(u: &mut Unstructured) -> ColumnarDiff { // (field, key slice, value-slice generator). let (field, key): (u64, Vec) = match u.u8() % 6 { - 0 => (FIELD_HOSTNAME, Vec::new()), // key () - 1 => (FIELD_LAST_GC_REQ, Vec::new()), // key () - 2 => (FIELD_SINCE, Vec::new()), // key () + 0 => (FIELD_HOSTNAME, Vec::new()), // key () + 1 => (FIELD_LAST_GC_REQ, Vec::new()), // key () + 2 => (FIELD_SINCE, Vec::new()), // key () 3 => (FIELD_ROLLUPS, enc_u64(u.u64() % 10_000)), // key u64 (SeqNo) - 4 => (FIELD_ACTIVE_ROLLUP, Vec::new()), // key () - _ => (FIELD_ACTIVE_GC, Vec::new()), // key () + 4 => (FIELD_ACTIVE_ROLLUP, Vec::new()), // key () + _ => (FIELD_ACTIVE_GC, Vec::new()), // key () }; let mut slices = Vec::with_capacity(1 + num_vals); @@ -251,6 +262,7 @@ fn gen_diff(u: &mut Unstructured) -> ColumnarDiff { field, diff_type, slices, + len_override: None, } } @@ -265,8 +277,13 @@ fn encode_field_diffs(diffs: &[ColumnarDiff]) -> Vec { for d in diffs { put_uint(&mut fields, 1, d.field); put_uint(&mut diff_types, 2, d.diff_type); - for slice in &d.slices { - put_uint(&mut data_lens, 3, slice.len() as u64); + for (i, slice) in d.slices.iter().enumerate() { + let is_last = i + 1 == d.slices.len(); + let len = match d.len_override { + Some(len) if is_last => len, + _ => slice.len() as u64, + }; + put_uint(&mut data_lens, 3, len); data_bytes.extend_from_slice(slice); } } @@ -300,13 +317,27 @@ fn encode_state_diff(u: &mut Unstructured, field_diffs: &[u8]) -> Vec { buf } -fn roundtrip(proto: ProtoStateDiff) { +/// Round trip whatever converts, tolerating inputs that don't. +/// +/// Only for mode 0, where arbitrary bytes legitimately fail to convert. Modes +/// that build a diff they *know* is valid must require the conversion instead, +/// or a newly-rejected shape silently turns the arm into a no-op. +fn roundtrip_lenient(proto: ProtoStateDiff) { let orig: StateDiff = match proto.into_rust() { Ok(v) => v, Err(_) => return, }; + roundtrip_from(orig.into_proto()); +} - let proto2: ProtoStateDiff = orig.into_proto(); +/// Asserts a canonical proto (already an `into_proto` output) is stable under a +/// further encode / decode / convert / re-encode. +/// +/// Both sides of the comparison are post-`into_proto`, so input-side +/// normalization can't make it false-fire. Comparing the protos rather than the +/// `StateDiff`s is forced: `StateDiff` only derives `PartialEq` under +/// `cfg(any(test, debug_assertions))`. +fn roundtrip_from(proto2: ProtoStateDiff) { let bytes2 = proto2.encode_to_vec(); let proto3 = ProtoStateDiff::decode(bytes2.as_slice()) .expect("re-encode of valid StateDiff must decode"); @@ -331,7 +362,7 @@ fuzz_target!(|data: &[u8]| { let Ok(proto) = ProtoStateDiff::decode(rest) else { return; }; - roundtrip(proto); + roundtrip_lenient(proto); } 1 => { // Valid columnar arm: a self-consistent set of field diffs that must @@ -343,17 +374,31 @@ fuzz_target!(|data: &[u8]| { let bytes = encode_state_diff(&mut u, &field_diffs); let proto = ProtoStateDiff::decode(bytes.as_slice()) .expect("hand-built ProtoStateDiff must decode"); - roundtrip(proto); + // Every shape `gen_diff` emits is valid, so rejection here is a + // find, not a reason to skip the arm. + let orig: StateDiff = proto + .into_rust() + .expect("valid columnar field_diffs must convert"); + let proto2: ProtoStateDiff = orig.into_proto(); + // The oracle below compares two `into_proto` outputs, so it cannot + // see content dropped between the input and `proto2`. `from_proto` + // pushes exactly one diff per input diff and `into_proto` emits + // exactly one entry per stored diff, so the counts must match. + assert_eq!( + proto2.field_diffs.as_ref().map_or(0, |f| f.fields.len()), + num_diffs, + "from_proto dropped columnar diffs" + ); + roundtrip_from(proto2); } _ => { // Inconsistent-columnar arm: build a valid set, then corrupt the // bookkeeping so `validate()`/iter must reject it (not panic). let mut u = Unstructured::new(rest); let num_diffs = u.range(1, 6); - let mut diffs: Vec = - (0..num_diffs).map(|_| gen_diff(&mut u)).collect(); + let mut diffs: Vec = (0..num_diffs).map(|_| gen_diff(&mut u)).collect(); - match u.u8() % 4 { + match u.u8() % 5 { 0 => { // Drop a value slice: data_lens count no longer matches the // count implied by diff_types. @@ -373,12 +418,30 @@ fuzz_target!(|data: &[u8]| { last.field = 9999; } } - _ => { + 3 => { // Unknown diff-type enum value. if let Some(last) = diffs.last_mut() { last.diff_type = 9999; } } + _ => { + // A declared length that disagrees with the bytes actually + // written: `validate()`'s data_bytes sum check, including the + // `u64::MAX` edge where the sum of the lengths wraps and can + // match the real data_bytes length. + // + // Always strictly greater than the true length, so the + // corruption is guaranteed to be one (a length that happened + // to match would make the `is_err` assertion below false-fire). + if let Some(last) = diffs.last_mut() { + let true_len = last.slices.last().map_or(0, |s| s.len() as u64); + last.len_override = Some(match u.u8() % 3 { + 0 => u64::MAX, + 1 => u64::MAX - 1, + _ => true_len + u64::from(u.u8()) + 1, + }); + } + } } let field_diffs = encode_field_diffs(&diffs); diff --git a/src/pgcopy/fuzz/fuzz_targets/copy_decode.rs b/src/pgcopy/fuzz/fuzz_targets/copy_decode.rs index fe9e1bf426d98..73458d49c44f4 100644 --- a/src/pgcopy/fuzz/fuzz_targets/copy_decode.rs +++ b/src/pgcopy/fuzz/fuzz_targets/copy_decode.rs @@ -32,7 +32,11 @@ //! token) is properly quoted and escaped so it survives the csv-core framing //! layer and reaches the value decoder instead of erroring early. When a //! header is configured we additionally emit a leading header row so the -//! header-skip path is exercised alongside data rows. +//! header-skip path is exercised alongside data rows. NOTE: a delimiter or +//! quote of `\r`/`\n` still frames badly, because the record terminator is +//! then itself a framing byte and no amount of quoting disambiguates it. +//! Those params are still worth feeding to the decoder, so they are +//! generated anyway, they just rarely reach a value decoder. //! //! A quarter of inputs feed the raw bytes through both formats so the //! framing/error paths stay covered. @@ -83,12 +87,23 @@ fn push_value( } 2 => out.push_str(u.choose(&["t", "f", "true", "false"])?), 3 => out.push_str(u.choose(&[ - "0", "-1.5", "3.14", "1e10", "-2.5e-3", "Infinity", "-Infinity", "NaN", + "0", + "-1.5", + "3.14", + "1e10", + "-2.5e-3", + "Infinity", + "-Infinity", + "NaN", ])?), 4 => { // bytea hex: `\x`. In COPY text the backslash must be doubled. + // Digits come in pairs: `parse_bytes_hex` rejects an odd count, and + // a rejected column aborts the whole decode, so the columns after + // this one would never run. out.push_str(if text_format { "\\\\x" } else { "\\x" }); - for _ in 0..u.int_in_range(0usize..=6)? { + for _ in 0..u.int_in_range(0usize..=3)? { + out.push(*u.choose(HEX)?); out.push(*u.choose(HEX)?); } } @@ -124,12 +139,10 @@ fn push_text_escapes(u: &mut Unstructured, out: &mut String) -> arbitrary::Resul for _ in 0..n { out.push_str(u.choose(&[ // Literal chars (decode to themselves). - "a", "b", "Z", "0", - // Recognized C-style escapes (decode to control bytes). + "a", "b", "Z", "0", // Recognized C-style escapes (decode to control bytes). "\\b", "\\f", "\\n", "\\r", "\\t", "\\v", // Hex escapes in the printable ASCII range. - "\\x41", "\\x7e", "\\x2c", - // Octal escapes in the printable ASCII range. + "\\x41", "\\x7e", "\\x2c", // Octal escapes in the printable ASCII range. "\\101", "\\052", "\\176", // A backslash before a non-escape char drops the backslash. "\\q", "\\\\", @@ -144,26 +157,29 @@ fn push_text_escapes(u: &mut Unstructured, out: &mut String) -> arbitrary::Resul /// NULL marker), or when it equals the active NULL token (so a data value that /// happens to match the NULL token is preserved as data rather than read as /// SQL NULL). -fn push_csv_field(field: &str, params: &CopyCsvFormatParams, out: &mut String) { - let q = params.quote as char; - let esc = params.escape as char; - let delim = params.delimiter as char; +/// +/// The record is built as bytes, not as a `String`: the delimiter/quote/escape +/// params are `u8` and csv-core compares them byte-wise, but `u8 as char` +/// UTF-8-encodes anything >= 0x80 as two bytes, which emits a byte pair the +/// configured params cannot frame. +fn push_csv_field(field: &str, params: &CopyCsvFormatParams, out: &mut Vec) { + let (q, esc, delim) = (params.quote, params.escape, params.delimiter); let needs_quote = field.is_empty() || *field == *params.null || field - .chars() - .any(|c| c == delim || c == q || c == esc || c == '\r' || c == '\n'); + .bytes() + .any(|b| b == delim || b == q || b == esc || b == b'\r' || b == b'\n'); if needs_quote { out.push(q); - for c in field.chars() { - if c == q || c == esc { + for b in field.bytes() { + if b == q || b == esc { out.push(esc); } - out.push(c); + out.push(b); } out.push(q); } else { - out.push_str(field); + out.extend_from_slice(field.as_bytes()); } } @@ -201,8 +217,23 @@ fn arbitrary_csv_params(u: &mut Unstructured) -> arbitrary::Result = ["NULL", "\\N", "null", "NA", "-"] + .into_iter() + .filter(|t| { + !t.bytes() + .any(|b| b == delimiter || b == quote || b == escape) + }) + .collect(); + if candidates.is_empty() { + String::new() + } else { + u.choose(&candidates)?.to_string() + } } else { String::new() }; @@ -254,8 +285,8 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { // CSV: fuzz the format params, then emit data the params can actually frame. let params = arbitrary_csv_params(&mut u)?; - let delim = params.delimiter as char; - let mut s = String::new(); + let delim = params.delimiter; + let mut s = Vec::new(); // A configured header means the first record is column names that the // decoder skips. Emit a plausible one so the header-skip path runs. @@ -266,7 +297,7 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { } push_csv_field(&format!("col{col}"), ¶ms, &mut s); } - s.push('\n'); + s.push(b'\n'); } let rows = u.int_in_range(1usize..=4)?; @@ -278,17 +309,17 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { // 1-in-8 NULL: emit the unquoted NULL token (empty field for the // default empty marker), which the decoder reads as SQL NULL. if u.int_in_range(0u8..=7)? == 0 { - s.push_str(¶ms.null); + s.extend_from_slice(params.null.as_bytes()); } else { let mut field = String::new(); push_value(&mut u, col, false, &mut field)?; push_csv_field(&field, ¶ms, &mut s); } } - s.push('\n'); + s.push(b'\n'); } - decode_csv(s.as_bytes(), params); + decode_csv(&s, params); Ok(()) } diff --git a/src/pgrepr/fuzz/fuzz_targets/value_decode_binary.rs b/src/pgrepr/fuzz/fuzz_targets/value_decode_binary.rs index 5eb8310f8a8ac..3067eab00be56 100644 --- a/src/pgrepr/fuzz/fuzz_targets/value_decode_binary.rs +++ b/src/pgrepr/fuzz/fuzz_targets/value_decode_binary.rs @@ -14,22 +14,26 @@ //! so any panic is an availability bug. Must never panic. //! //! A random byte string almost never satisfies these strict decoders: an exact -//! length check, a version byte, base-10000 digit bounds, a role-id variant tag. -//! So feeding raw bytes leaves the decoders barely exercised. Instead we pick -//! a type and *encode a valid binary value for it* (numeric header + digits, the -//! 16-byte interval triple, the 26-byte mz_aclitem, a jsonb version byte plus -//! real JSON, in-range date/time/timestamp), so the decoder runs all the way to -//! the value-construction and range-check logic. We still occasionally truncate -//! the valid encoding (to hit the length-validation / short-read paths) and, a -//! quarter of the time, fall back to the "any OID, raw bytes" mode so the -//! not-implemented branches and crafted-header paths stay covered. +//! length check, a version byte, a sign word drawn from a five-value set, a +//! role-id variant tag. So feeding raw bytes leaves the decoders barely +//! exercised. Instead we pick a type and *encode a valid binary value for it* +//! (numeric header + digits, the 16-byte interval triple, the 26-byte +//! mz_aclitem, a jsonb version byte plus real JSON, in-range +//! date/time/timestamp), so the decoder runs all the way to the +//! value-construction and range-check logic. We then occasionally corrupt one +//! byte of that encoding, so the decoders' *content* validation runs and not +//! just their length checks, or truncate it for the length-validation and +//! short-read paths. A quarter of the time we instead fall back to the "any OID, +//! raw bytes" mode so the not-implemented branches and crafted-header paths stay +//! covered. //! -//! A few arms also reach past the "happy" shape on purpose: the numeric header -//! sometimes carries an out-of-band weight/dscale or digit words outside -//! `0..=9999` (negative or `>9999`) to exercise the base-10000 digit-bound and -//! scale math, and the bytea/text bodies are occasionally multi-KB so the -//! decoders' allocation/validation paths run on a large value rather than a -//! handful of bytes. +//! A few arms also reach past the "happy" shape on purpose. The numeric header +//! sometimes carries an out-of-band weight/dscale, or digit words outside +//! `0..=9999`, which Materialize accepts (unlike PostgreSQL's `numeric_recv`, +//! which rejects them) and folds into the accumulator, so extreme values land on +//! the trailing precision guard. The bytea/text bodies are occasionally multi-KB +//! so the decoders' allocation/validation paths run on a large value rather than +//! a handful of bytes. #![no_main] @@ -54,6 +58,134 @@ fn push_role_id(u: &mut Unstructured, b: &mut Vec) -> arbitrary::Result<()> Ok(()) } +/// OID of `numeric`. `NumericConstraints` is not exported from `mz_pgrepr`, so +/// going through the OID is the only way to build a `Type::Numeric` that carries +/// constraints. +const NUMERIC_OID: u32 = 1700; + +/// A `numeric` type, usually unconstrained but sometimes carrying a +/// `numeric(precision, scale)` typmod. +/// +/// Constraints are the only `Type` payload `decode_binary` acts on: they feed +/// `rescale_numeric`, which rescales the fully client-controlled decoded value. +/// Production takes that path for every `$1` bound against a `numeric(p, s)` +/// column, since pgwire derives the parameter type from the planned scalar type. +fn gen_numeric_type(u: &mut Unstructured) -> arbitrary::Result { + if u.int_in_range(0u8..=2)? != 0 { + return Ok(Type::Numeric { constraints: None }); + } + let typmod = if u.int_in_range(0u8..=3)? == 0 { + u.arbitrary::()? + } else { + // A well-formed `numeric(precision, scale)` typmod, packed the way + // `NumericConstraints::into_typmod` does it. + ((u.int_in_range(0i32..=39)? << 16) | u.int_in_range(0i32..=39)?) + 4 + }; + // `NumericConstraints::from_typmod` accepts every `i32`, but fall back + // instead of unwrapping so the harness itself cannot panic. + Ok(Type::from_oid_and_typmod(NUMERIC_OID, typmod) + .unwrap_or(Type::Numeric { constraints: None })) +} + +/// Append a JSON scalar literal. +/// +/// Covers the shapes `JsonbPacker`'s custom visitors exist for: numbers past +/// `f64` and past numeric's 39-digit precision (`NumberParser` funnels every +/// number through `strconv::parse_numeric`), `\u` escapes and lone surrogates, +/// and the `$serde_json::private::Number` magic key with which serde_json spells +/// an arbitrary-precision number, so that a one-key map of it is parsed as a +/// *number* rather than as a map. +fn push_json_scalar(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { + match u.int_in_range(0u8..=7)? { + 0 => out.push_str("null"), + 1 => out.push_str(*u.choose(&["true", "false"])?), + 2 => out.push_str(&u.arbitrary::()?.to_string()), + 3 => out.push_str(&format!( + "{}.{}", + u.arbitrary::()?, + u.arbitrary::()? + )), + 4 => out.push_str(*u.choose(&[ + "1e309", + "-1e309", + "1e-400", + "1e100000", + "-0", + "0.00000000000000000000000000000000000000001", + "111111111111111111111111111111111111111111111", + ])?), + 5 => out.push_str(*u.choose(&[ + r#""""#, + r#""s""#, + r#"" ""#, + r#""\ud800""#, + r#""\udbff\udfff""#, + r#""\\""#, + r#""\"""#, + r#""é""#, + ])?), + 6 => { + out.push_str(r#"{"$serde_json::private::Number":"#); + // Only a numeric string packs. The others reach `NumberParser`'s + // error paths (unparseable number, non-string payload). + out.push_str(*u.choose(&[ + r#""1""#, + r#""NaN""#, + r#""Infinity""#, + r#""1e100000""#, + r#""abc""#, + r#""""#, + "1", + "null", + ])?); + out.push('}'); + } + _ => out.push_str(&u.arbitrary::()?.to_string()), + } + Ok(()) +} + +/// Append a JSON document. `depth` bounds the nesting so we always terminate. +fn push_json(u: &mut Unstructured, out: &mut String, depth: u8) -> arbitrary::Result<()> { + if depth == 0 || u.int_in_range(0u8..=2)? == 0 { + return push_json_scalar(u, out); + } + let n = u.int_in_range(0usize..=4)?; + if u.arbitrary::()? { + out.push('['); + for i in 0..n { + if i > 0 { + out.push(','); + } + push_json(u, out, depth - 1)?; + } + out.push(']'); + } else { + out.push('{'); + for i in 0..n { + if i > 0 { + out.push(','); + } + // Keys come from a small pool so duplicates (which the packer must + // dedup) come up often, and so the magic key also appears alongside + // other keys, where it is a plain map key rather than a number. An + // escaped key cannot be borrowed out of the input, which takes + // `KeyClassifier`'s owned branch instead of its borrowed one. + out.push_str(*u.choose(&[ + r#""a""#, + r#""b""#, + r#""""#, + r#""$serde_json::private::Number""#, + r#""\u0041""#, + ])?); + out.push(':'); + push_json(u, out, depth - 1)?; + } + out.push('}'); + } + Ok(()) +} + /// Pick a type that has a binary decoder and encode a valid value for it. fn gen_typed_value(u: &mut Unstructured) -> arbitrary::Result<(Type, Vec)> { let mut b = Vec::new(); @@ -126,17 +258,9 @@ fn gen_typed_value(u: &mut Unstructured) -> arbitrary::Result<(Type, Vec)> { // Jsonb: a version byte (1) followed by real JSON text. 13 => { b.push(1); - let json: &[u8] = match u.int_in_range(0u8..=7)? { - 0 => b"null", - 1 => b"true", - 2 => b"123", - 3 => b"-4.5", - 4 => b"\"s\"", - 5 => b"[1,2,3]", - 6 => b"{\"a\":1}", - _ => b"[]", - }; - b.extend_from_slice(json); + let mut json = String::new(); + push_json(u, &mut json, 3)?; + b.extend_from_slice(json.as_bytes()); Type::Jsonb } 14 => { @@ -146,13 +270,15 @@ fn gen_typed_value(u: &mut Unstructured) -> arbitrary::Result<(Type, Vec)> { // Numeric: i16 ndigits, i16 weight, u16 sign, u16 dscale, then ndigits // base-10000 words (each 0..=9999). 15 => { - let nan = u.int_in_range(0u8..=5)? == 0; - let (ndigits, sign): (i16, u16) = if nan { - (0, 0xC000) - } else if u.int_in_range(0u8..=1)? == 0 { - (u.int_in_range(0i16..=4)?, 0x0000) + // All five sign words `Numeric::from_sql` accepts. `NaN` and + // `±Infinity` return early, skipping the dscale/scale validation and + // the trailing `to_width` plus context-status guard that the finite + // signs go through, and a real encoding gives them no digit words. + let sign = *u.choose(&[0x0000u16, 0x4000, 0xC000, 0xD000, 0xF000])?; + let ndigits: i16 = if sign == 0x0000 || sign == 0x4000 { + u.int_in_range(0i16..=4)? } else { - (u.int_in_range(0i16..=4)?, 0x4000) + 0 }; // Mostly a well-formed in-range header so the decoder reaches value // construction. Occasionally an out-of-band weight/dscale so the @@ -172,18 +298,20 @@ fn gen_typed_value(u: &mut Unstructured) -> arbitrary::Result<(Type, Vec)> { b.extend_from_slice(&sign.to_be_bytes()); b.extend_from_slice(&dscale.to_be_bytes()); // Each base-10000 digit word should be 0..=9999. Occasionally emit - // an out-of-band word (>9999, or the full i16 range incl. negative) - // to exercise the digit-bound validation path. + // one outside that range. `from_sql` reads the words as `u16` and + // folds them straight into the accumulator without a bound check, so + // this is not a validation path but a way to push the accumulator + // toward the trailing precision guard. let oob_words = u.int_in_range(0u8..=7)? == 0; for _ in 0..ndigits { let word = if oob_words { - u.arbitrary::()? + u.arbitrary::()? } else { - u.int_in_range(0i16..=9999)? + u.int_in_range(0u16..=9999)? }; b.extend_from_slice(&word.to_be_bytes()); } - Type::Numeric { constraints: None } + gen_numeric_type(u)? } 16 => { b.extend_from_slice(&u.arbitrary::()?.to_be_bytes()); @@ -192,7 +320,11 @@ fn gen_typed_value(u: &mut Unstructured) -> arbitrary::Result<(Type, Vec)> { 17 => { // Occasionally a multi-KB UTF-8 body so the text decoder's // validation/copy runs on a large value, not just a short one. - let max = if u.int_in_range(0u8..=15)? == 0 { 8192 } else { 16 }; + let max = if u.int_in_range(0u8..=15)? == 0 { + 8192 + } else { + 16 + }; push_ascii(u, &mut b, max)?; Type::Text } @@ -248,14 +380,32 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { // length error paths covered. if u.int_in_range(0u8..=3)? == 0 { let oid = u32::from(u.arbitrary::()?); + // A typmod other than -1 also covers `from_oid_and_typmod`'s + // `InvalidTypmod` rejection, which no other mode reaches. + let typmod = if u.int_in_range(0u8..=3)? == 0 { + u.arbitrary::()? + } else { + -1 + }; let rest = u.take_rest(); - if let Ok(ty) = Type::from_oid(oid) { + if let Ok(ty) = Type::from_oid_and_typmod(oid, typmod) { let _ = Value::decode_binary(&ty, rest); } return Ok(()); } let (ty, mut body) = gen_typed_value(&mut u)?; + // Occasionally corrupt a byte so the decoders' *content* validation runs and + // not only their length checks: an unrecognized role-id variant tag, a NUL or + // invalid UTF-8 in a `name`, a non-numeric `mz_timestamp`, an unknown numeric + // sign, a wrong jsonb version byte. Truncation alone only ever produces + // bodies that are short, never bodies that are wrong. For the + // Materialize-specific types this is the only source of bad content at all, + // since `Type::from_oid` cannot construct them, so raw mode never sees them. + if !body.is_empty() && u.int_in_range(0u8..=7)? == 0 { + let i = u.int_in_range(0usize..=body.len() - 1)?; + body[i] = u.arbitrary::()?; + } // Occasionally truncate to hit the exact-length / short-read checks. if !body.is_empty() && u.int_in_range(0u8..=7)? == 0 { let keep = u.int_in_range(0usize..=body.len())?; diff --git a/src/pgrepr/fuzz/fuzz_targets/value_decode_text.rs b/src/pgrepr/fuzz/fuzz_targets/value_decode_text.rs index 1ec5c744ff062..ffa3615ba7768 100644 --- a/src/pgrepr/fuzz/fuzz_targets/value_decode_text.rs +++ b/src/pgrepr/fuzz/fuzz_targets/value_decode_text.rs @@ -9,8 +9,8 @@ //! Fuzz target: `Value::decode_text` decodes a client-supplied bind-parameter //! value in Postgres *text* format. It dispatches on the type and delegates to -//! the `strconv` parsers (recursively, for array/list/map/record/range), all -//! over untrusted client bytes. Must never panic. +//! the `strconv` parsers (recursively, for array/list/map/range), all over +//! untrusted client bytes. Must never panic. //! //! A random byte string almost never reaches the interesting recursive //! decoders: the array/list/map/range grammars need a leading brace/bracket and @@ -24,101 +24,246 @@ //! bodies for `Array`/`List`/`Map`/`Range` (including the `empty` range and the //! unsupported-but-parsed `[lo:hi]=` array-dimension prefix). This drives the //! recursive element dispatch and the per-element scalar parsers all the way to -//! value construction and range/normalization checks. We still spend a quarter -//! of inputs in the "any OID, raw bytes" mode so the not-implemented branches -//! and the syntax-error paths stay covered. +//! value construction and range/normalization checks. +//! +//! The type is built first and the literal is then built *for* that type at +//! every nesting level, so a nested container carries an element that is +//! well-typed for it rather than one re-rolled from an unrelated type. That is +//! what reaches `parse_list`'s nested-list mode, `parse_map`'s nested-map mode, +//! and array-of-range element construction. A small share of elements is +//! deliberately ill-typed so the element-level error paths stay covered too. +//! +//! We still spend a quarter of inputs in the "raw bytes" mode so the +//! not-implemented branches (json, timetz, int2vector) and the scalar +//! syntax-error paths stay covered. The OID there is drawn from the set +//! `Type::from_oid` actually resolves: it recognizes only 64 of the 65,536 `u16` +//! OIDs, so an arbitrary OID would throw away 99.9% of that budget on an +//! `UnknownOid` early return. +//! +//! NOTE: `Type::Record`'s arm stays unreachable. `from_oid` has no `RECORD` arm, +//! and `decode_text` rejects anonymous composite types outright, so there is +//! nothing behind it to exercise. //! //! Excluded from the main workspace because libFuzzer requires nightly Rust. #![no_main] +use std::sync::LazyLock; + use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_pgrepr::{Type, Value}; -/// A well-formed text literal for a scalar leaf type, paired with that type. -/// The literal is in the *unnested* representation (no extra quoting). The -/// container builders re-quote/escape it as needed. -fn gen_leaf(u: &mut Unstructured) -> arbitrary::Result<(Type, String)> { - Ok(match u.int_in_range(0u8..=14)? { - 0 => ( - Type::Bool, - (*u.choose(&["true", "false", "t", "f", "yes", "no", "on", "off", "1", "0"])?) - .to_string(), - ), +/// Every OID that `Type::from_oid` resolves. Discovered by scanning the `u16` +/// space, which is exhaustive: `from_oid` delegates to +/// `postgres_types::Type::from_oid`, which knows no OID above that and none of +/// Materialize's custom OIDs. +static KNOWN_OIDS: LazyLock> = LazyLock::new(|| { + (0..=u32::from(u16::MAX)) + .filter(|oid| Type::from_oid(*oid).is_ok()) + .collect() +}); + +/// A scalar leaf type, i.e. one of the non-container `decode_text` arms that has +/// a hand-written text parser behind it. +fn gen_leaf_type(u: &mut Unstructured) -> arbitrary::Result { + Ok(match u.int_in_range(0u8..=18)? { + 0 => Type::Bool, + 1 => Type::Int2, + 2 => Type::Int4, + 3 => Type::Int8, + 4 => Type::UInt2, + 5 => Type::UInt4, + 6 => Type::UInt8, + 7 => Type::Oid, + 8 => Type::Float4, + 9 => Type::Float8, + 10 => gen_numeric_type(u)?, + 11 => Type::Interval { constraints: None }, + 12 => Type::Date, + 13 => Type::Timestamp { precision: None }, + 14 => Type::Uuid, + 15 => Type::Name, + 16 => Type::MzTimestamp, + 17 => Type::MzAclItem, + _ => Type::AclItem, + }) +} + +/// A `numeric`, sometimes carrying precision/scale constraints so that +/// `rescale_numeric` runs rather than passing the value straight through. +/// +/// `Type` exposes no constructor for `NumericConstraints`, so the constraints +/// have to come from a packed typmod. +fn gen_numeric_type(u: &mut Unstructured) -> arbitrary::Result { + let typmod = match u.int_in_range(0u8..=3)? { + // A plausible `numeric(p, s)`: the scale converts and `rescale` runs. + 1 | 2 => { + let precision = u.int_in_range(0i32..=39)?; + let scale = u.int_in_range(0i32..=39)?; + ((precision << 16) | (scale & 0x7ff)) + 4 + } + // Any typmod: a scale outside `0..=39` (the encoding admits negative + // scales) instead fails the `NumericMaxScale` conversion. + 3 => u.arbitrary::()?, + // Unconstrained. + _ => -1, + }; + let oid = Type::Numeric { constraints: None }.oid(); + Ok(Type::from_oid_and_typmod(oid, typmod).unwrap_or(Type::Numeric { constraints: None })) +} + +/// A `Type` the generator can write a literal for, nesting at most `depth` +/// containers deep. +fn gen_type(u: &mut Unstructured, depth: u8) -> arbitrary::Result { + // At max depth, or randomly, stop at a scalar leaf. + if depth == 0 || u.int_in_range(0u8..=2)? == 0 { + return gen_leaf_type(u); + } + + Ok(match u.int_in_range(0u8..=3)? { + 0 => Type::Array(Box::new(gen_type(u, depth - 1)?)), + 1 => Type::List(Box::new(gen_type(u, depth - 1)?)), + 2 => Type::Map { + value_type: Box::new(gen_type(u, depth - 1)?), + }, + // A range element must be a totally-ordered scalar, so restrict it to + // one of the supported domains. + _ => Type::Range { + element_type: Box::new(match u.int_in_range(0u8..=4)? { + 0 => Type::Int4, + 1 => Type::Int8, + 2 => gen_numeric_type(u)?, + 3 => Type::Date, + _ => Type::Timestamp { precision: None }, + }), + }, + }) +} + +/// A well-formed text literal *for* `ty`, in the unnested representation (no +/// extra quoting). The container builders re-quote/escape it as needed. +fn leaf_literal(u: &mut Unstructured, ty: &Type) -> arbitrary::Result { + Ok(match ty { + Type::Bool => (*u.choose(&[ + "true", "false", "t", "f", "yes", "no", "on", "off", "1", "0", + ])?) + .to_string(), // Integers: in- and out-of-range so the parse-int overflow path is hit. - 1 => (Type::Int2, gen_int_literal(u)?), - 2 => (Type::Int4, gen_int_literal(u)?), - 3 => (Type::Int8, gen_int_literal(u)?), - 4 => (Type::UInt2, gen_int_literal(u)?), - 5 => (Type::UInt4, gen_int_literal(u)?), - 6 => (Type::UInt8, gen_int_literal(u)?), - 7 => (Type::Oid, gen_int_literal(u)?), - // Floats, including the special-token branches. - 8 => ( - Type::Float8, - (*u.choose(&[ - "0", "-0", "1.5", "-2.25", "3e10", "1.2e-3", "inf", "-inf", "Infinity", "NaN", ".5", - "1e400", - ])?) - .to_string(), - ), - 9 => (Type::Float4, gen_int_literal(u)?), - // Numeric: feed digit strings, exponents, and out-of-band magnitudes. - 10 => (Type::Numeric { constraints: None }, gen_numeric_literal(u)?), + Type::Int2 + | Type::Int4 + | Type::Int8 + | Type::UInt2 + | Type::UInt4 + | Type::UInt8 + | Type::Oid => gen_int_literal(u)?, + // Floats, including the special tokens and the overflow/underflow + // boundaries `parse_float` detects after the fact. The f32-only bounds + // matter because `Float4` is the sole caller of `f32::from_str`. + Type::Float4 | Type::Float8 => (*u.choose(&[ + "0", "-0", "1.5", "-2.25", "3e10", "1.2e-3", "inf", "-inf", "Infinity", "NaN", ".5", + "1e400", "3.4e39", "1e-46", + ])?) + .to_string(), + // Numeric: digit strings, exponents, and out-of-band magnitudes. + Type::Numeric { .. } => gen_numeric_literal(u)?, // Interval: a grab bag of the unit/ISO/SQL-standard forms. - 11 => ( - Type::Interval { constraints: None }, - (*u.choose(&[ - "1 day", - "01:02:03", - "-1 year 2 mons", - "1-2", - "P1Y2M3DT4H5M6S", - "1 day 2:03:04.567", - "@ 5 hours ago", - "100000000 years", - "1.5 days", - ])?) - .to_string(), - ), - // Date / time / timestamp(tz): valid and edge-of-range forms. - 12 => ( - Type::Date, - (*u.choose(&[ - "2000-01-01", - "0001-01-01 BC", - "294276-12-31", - "infinity", - "-infinity", - "1999-02-29", - "2024-02-29", - ])?) - .to_string(), - ), - 13 => ( - Type::Timestamp { precision: None }, - (*u.choose(&[ - "2000-01-01 00:00:00", - "1999-12-31 23:59:59.999999", - "294277-01-01 00:00:00", - "0001-01-01 00:00:00 BC", - "infinity", - "2024-02-29 12:34:56+05:30", - ])?) - .to_string(), - ), + Type::Interval { .. } => (*u.choose(&[ + "1 day", + "01:02:03", + "-1 year 2 mons", + "1-2", + "P1Y2M3DT4H5M6S", + "1 day 2:03:04.567", + "@ 5 hours ago", + "100000000 years", + "1.5 days", + ])?) + .to_string(), + // Date / timestamp: valid and edge-of-range forms. + Type::Date => (*u.choose(&[ + "2000-01-01", + "0001-01-01 BC", + "294276-12-31", + "infinity", + "-infinity", + "1999-02-29", + "2024-02-29", + ])?) + .to_string(), + Type::Timestamp { .. } => (*u.choose(&[ + "2000-01-01 00:00:00", + "1999-12-31 23:59:59.999999", + "294277-01-01 00:00:00", + "0001-01-01 00:00:00 BC", + "infinity", + "2024-02-29 12:34:56+05:30", + ])?) + .to_string(), // Uuid: canonical, braced, and hyphen-free spellings. - _ => ( - Type::Uuid, - (*u.choose(&[ - "00000000-0000-0000-0000-000000000000", - "ffffffffffffffffffffffffffffffff", - "{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}", - "A0EEBC999C0B4EF8BB6D6BB9BD380A11", - ])?) - .to_string(), - ), + Type::Uuid => (*u.choose(&[ + "00000000-0000-0000-0000-000000000000", + "ffffffffffffffffffffffffffffffff", + "{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}", + "A0EEBC999C0B4EF8BB6D6BB9BD380A11", + ])?) + .to_string(), + // Name truncates at 64 bytes without splitting a multibyte character, + // so feed runs whose characters straddle that boundary. + Type::Name => match u.int_in_range(0u8..=5)? { + 0 => String::new(), + 1 => "a".to_string(), + 2 => "a".repeat(64), + 3 => "a".repeat(65), + // 66 bytes of 2-byte chars: byte 64 is mid-character. + 4 => "é".repeat(33), + // 68 bytes of 4-byte chars: byte 64 ends a character, byte 65 does + // not. + _ => "😀".repeat(17), + }, + // mz_timestamp parses as a u64 of milliseconds and otherwise falls back + // to the timestamptz parser, whose result must fit a u64. + Type::MzTimestamp => (*u.choose(&[ + "0", + "18446744073709551615", + "18446744073709551616", + "-1", + " 42 ", + "2000-01-01 00:00:00", + "1969-12-31 23:59:59+00", + ])?) + .to_string(), + // mz_aclitem is `grantee=privileges/grantor` over `RoleId`s. + Type::MzAclItem => (*u.choose(&[ + "u1=UC/u2", + "=arwd/s1", + "s1=RBNP/g1", + "u1=/u2", + "u1=X/u2", + "=/", + "u1", + "u=U/u1", + "p=U/p", + "u18446744073709551616=U/u1", + "é1=U/u2", + "u1=U/u2/u3", + ])?) + .to_string(), + // aclitem is the same shape over role OIDs. + Type::AclItem => (*u.choose(&[ + "1=UC/2", + "=arwd/0", + "1=/2", + "1=Z/2", + "=/", + "1", + "99999999999=U/2", + "-1=U/2", + ])?) + .to_string(), + // Types the generator does not build. Any short token is a valid + // literal for the text-like ones and a syntax probe for the rest. + _ => (*u.choose(&["", "abc", "0", " x "])?).to_string(), }) } @@ -144,7 +289,11 @@ fn gen_numeric_literal(u: &mut Unstructured) -> arbitrary::Result { 0 => "0".to_string(), 1 => u.arbitrary::()?.to_string(), 2 => format!("{}.{}", u.arbitrary::()?, u.arbitrary::()?), - 3 => format!("{}e{}", u.int_in_range(1i32..=9)?, u.int_in_range(-40i32..=40)?), + 3 => format!( + "{}e{}", + u.int_in_range(1i32..=9)?, + u.int_in_range(-40i32..=40)? + ), 4 => "NaN".to_string(), 5 => "-Infinity".to_string(), // Long digit run (more than the 39 significant digits numeric keeps). @@ -185,175 +334,148 @@ fn escape_for_container(u: &mut Unstructured, body: &str) -> arbitrary::Result arbitrary::Result<(Type, String)> { - // At max depth, or randomly, emit a scalar leaf. - if depth == 0 || u.int_in_range(0u8..=2)? == 0 { - return gen_leaf(u); +/// A literal for `ty`: a scalar literal for a leaf, and a container body built +/// for the container's own element type so that nested elements stay well-typed. +/// +/// Recursion is bounded by `ty`, which the caller built with a depth limit. +fn elem_literal(u: &mut Unstructured, ty: &Type) -> arbitrary::Result { + // Occasionally an element literal for an unrelated type, so the + // element-level parse-error paths stay covered. Tested against the high + // bound: `int_in_range` yields its low bound once the input is exhausted, + // and this branch must stay rare even then. Keep it well below the + // per-container element count, or most container bodies end up ill-typed. + if u.int_in_range(0u8..=63)? == 63 { + let ty = gen_leaf_type(u)?; + return leaf_literal(u, &ty); } - Ok(match u.int_in_range(0u8..=3)? { - // Array: `{e1,e2,...}`, possibly multi-dimensional, possibly with NULLs, - // possibly prefixed with the (unsupported, but parsed) dimension syntax. - 0 => { - let (elem_ty, _) = gen_value(u, depth - 1)?; - let n = u.int_in_range(0usize..=4)?; - let mut body = String::new(); - // Occasionally emit the `[lo:hi]=` dimension prefix, which the parser - // recognizes and then rejects as unsupported. - if u.int_in_range(0u8..=7)? == 0 { - body.push_str(&format!("[{}:{}]=", u.int_in_range(-2i32..=2)?, n)); - } - // Optionally wrap in extra braces for a multi-dimensional shape. - let extra_dims = u.int_in_range(0u8..=2)?; - for _ in 0..extra_dims { - body.push('{'); - } - body.push('{'); - for i in 0..n { - if i > 0 { - body.push(','); - } - if u.int_in_range(0u8..=6)? == 0 { - body.push_str(*u.choose(&["NULL", "null", "NuLl"])?); - } else { - let (_, elem) = elem_literal(u, &elem_ty, depth - 1)?; - body.push_str(&escape_for_container(u, &elem)?); - } - } - body.push('}'); - for _ in 0..extra_dims { - body.push('}'); - } - (Type::Array(Box::new(elem_ty)), body) + match ty { + Type::Array(elem_ty) => array_body(u, elem_ty), + Type::List(elem_ty) => list_body(u, elem_ty), + Type::Map { value_type } => map_body(u, value_type), + Type::Range { element_type } => range_body(u, element_type), + _ => leaf_literal(u, ty), + } +} + +/// An array body: `{e1,e2,...}`, possibly multi-dimensional, possibly with +/// NULLs, possibly prefixed with the (unsupported, but parsed) dimension syntax. +fn array_body(u: &mut Unstructured, elem_ty: &Type) -> arbitrary::Result { + let n = u.int_in_range(0usize..=4)?; + let mut body = String::new(); + // Occasionally emit the `[lo:hi]=` dimension prefix, which the parser + // recognizes and then rejects as unsupported. Tested against the high bound + // so that an exhausted input does not turn every array into a body that + // `parse_array` rejects at byte 0. + if u.int_in_range(0u8..=7)? == 7 { + body.push_str(&format!("[{}:{}]=", u.int_in_range(-2i32..=2)?, n)); + } + // Optionally wrap in extra braces for a multi-dimensional shape. + let extra_dims = u.int_in_range(0u8..=2)?; + for _ in 0..extra_dims { + body.push('{'); + } + body.push('{'); + for i in 0..n { + if i > 0 { + body.push(','); } - // List: `{e1,e2,...}`. Nested lists allowed via embedded braces. - 1 => { - let (elem_ty, _) = gen_value(u, depth - 1)?; - let nested_list = matches!(elem_ty, Type::List(_)); - let n = u.int_in_range(0usize..=4)?; - let mut body = String::from("{"); - for i in 0..n { - if i > 0 { - body.push(','); - } - if u.int_in_range(0u8..=6)? == 0 { - body.push_str("NULL"); - } else { - let (_, elem) = elem_literal(u, &elem_ty, depth - 1)?; - // A nested list element keeps its braces. Other elements are - // quoted/escaped. - if nested_list { - body.push_str(&elem); - } else { - body.push_str(&escape_for_container(u, &elem)?); - } - } - } - body.push('}'); - (Type::List(Box::new(elem_ty)), body) + if u.int_in_range(0u8..=6)? == 0 { + body.push_str(*u.choose(&["NULL", "null", "NuLl"])?); + } else { + let elem = elem_literal(u, elem_ty)?; + body.push_str(&escape_for_container(u, &elem)?); } - // Map: `{k1=>v1,k2=>v2,...}` with text keys. - 2 => { - let (val_ty, _) = gen_value(u, depth - 1)?; - let nested_map = matches!(val_ty, Type::Map { .. }); - let n = u.int_in_range(0usize..=4)?; - let mut body = String::from("{"); - for i in 0..n { - if i > 0 { - body.push(','); - } - let key = *u.choose(&["a", "b", "key one", "k\"q", "", "=>"])?; - body.push_str(&escape_for_container(u, key)?); - body.push_str("=>"); - if u.int_in_range(0u8..=6)? == 0 { - body.push_str("NULL"); - } else { - let (_, val) = elem_literal(u, &val_ty, depth - 1)?; - if nested_map { - body.push_str(&val); - } else { - body.push_str(&escape_for_container(u, &val)?); - } - } - } - body.push('}'); - (Type::Map { value_type: Box::new(val_ty) }, body) + } + body.push('}'); + for _ in 0..extra_dims { + body.push('}'); + } + Ok(body) +} + +/// A list body: `{e1,e2,...}`. A nested list keeps its braces bare, which is +/// what `parse_list`'s nested-list mode expects. +fn list_body(u: &mut Unstructured, elem_ty: &Type) -> arbitrary::Result { + let nested_list = matches!(elem_ty, Type::List(_)); + let n = u.int_in_range(0usize..=4)?; + let mut body = String::from("{"); + for i in 0..n { + if i > 0 { + body.push(','); } - // Range: `empty`, `[lo,hi)`, `(,hi]`, `[lo,)`, etc. Range elements must - // be a totally-ordered scalar, so restrict to one of the supported - // domains. - _ => { - let elem_ty = match u.int_in_range(0u8..=4)? { - 0 => Type::Int4, - 1 => Type::Int8, - 2 => Type::Numeric { constraints: None }, - 3 => Type::Date, - _ => Type::Timestamp { precision: None }, - }; - if u.int_in_range(0u8..=5)? == 0 { - return Ok((Type::Range { element_type: Box::new(elem_ty) }, "empty".to_string())); - } - let lo_inc = u.arbitrary::()?; - let hi_inc = u.arbitrary::()?; - let lo = if u.int_in_range(0u8..=2)? == 0 { - String::new() + if u.int_in_range(0u8..=6)? == 0 { + body.push_str("NULL"); + } else { + let elem = elem_literal(u, elem_ty)?; + if nested_list { + body.push_str(&elem); } else { - gen_range_bound(u, &elem_ty)? - }; - let hi = if u.int_in_range(0u8..=2)? == 0 { - String::new() - } else { - gen_range_bound(u, &elem_ty)? - }; - let body = format!( - "{}{},{}{}", - if lo_inc { '[' } else { '(' }, - lo, - hi, - if hi_inc { ']' } else { ')' }, - ); - (Type::Range { element_type: Box::new(elem_ty) }, body) + body.push_str(&escape_for_container(u, &elem)?); + } } - }) + } + body.push('}'); + Ok(body) } -/// Generate a literal for a *specific* element type (so container element types -/// stay consistent), bottoming out via the generic builder for containers. -fn elem_literal(u: &mut Unstructured, ty: &Type, depth: u8) -> arbitrary::Result<(Type, String)> { - match ty { - Type::Bool => Ok((ty.clone(), (*u.choose(&["t", "f", "true", "false"])?).to_string())), - Type::Int2 | Type::Int4 | Type::Int8 | Type::UInt2 | Type::UInt4 | Type::UInt8 - | Type::Oid | Type::Float4 => Ok((ty.clone(), gen_int_literal(u)?)), - Type::Float8 => Ok(( - ty.clone(), - (*u.choose(&["1.5", "-2.25", "inf", "NaN", "0"])?).to_string(), - )), - Type::Numeric { .. } => Ok((ty.clone(), gen_numeric_literal(u)?)), - Type::Date => Ok(( - ty.clone(), - (*u.choose(&["2000-01-01", "1999-12-31", "infinity"])?).to_string(), - )), - Type::Timestamp { .. } => Ok(( - ty.clone(), - (*u.choose(&["2000-01-01 00:00:00", "1999-12-31 23:59:59"])?).to_string(), - )), - Type::Uuid => Ok(( - ty.clone(), - "00000000-0000-0000-0000-000000000000".to_string(), - )), - Type::Interval { .. } => { - Ok((ty.clone(), (*u.choose(&["1 day", "01:02:03", "1-2"])?).to_string())) +/// A map body: `{k1=>v1,k2=>v2,...}` with text keys. A nested map keeps its +/// braces bare, which is what `parse_map`'s nested-map mode expects. +fn map_body(u: &mut Unstructured, val_ty: &Type) -> arbitrary::Result { + let nested_map = matches!(val_ty, Type::Map { .. }); + let n = u.int_in_range(0usize..=4)?; + let mut body = String::from("{"); + for i in 0..n { + if i > 0 { + body.push(','); } - // Containers and everything else: delegate to the recursive builder, - // which will pick its own (possibly different) shape but keep it valid. - _ => gen_value(u, depth), + let key = *u.choose(&["a", "b", "key one", "k\"q", "", "=>"])?; + body.push_str(&escape_for_container(u, key)?); + body.push_str("=>"); + if u.int_in_range(0u8..=6)? == 0 { + body.push_str("NULL"); + } else { + let val = elem_literal(u, val_ty)?; + if nested_map { + body.push_str(&val); + } else { + body.push_str(&escape_for_container(u, &val)?); + } + } + } + body.push('}'); + Ok(body) +} + +/// A range body: `empty`, `[lo,hi)`, `(,hi]`, `[lo,)`, etc. +fn range_body(u: &mut Unstructured, elem_ty: &Type) -> arbitrary::Result { + if u.int_in_range(0u8..=5)? == 0 { + return Ok("empty".to_string()); } + let lo_inc = u.arbitrary::()?; + let hi_inc = u.arbitrary::()?; + let lo = if u.int_in_range(0u8..=2)? == 0 { + String::new() + } else { + gen_range_bound(u, elem_ty)? + }; + let hi = if u.int_in_range(0u8..=2)? == 0 { + String::new() + } else { + gen_range_bound(u, elem_ty)? + }; + Ok(format!( + "{}{},{}{}", + if lo_inc { '[' } else { '(' }, + lo, + hi, + if hi_inc { ']' } else { ')' }, + )) } -/// A scalar range-bound literal matching the range's element type. +/// A scalar range-bound literal matching the range's element type. Narrower than +/// [`leaf_literal`] on purpose: the bounds have to be comparable for the +/// normalization check to do anything. fn gen_range_bound(u: &mut Unstructured, ty: &Type) -> arbitrary::Result { Ok(match ty { Type::Date => (*u.choose(&["2000-01-01", "1999-12-31", "2024-06-06"])?).to_string(), @@ -367,19 +489,21 @@ fn gen_range_bound(u: &mut Unstructured, ty: &Type) -> arbitrary::Result } fn run(mut u: Unstructured) -> arbitrary::Result<()> { - // A quarter of the time, the raw mode: any OID + raw remaining bytes. - // This keeps the not-implemented branches (json, record, timetz, - // int2vector) and the scalar syntax-error paths covered. + // A quarter of the time, the raw mode: a known OID + raw remaining bytes. + // This keeps the not-implemented branches and the scalar syntax-error paths + // covered, including the types the typed mode never builds. if u.int_in_range(0u8..=3)? == 0 { - let oid = u32::from(u.arbitrary::()?); + let oid = *u.choose(KNOWN_OIDS.as_slice())?; let rest = u.take_rest(); + // `KNOWN_OIDS` holds only OIDs that resolve, so this always matches. if let Ok(ty) = Type::from_oid(oid) { let _ = Value::decode_text(&ty, rest); } return Ok(()); } - let (ty, body) = gen_value(&mut u, 3)?; + let ty = gen_type(&mut u, 3)?; + let body = elem_literal(&mut u, &ty)?; let _ = Value::decode_text(&ty, body.as_bytes()); Ok(()) } diff --git a/src/pgtz/fuzz/fuzz_targets/timezone_parse.rs b/src/pgtz/fuzz/fuzz_targets/timezone_parse.rs index 6eaeb83420a34..5cccf7875783c 100644 --- a/src/pgtz/fuzz/fuzz_targets/timezone_parse.rs +++ b/src/pgtz/fuzz/fuzz_targets/timezone_parse.rs @@ -12,21 +12,33 @@ //! tokenizer + offset builder, in both ISO and POSIX modes. Any panic is an //! availability bug. //! -//! The interesting surface is the *offset tokenizer*: `tokenize_timezone` -//! grabs the first alphabetic run as a single `TzName` and returns immediately -//! (so any POSIX DST-rule tail is silently discarded, making fuzzing that -//! grammar dead weight), while everything else flows through `parse_num`, which -//! splits long all-digit runs into `[..hhhh]mm` chunks unless a `:` is present, -//! plus the punctuation-as-delimiter trimming and the `z`/`Z`-only-at-end rule. -//! `build_timezone_offset_second` then matches the token stream against twelve -//! fixed `±H[H][:M[M][:S[S]]]` / `±HHH` / `TzName` / `Zulu` shapes and enforces -//! the `hour<=15`, `min<60`, `sec<60` bounds. So we generate inputs that stress -//! exactly that math: long all-digit runs (`+00000100`, `+0000001:000001`), -//! the hour/min/sec boundaries (`+15:59:59`, `+16`, `+0:60`), the colon-vs-no- -//! colon `split_nums` toggle, punctuation-delimited junk around a real offset, -//! bare `z`/`Z` placed mid-string vs at the end, abbreviations drawn from -//! `TIMEZONE_ABBREVS`, and case-mangled IANA names. A quarter of inputs stay -//! the raw bytes so the tokenizer reject paths keep their coverage. +//! The interesting surface is the *offset tokenizer*. On the first ASCII +//! alphabetic character `tokenize_timezone` pushes the entire remainder of the +//! string as a single `TzName` and returns, so everything after a letter is +//! folded into the name rather than tokenized. Two consequences shape this +//! generator: fuzzing the POSIX DST-rule grammar is dead weight, and only +//! inputs whose letters come *last* get both halves of the string tokenized. +//! Everything else flows through `parse_num`, which splits long all-digit runs +//! into `[..hhhh]mm` chunks unless a `:` is present, plus the +//! punctuation-as-`Delim` handling and the `z`/`Z`-only-at-end rule. +//! `build_timezone_offset_second` then matches the token stream against a table +//! of `±H[H][:M[M][:S[S]]]` / `±HH H` / `TzName` / `Zulu` shapes and enforces +//! the `hour<=15`, `min<60`, `sec<60` bounds. +//! +//! So we generate inputs that stress exactly that math: long all-digit runs +//! (`+00000100`, `+0000001:000001`), runs long enough to overflow the `u64` +//! parse, the hour/min/sec boundaries (`+15:59:59`, `+16`, `+0:60`), the +//! colon-vs-no-colon `split_nums` toggle, *interior* punctuation, a `z`/`Z` +//! after digits, abbreviations from `TIMEZONE_ABBREVS` placed after an offset +//! so both halves tokenize, and case-mangled IANA names. A quarter of inputs +//! stay the raw bytes so the tokenizer reject paths keep their coverage. +//! +//! NOTE: two of the twelve entries in that format table, `[±, Num, Num, Num]`, +//! are unreachable. `parse_num` is the only thing that pushes `Num` and it +//! pushes at most two per digit run, while every other tokenizer arm pushes a +//! separator, `Zulu`, or `TzName` in between, so no input yields three +//! consecutive `Num` tokens. The widest all-digit offset (`+00000100`) matches +//! the three-token `[Plus, Num, Num]` shape instead. #![no_main] @@ -36,38 +48,49 @@ use mz_pgtz::timezone::{Timezone, TimezoneSpec}; /// IANA names exercising fractional-hour offsets and DST, in canonical casing. /// `gen_named` may re-case them to hit the case-insensitive lookup path. +/// `posixrules` is not a chrono-tz zone, so it covers the reject path. const NAMED: &[&str] = &[ "UTC", "GMT", "America/New_York", "Europe/London", - "Asia/Kolkata", // :30 offset - "Australia/Lord_Howe", // :30 offset with DST - "Pacific/Chatham", // :45 offset + "Asia/Kolkata", // :30 offset + "Australia/Lord_Howe", // :30 offset with DST + "Pacific/Chatham", // :45 offset "America/Argentina/Buenos_Aires", "Etc/GMT+12", "posixrules", ]; /// A spread of abbreviations from `TIMEZONE_ABBREVS`: fixed-offset ones, DST -/// ones, and ones that alias to a `Tz`, so the abbrev lookup + fallback to -/// `Tz::from_str_insensitive` both run. `EST`/`PST`/... also double as the -/// leading `std` name of a POSIX-looking string (whose offset tail is what the -/// tokenizer actually keeps). +/// ones, and ones that alias to a `Tz`, so the abbrev lookup and its fallback +/// to `Tz::from_str_insensitive` both run. `WEST` is the one entry absent from +/// `src/pgtz/tznames/Default`, so it drives the miss-then-fallback-then-reject +/// path. const ABBREVS: &[&str] = &[ - "EST", "EDT", "PST", "PDT", "CST", "CDT", "MST", "MDT", "CET", "CEST", "EET", - "EEST", "BST", "IST", "JST", "ACDT", "ACST", "AEST", "AEDT", "NZST", "NZDT", - "CHADT", "CHAST", "HKT", "WET", "WEST", "UCT", "ZULU", "GMT", "UTC", + "EST", "EDT", "PST", "PDT", "CST", "CDT", "MST", "MDT", "CET", "CEST", "EET", "EEST", "BST", + "IST", "JST", "ACDT", "ACST", "AEST", "AEDT", "NZST", "NZDT", "CHADT", "CHAST", "HKT", "WET", + "WEST", "UCT", "ZULU", "GMT", "UTC", ]; +/// ASCII whitespace and punctuation, which the tokenizer trims at the edges of +/// the string and turns into a `Delim` in the interior. Deliberately excludes +/// `+`/`-`, the two characters the trimming closure spares. +const JUNK: &[char] = &[' ', '!', '?', '.', ',', '*', '/', '#', '~', '\t']; + /// Emit a numeric UTC offset, biased toward the tokenizer/builder boundaries: /// `z`/`Z`, `±HH`, `±HH:MM`, `±HH:MM:SS`, long all-digit runs that `parse_num` -/// must chunk, and the exact `hour<=15` / `min<60` / `sec<60` edges. +/// must chunk or fail to parse, and the exact `hour<=15` / `min<60` / `sec<60` +/// edges. +/// +/// Every shape but the bare `z`/`Z` ends in a digit. Callers that append more +/// text must check for that, since a trailing letter would make the tokenizer +/// fold the whole string into one `TzName`. fn gen_offset(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { - match u.int_in_range(0u8..=8)? { + match u.int_in_range(0u8..=9)? { // Bare Zulu (only valid at end-of-string). 0 => { - out.push(if u.ratio(1, 2)? { 'z' } else { 'Z' }); + out.push(zulu(u)?); return Ok(()); } // Hour at/around the `<= 15` boundary. @@ -104,11 +127,12 @@ fn gen_offset(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { } out.push_str(&u.int_in_range(0u32..=999)?.to_string()); } - // Colon-delimited long all-digit runs (colon disables `split_nums`), - // e.g. `+0000001:000001:000001`. + // Colon-delimited long all-digit runs (a colon disables `split_nums`), + // e.g. `+0000001:000001:000001`. At least two parts: a single part emits + // no colon at all and degenerates into the arm above. 5 => { out.push(sign(u)?); - let parts = u.int_in_range(1u8..=3)?; + let parts = u.int_in_range(2u8..=3)?; for p in 0..parts { if p > 0 { out.push(':'); @@ -120,6 +144,22 @@ fn gen_offset(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { out.push_str(&u.int_in_range(0u32..=99)?.to_string()); } } + // A digit run long enough to overflow the `u64` parse in `parse_num`. + // The run must start nonzero: leading zeros accumulate to zero and never + // overflow, however long the padding. `parse_num` parses the whole run + // when a colon is present (`split_nums` off, overflowing past 20 digits) + // and the run minus its last two digits otherwise, so this range + // straddles both thresholds. + 6 => { + out.push(sign(u)?); + let digits = u.int_in_range(18u32..=24)?; + for _ in 0..digits { + out.push(*u.choose(&['1', '8', '9'])?); + } + if u.ratio(1, 2)? { + out.push_str(":00"); + } + } // Ordinary `±HH[:MM[:SS]]` across the full valid range. _ => { out.push(sign(u)?); @@ -142,6 +182,10 @@ fn sign(u: &mut Unstructured) -> arbitrary::Result { Ok(if u.ratio(1, 2)? { '+' } else { '-' }) } +fn zulu(u: &mut Unstructured) -> arbitrary::Result { + Ok(if u.ratio(1, 2)? { 'z' } else { 'Z' }) +} + /// Emit an IANA name, sometimes case-mangled to hit `from_str_insensitive`. fn gen_named(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { let name = *u.choose(NAMED)?; @@ -163,40 +207,77 @@ fn gen_named(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { Ok(()) } -/// Wrap an inner spec in leading/trailing whitespace and ASCII punctuation, -/// which the tokenizer trims (except `+`/`-`) or treats as `Delim`. This keeps -/// the `" ! ? ! - 5:15 ? ! ? "`-style paths covered. -fn gen_punct_wrapped(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { - const JUNK: &[char] = &[' ', '!', '?', '.', ',', '*', '/', '#', '~', '\t']; - let lead = u.int_in_range(0u8..=3)?; - for _ in 0..lead { +/// Emit an offset whose components are separated by *interior* punctuation, +/// e.g. `+05!30` or `-12.30`. +/// +/// Interior placement is what makes this arm distinct: the tokenizer trims +/// leading and trailing whitespace and punctuation, so an offset merely +/// *bracketed* in junk is byte-identical to the bare offset by the time it is +/// tokenized, and yields no `Delim` at all. Empty components put two separators +/// back to back, producing the odd token streams that match no format at all +/// (`-5::15` gives `[Dash, Num, Colon, Colon, Num]`), which drives the +/// mismatch/reset arm of `build_timezone_offset_second`. +fn gen_punct_delimited(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { + // Leading junk is trimmed away, so keep it rare, just enough to cover the + // trimming closure itself. + if u.ratio(1, 4)? { out.push(*u.choose(JUNK)?); } - gen_offset(u, out)?; - let trail = u.int_in_range(0u8..=3)?; - for _ in 0..trail { - out.push(*u.choose(JUNK)?); + out.push(sign(u)?); + let parts = u.int_in_range(2u8..=4)?; + for p in 0..parts { + if p > 0 { + // `:` keeps `split_nums` disabled, JUNK yields a `Delim`. + if u.ratio(1, 3)? { + out.push(':'); + } else { + out.push(*u.choose(JUNK)?); + } + } + if u.ratio(7, 8)? { + out.push_str(&u.int_in_range(0u32..=99)?.to_string()); + } } Ok(()) } fn gen_tz(u: &mut Unstructured, out: &mut String) -> arbitrary::Result<()> { - match u.int_in_range(0u8..=6)? { + match u.int_in_range(0u8..=9)? { 0 => gen_named(u, out)?, 1 => out.push_str(u.choose(ABBREVS)?), - 2 | 3 => gen_offset(u, out)?, - 4 => gen_punct_wrapped(u, out)?, - // An abbreviation immediately followed by an offset: the tokenizer keeps - // the abbrev as a `TzName` and returns, so the offset tail is ignored, - // but this still stresses the "first alpha wins" early return. - 5 => { - out.push_str(u.choose(ABBREVS)?); + 2 | 3 | 4 => { gen_offset(u, out)?; + // A `z`/`Z` after digits is the only shape that reaches the + // `parse_num` call in the tokenizer's Zulu arm with digits pending, + // e.g. `+05:30z` -> `[Plus, Num, Colon, Num, Zulu]`. Appending it to + // a bare `z` offset would instead just build a two-letter `TzName`. + if out.ends_with(|c: char| c.is_ascii_digit()) && u.ratio(1, 4)? { + out.push(zulu(u)?); + } } - // A bare `z`/`Z` placed *before* more text, so it is NOT at end-of-string - // and must be tokenized as a `TzName`, not `Zulu`. + 5 | 6 => gen_punct_delimited(u, out)?, + // An offset, a delimiter, then an abbreviation, e.g. `+05:30 EST` -> + // `[Plus, Num, Colon, Num, Delim, TzName("EST")]`. The letters have to + // come last for both halves to tokenize, and this reaches a `Delim`, a + // short `TzName`, and a six-token stream in one input. + 7 | 8 => { + gen_offset(u, out)?; + if out.ends_with(|c: char| c.is_ascii_digit()) { + out.push(*u.choose(JUNK)?); + out.push_str(u.choose(ABBREVS)?); + } + } + // Letters first, so the alphabetic branch takes the whole remaining + // string and the numeric tail is never tokenized: the input collapses + // into one long `TzName` that misses both the abbrev table and + // `Tz::from_str_insensitive`. Low weight, because its only marginal + // coverage over a bare abbreviation is a longer lookup miss. _ => { - out.push(if u.ratio(1, 2)? { 'z' } else { 'Z' }); + if u.ratio(1, 2)? { + out.push_str(u.choose(ABBREVS)?); + } else { + out.push(zulu(u)?); + } gen_offset(u, out)?; } } diff --git a/src/pgwire/fuzz/corpus.dict b/src/pgwire/fuzz/corpus.dict index 81586cb1f734a..cd3931c39a94d 100644 --- a/src/pgwire/fuzz/corpus.dict +++ b/src/pgwire/fuzz/corpus.dict @@ -4,6 +4,11 @@ # (startup/SSL/cancel messages omit the tag). Seeding the type tags and the # special startup/SSL/cancel magic version codes lets the mutator reach the # per-message decoders instead of being rejected as an unknown tag. +# +# These are raw wire bytes, so they pay off on the target's raw-bytes branch +# (the one the seeds in prepare-corpus.sh steer onto). On the grammar branch the +# input is consumed as generation choices rather than wire bytes, and a token +# there is just entropy. # Frontend message type tags. "Q" diff --git a/src/pgwire/fuzz/fuzz_targets/codec_decode.rs b/src/pgwire/fuzz/fuzz_targets/codec_decode.rs index 7947ff0cecd1f..1bff0ed0fa7ff 100644 --- a/src/pgwire/fuzz/fuzz_targets/codec_decode.rs +++ b/src/pgwire/fuzz/fuzz_targets/codec_decode.rs @@ -15,15 +15,14 @@ //! A frame is `[type:1][len:4 BE][body:len-4]`. Random bytes rarely have a //! length field that matches the bytes that follow, so the decoder bails in the //! header before reaching the per-message body parsers (Query/Parse/Bind/ -//! Describe/Execute/…), and once one frame errors, the streaming decoder stops, -//! so later frames never decode either. So we consume the byte stream as grammar -//! choices and emit correctly-framed messages: a valid type tag, the right -//! length, and (usually) a valid body for that type, concatenating several so -//! the decoder walks frame after frame. A quarter of inputs are still the raw -//! bytes, and a quarter of frames carry an arbitrary body, so the header -//! validation and per-message error paths stay covered. +//! Describe/Execute/…). So we consume the byte stream as grammar choices and emit +//! correctly-framed messages: a valid type tag, the right length, and (usually) a +//! valid body for that type, concatenating several so the decoder walks frame +//! after frame. A quarter of inputs are still the raw bytes, and a quarter of +//! frames carry an arbitrary body, so the header validation and per-message error +//! paths stay covered. //! -//! Beyond well-formed frames we deliberately stress two thin spots: +//! Beyond well-formed frames we deliberately stress three thin spots: //! //! * **Count-driven loops.** The body parsers for Parse and Bind read an `i16` //! element count (param-type / format-code / parameter counts) and then loop @@ -31,8 +30,8 @@ //! own `i32` byte length. We sometimes emit a huge count or length (up to //! `i16::MAX` / a large positive `i32`) backed by a body far too short to //! satisfy it, so the loops read off the end of the cursor and must error out -//! gracefully rather than over-read, over-allocate, or panic. Long cstrings -//! feed the same idea on the string side. +//! gracefully rather than over-read or panic. Long cstrings feed the same idea +//! on the string side. //! //! * **Streaming / partial-frame reassembly.** The codec is a `tokio_util` //! `Decoder`: it advances `Head -> Data -> Head` across calls and returns @@ -43,15 +42,32 @@ //! chunks, so it parks in the `Data` await-more-bytes state and resumes when //! the rest shows up. //! +//! * **The pre-auth SASL/password grammars.** `Codec::decode` does not parse +//! auth payloads. Its `b'p'` arm copies the body verbatim into +//! `RawAuthentication`, and `protocol` later picks a parser based on where the +//! handshake is. Those parsers are hand-rolled, byte-at-a-time, and run before +//! the client has authenticated, so `feed_and_drain` runs all three on every +//! payload and `gen_auth_body` generates the shapes they accept. +//! //! Errors are expected. What we assert is the absence of panics and //! memory-safety violations. +//! +//! Note that allocation amplification is *not* in scope. The only speculative +//! `reserve` is on the declared frame length, which `parse_frame_len` caps at +//! `MAX_FRAME_SIZE` (64 MiB), far under the runner's `-rss_limit_mb`. The +//! count-driven loops push one element per successful cursor read, so they are +//! bounded by the bytes actually present. No oracle here can catch an +//! over-allocation, so don't read one into the target. #![no_main] use bytes::BytesMut; use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_pgwire::fuzz_exports::Codec; +use mz_pgwire::fuzz_exports::{ + Codec, Cursor, FrontendMessage, decode_password, decode_sasl_initial_response, + decode_sasl_response, +}; use tokio_util::codec::Decoder; /// Frontend message type tags the codec dispatches on. @@ -118,8 +134,8 @@ fn gen_body(u: &mut Unstructured, tag: u8, out: &mut Vec) -> arbitrary::Resu b'X' | b'S' | b'H' | b'c' => {} // Simple query / copy-fail: a single cstring. b'Q' | b'f' => maybe_long_cstr(u, out)?, - // Password / generic auth: a cstring is a plausible password message. - b'p' => maybe_long_cstr(u, out)?, + // Auth: shapes for the sub-parsers `feed_and_drain` runs on the payload. + b'p' => gen_auth_body(u, out)?, // CopyData: arbitrary payload. b'd' => { for _ in 0..u.int_in_range(0usize..=16)? { @@ -128,7 +144,11 @@ fn gen_body(u: &mut Unstructured, tag: u8, out: &mut Vec) -> arbitrary::Resu } // Describe / Close: a 'S'tatement|'P'ortal byte then a name cstring. b'D' | b'C' => { - out.push(if u.int_in_range(0u8..=1)? == 0 { b'S' } else { b'P' }); + out.push(if u.int_in_range(0u8..=1)? == 0 { + b'S' + } else { + b'P' + }); maybe_long_cstr(u, out)?; } // Execute: portal cstring + max-rows i32. @@ -188,6 +208,101 @@ fn gen_body(u: &mut Unstructured, tag: u8, out: &mut Vec) -> arbitrary::Resu Ok(()) } +/// Append a comma-free printable run. Every SASL field is comma-delimited and +/// goes through `String::from_utf8`, so restricting tokens to `0x2d..=0x7e` +/// (printable ASCII above `,`) is what lets the parser advance past a field +/// instead of stopping short or erroring on invalid UTF-8. +fn push_token(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> { + for _ in 0..u.int_in_range(0usize..=8)? { + out.push(u.int_in_range(0x2du8..=0x7e)?); + } + Ok(()) +} + +/// Build an auth-message payload targeting the three sub-parsers: a cleartext +/// password, a SASL initial response, or a SASL client-final message. +fn gen_auth_body(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> { + match u.int_in_range(0u8..=2)? { + // `decode_password` reads a single cstring. + 0 => maybe_long_cstr(u, out)?, + // `decode_sasl_initial_response`: mechanism cstring, a declared response + // length it only rejects when negative, then a client-first-message + // parsed out of whatever is left. + 1 => { + match u.int_in_range(0u8..=2)? { + 0 => out.extend_from_slice(b"SCRAM-SHA-256\0"), + 1 => out.extend_from_slice(b"SCRAM-SHA-256-PLUS\0"), + _ => push_cstr(u, out)?, + } + // The parser rejects a negative declared length and then ignores the + // value entirely, parsing whatever follows regardless. Mostly + // declare a non-negative one so the client-first grammar is reached, + // occasionally go negative to cover the rejection. + let declared = if u.int_in_range(0u8..=7)? == 0 { + u.int_in_range(i32::MIN..=-1)? + } else { + u.int_in_range(0i32..=i32::MAX)? + }; + be32(out, declared); + gen_sasl_client_first(u, out)?; + } + _ => gen_sasl_client_final(u, out)?, + } + Ok(()) +} + +/// A SCRAM `client-first-message` (RFC 5802): `gs2-cbind-flag "," [authzid] "," +/// ["m=" mext ","] "n=" user "," "r=" nonce ["," ext]*`. Kept well-formed +/// because the parser aborts on the first unexpected byte, so an approximation +/// of the grammar would never reach the later fields. +fn gen_sasl_client_first(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> { + match u.int_in_range(0u8..=2)? { + 0 => out.push(b'n'), + 1 => out.push(b'y'), + // Channel binding required: "p=" carries the channel name. + _ => { + out.extend_from_slice(b"p="); + push_token(u, out)?; + } + } + out.push(b','); + if u.int_in_range(0u8..=1)? == 0 { + out.extend_from_slice(b"a="); + push_token(u, out)?; + } + out.push(b','); + if u.int_in_range(0u8..=3)? == 0 { + out.extend_from_slice(b"m="); + push_token(u, out)?; + out.push(b','); + } + out.extend_from_slice(b"n="); + push_token(u, out)?; + out.extend_from_slice(b",r="); + push_token(u, out)?; + for _ in 0..u.int_in_range(0usize..=2)? { + out.push(b','); + push_token(u, out)?; + } + Ok(()) +} + +/// A SCRAM `client-final-message` (RFC 5802): `"c=" cbind "," "r=" nonce +/// ["," ext]* "," "p=" proof`. The proof is mandatory and last. +fn gen_sasl_client_final(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> { + out.extend_from_slice(b"c="); + push_token(u, out)?; + out.extend_from_slice(b",r="); + push_token(u, out)?; + for _ in 0..u.int_in_range(0usize..=2)? { + out.push(b','); + push_token(u, out)?; + } + out.extend_from_slice(b",p="); + push_token(u, out)?; + Ok(()) +} + /// A cstring that is usually short but occasionally long, to stress the scan /// and downstream string allocations. fn maybe_long_cstr(u: &mut Unstructured, out: &mut Vec) -> arbitrary::Result<()> { @@ -239,20 +354,57 @@ fn pump(u: &mut Unstructured, data: &[u8], chunked: bool) -> arbitrary::Result<( let mut feed_and_drain = |buf: &mut BytesMut| { // The codec is a streaming decoder, so pump it until it stops returning - // complete messages or errors out. Errors are expected. What we care - // about is the absence of panics and memory-safety violations. + // complete messages or runs out of forward progress. Errors are + // expected. What we care about is the absence of panics and + // memory-safety violations. loop { + let before = buf.len(); match codec.decode(buf) { - Ok(Some(_msg)) => continue, + Ok(Some(msg)) => { + if std::env::var_os("MZ_FUZZ_TRACE").is_some() { + eprintln!("TRACE decoded {}", msg.name()); + if let FrontendMessage::RawAuthentication(d) = &msg { + eprintln!( + "TRACE password={:?} sasl_init={:?} sasl_resp={:?}", + decode_password(Cursor::new(d)).is_ok(), + decode_sasl_initial_response(Cursor::new(d)).is_ok(), + decode_sasl_response(Cursor::new(d)).is_ok(), + ); + } + } + // `decode_auth` copies the payload verbatim without parsing + // it. The real parsers run in `protocol`, which picks one by + // handshake state: the two SASL parsers during SCRAM, + // `decode_password` for cleartext. This target has no + // connection state, so run all three on every payload. That + // is a superset of what a single connection reaches, but each + // is reachable pre-auth, so a panic in any of them is a real + // pre-auth availability bug. + if let FrontendMessage::RawAuthentication(data) = msg { + let _ = decode_password(Cursor::new(&data)); + let _ = decode_sasl_initial_response(Cursor::new(&data)); + let _ = decode_sasl_response(Cursor::new(&data)); + } + continue; + } Ok(None) => break, - // NOTE: this breaks out of the drain but does not latch the - // error, so in chunked mode the outer loop keeps feeding the - // same codec afterwards. Production `FramedConn` instead tears - // the connection down on the first error, leaving `decode_state` - // mid-frame. Continuing is intentional and harmless here: frame - // body parsing is stateless per frame, so anything reachable - // after an error is also reachable from a fresh stream. - Err(_) => break, + Err(_) => { + // Production `FramedConn` tears the connection down on the + // first error, so nothing ever resumes mid-frame. Match that + // by starting over rather than leaving `decode_state` stuck + // in `Data(stale_tag, stale_len)`, which would shred every + // later frame at the stale length and re-parse it under the + // stale tag instead of its own. + codec = Codec::new(); + // A body-parse error has already split the frame off, so the + // buffer shrank and the next frame is aligned. A header + // rejection (`parse_frame_len`, or the aggregate size cap) + // consumes nothing, so continuing would spin on the same + // bytes forever. + if buf.len() == before { + break; + } + } } } }; @@ -276,7 +428,16 @@ fn pump(u: &mut Unstructured, data: &[u8], chunked: bool) -> arbitrary::Result<( fn run(mut u: Unstructured) -> arbitrary::Result<()> { // A quarter of the time, the raw bytes: keeps the header-framing and - // unknown-tag error paths covered. + // unknown-tag error paths covered, and is the only path on which a + // hand-written wire capture reaches the decoder as written. + // + // NOTE: `int_in_range` consumes from the *front*, one byte per decision, so + // this prefix is a wire format the corpus depends on: `data[0] % 4 == 0` + // selects this branch, `data[1] % 2 == 0` selects chunked, and `data[2..]` is + // what the decoder sees byte for byte. `prepare-corpus.sh` prepends + // `\x00\x00` to every seed to land here. Reordering these two decisions, or + // adding a third ahead of them, silently repurposes every seed's leading + // bytes and strands the corpus. if u.int_in_range(0u8..=3)? == 0 { let chunked = u.int_in_range(0u8..=1)? == 0; let rest = u.take_rest(); diff --git a/src/pgwire/fuzz/prepare-corpus.sh b/src/pgwire/fuzz/prepare-corpus.sh index 3f7ad984df7ae..642938400b042 100755 --- a/src/pgwire/fuzz/prepare-corpus.sh +++ b/src/pgwire/fuzz/prepare-corpus.sh @@ -18,6 +18,14 @@ # frontend messages. The startup handshake (StartupMessage, SSLRequest, # CancelRequest) is parsed by a separate `decode_startup` path that this # target does not exercise, so no startup-phase seeds are included. +# +# Every seed carries the two-byte control prefix the target reads off the front +# of the input before anything else (see `run` in codec_decode.rs): the first +# byte selects the raw-bytes branch, the second selects chunked feeding, and only +# the bytes after them reach the decoder. Without the prefix a seed's own type +# tag is eaten as a control byte, the decoder reads the length field's second +# byte as the message tag, and the frame parks in the await-more-bytes state +# without ever reaching the body parser the seed is named after. set -euo pipefail @@ -31,14 +39,26 @@ python3 - "$corpus" <<'PY' import os, struct, sys corpus = sys.argv[1] +# Steers `run` onto the raw-bytes branch, feeding the rest of the seed to the +# decoder verbatim: `data[0] % 4 == 0` picks raw, `data[1] % 2 == 0` picks +# chunked. +CONTROL_PREFIX = b"\x00\x00" + def frame(tag: bytes, payload: bytes) -> bytes: # Standard pgwire frame: 1-byte type tag + 4-byte BE length (incl # itself) + payload. - return tag + struct.pack(">I", 4 + len(payload)) + payload + return CONTROL_PREFIX + tag + struct.pack(">I", 4 + len(payload)) + payload def cstr(s: str) -> bytes: return s.encode() + b"\x00" +def sasl_initial(mechanism: str, client_first: str) -> bytes: + # `decode_sasl_initial_response` reads the mechanism cstring, then a declared + # response length it only rejects when negative, then parses the rest as a + # client-first-message. + resp = client_first.encode() + return cstr(mechanism) + struct.pack(">I", len(resp)) + resp + seeds = { "01_query_select_1": frame(b"Q", cstr("SELECT 1")), "02_query_empty": frame(b"Q", cstr("")), @@ -55,7 +75,15 @@ seeds = { "13_copy_done": frame(b"c", b""), "14_copy_fail": frame(b"f", cstr("client gave up")), "15_password": frame(b"p", cstr("hunter2")), - "16_sasl_initial": frame(b"p", cstr("SCRAM-SHA-256") + struct.pack(">I", 5) + b"hello"), + # The auth seeds carry payloads that parse, so the sub-parsers the target + # runs on `RawAuthentication` get real starting coverage. A SCRAM message + # is comma-delimited and the parsers abort on the first unexpected byte, so + # an approximate payload would never reach the later fields. + "16_sasl_initial": frame(b"p", sasl_initial("SCRAM-SHA-256", "n,,n=user,r=rOprNGfwEbeRWgbNEkqO")), + "17_sasl_initial_cbind": frame( + b"p", sasl_initial("SCRAM-SHA-256-PLUS", "p=tls-server-end-point,a=admin,n=user,r=rOprNGfwEbeRWgbNEkqO") + ), + "18_sasl_response": frame(b"p", b"c=biws,r=rOprNGfwEbeRWgbNEkqO,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="), } for name, blob in seeds.items(): diff --git a/src/pgwire/src/lib.rs b/src/pgwire/src/lib.rs index 15085faa26e10..c4755b1bbabc4 100644 --- a/src/pgwire/src/lib.rs +++ b/src/pgwire/src/lib.rs @@ -37,7 +37,17 @@ pub use server::{Config, Server}; /// Internal types re-exported under `cfg(feature = "fuzzing")` so the fuzz /// crate can drive the frontend-message decoder directly. Not for /// production use. +/// +/// The auth sub-parsers are included because `Codec::decode` does not call +/// them: its `b'p'` arm copies the payload verbatim into +/// `FrontendMessage::RawAuthentication`, and `protocol` picks the parser from +/// the handshake state. Without these, the pre-auth SASL/password grammars are +/// unreachable from the decoder alone. #[cfg(feature = "fuzzing")] pub mod fuzz_exports { - pub use crate::codec::Codec; + pub use mz_pgwire_common::{Cursor, FrontendMessage}; + + pub use crate::codec::{ + Codec, decode_password, decode_sasl_initial_response, decode_sasl_response, + }; } diff --git a/src/postgres-util/fuzz/fuzz_targets/postgres_table_desc_proto_roundtrip.rs b/src/postgres-util/fuzz/fuzz_targets/postgres_table_desc_proto_roundtrip.rs index 9021238101157..bbd77df350360 100644 --- a/src/postgres-util/fuzz/fuzz_targets/postgres_table_desc_proto_roundtrip.rs +++ b/src/postgres-util/fuzz/fuzz_targets/postgres_table_desc_proto_roundtrip.rs @@ -12,28 +12,42 @@ //! here is reachable from a compromised upstream Postgres or on-disk catalog //! bytes. //! -//! The first input byte selects the arm. The rest feeds it: +//! The first input byte selects one of four arms. The rest feeds it: //! //! * **Structured arm.** Drives `PostgresTableDesc`'s proptest `Arbitrary` //! (behind mz-postgres-util's `schemas` feature) from the libFuzzer byte //! stream to synthesize a *valid, fully-populated* value, then asserts the -//! `value -> proto -> value` chain is the identity AND that re-encoding the -//! proto is byte-idempotent. This is what actually reaches the deep shape: -//! several `PostgresColumnDesc`s with arbitrary `col_num`/`type_oid`/ -//! `type_mod`/`nullable`, and a `BTreeSet` of several `PostgresKeyDesc`s, -//! each with a `Vec` of `cols`. Random proto bytes decode to a -//! near-empty desc, so the populated branches never get covered otherwise. +//! `value -> proto -> value` chain is the identity. This is what actually +//! reaches the deep shape: several `PostgresColumnDesc`s with arbitrary +//! `col_num`/`type_oid`/`type_mod`/`nullable`, and a `BTreeSet` of several +//! `PostgresKeyDesc`s, each with a `Vec` of `cols`. Random proto bytes +//! decode to a near-empty desc, so the populated branches never get covered +//! otherwise. +//! * **Narrowing arm.** Hand-builds a proto whose `ProtoPostgresColumnDesc:: +//! col_num` and `ProtoPostgresKeyDesc::cols` sit in a dense neighborhood of +//! the `u16::MAX` boundary, and asserts the `u32 -> u16` narrowing in +//! `from_proto` rejects *exactly* the out-of-range values via `Err` (never a +//! panic, never a silent truncation) and preserves the in-range ones bit for +//! bit. `col_num` is a column's positional identity, matched by equality when +//! purification resolves key columns and when a source checks an upstream +//! schema for compatibility, so a truncating narrowing would silently remap +//! or drop key columns instead of rejecting the input. +//! * **Duplicate-keys arm.** Builds a proto whose repeated `keys` field carries +//! duplicate and unsorted entries, which must collapse into the Rust +//! `BTreeSet` without tripping an ordering assertion and without letting the +//! wire order leak into the decoded value. The key bodies come from the input +//! so the mutator can vary `cols` (including empty) and the flags while the +//! duplicate/unsorted shape stays fixed. //! * **Raw-bytes arm.** Decodes arbitrary bytes straight into the proto then //! `into_rust`, exercising the decoder against malformed/adversarial input, -//! then re-encodes the recovered value. This is where the untrusted-bytes -//! invariants live: the `u32 -> u16` narrowing for -//! `ProtoPostgresColumnDesc::col_num` and `ProtoPostgresKeyDesc::cols` must -//! return `Err`, not panic, and the wire's repeated `keys` field with -//! duplicate / unsorted entries must collapse cleanly into the Rust -//! `BTreeSet` rather than trip an ordering assertion. +//! then re-encodes the recovered value. Random bytes essentially never reach +//! the boundary or duplicate-key shapes, which is why those get their own +//! arms. #![no_main] +use std::collections::BTreeSet; + use libfuzzer_sys::fuzz_target; use mz_postgres_util::desc::{ PostgresTableDesc, ProtoPostgresColumnDesc, ProtoPostgresKeyDesc, ProtoPostgresTableDesc, @@ -52,8 +66,12 @@ fn seed_from(bytes: &[u8]) -> [u8; 32] { seed } -/// Assert the `value -> proto -> value` chain is the identity and that -/// re-encoding the proto is byte-idempotent. +/// Read `bytes[i]`, treating a short input as zero-padded. +fn byte(bytes: &[u8], i: usize) -> u8 { + bytes.get(i).copied().unwrap_or(0) +} + +/// Assert the `value -> proto -> value` chain is the identity. fn assert_roundtrip(orig: PostgresTableDesc) { let proto = >::from_rust(&orig); let bytes = proto.encode_to_vec(); @@ -62,11 +80,16 @@ fn assert_roundtrip(orig: PostgresTableDesc) { let round: PostgresTableDesc = proto2 .into_rust() .expect("re-encoded PostgresTableDesc must convert back to Rust"); - assert_eq!(orig, round, "PostgresTableDesc changed across proto roundtrip"); + assert_eq!( + orig, round, + "PostgresTableDesc changed across proto roundtrip" + ); - // Encoding the recovered value must reproduce the same wire bytes. - let bytes2 = >::from_rust(&round) - .encode_to_vec(); + // Implied by the equality above for as long as every field `into_proto` + // reads takes part in `PartialEq`. Kept as a guard for the day one doesn't, + // not as coverage of a separate invariant. + let bytes2 = + >::from_rust(&round).encode_to_vec(); assert_eq!(bytes, bytes2, "proto re-encode was not idempotent"); } @@ -101,21 +124,25 @@ fuzz_target!(|data: &[u8]| { } // Targeted arm: hand-build a proto whose `col_num` / `cols` values // straddle the u16 boundary, confirming the u32 -> u16 narrowing in - // `from_proto` returns `Err` (not a panic) for the out-of-range cases - // and succeeds for the in-range ones. + // `from_proto` returns `Err` (not a panic, not a truncated value) for + // the out-of-range cases and preserves the in-range ones. 1 => { - // Use the first few bytes as little-endian u32 candidates so the - // fuzzer can search both sides of the 65535 boundary. - let take_u32 = |i: usize| -> u32 { - let mut buf = [0u8; 4]; - let n = rest.len().saturating_sub(i * 4).min(4); - if n > 0 { - buf[..n].copy_from_slice(&rest[i * 4..i * 4 + n]); + // Draw each candidate from a dense neighborhood of the boundary. A + // plain little-endian u32 over 4 input bytes would put the in-range + // side at 2^-16 of executions, and the fuzzer gets no coverage + // gradient toward it, because the success path is already covered by + // the other arms. + let candidate = |lo: usize, sel: usize| -> u32 { + let base = u32::from(u16::from_le_bytes([byte(rest, lo), byte(rest, lo + 1)])); + match byte(rest, sel) % 4 { + 0 => base, + 1 => u32::from(u16::MAX), + 2 => u32::from(u16::MAX) + 1 + base, + _ => u32::MAX - base, } - u32::from_le_bytes(buf) }; - let col_num = take_u32(0); - let key_col = take_u32(1); + let col_num = candidate(0, 2); + let key_col = candidate(3, 5); let proto = ProtoPostgresTableDesc { name: "t".into(), @@ -139,43 +166,91 @@ fuzz_target!(|data: &[u8]| { let bytes = proto.encode_to_vec(); let decoded = ProtoPostgresTableDesc::decode(bytes.as_slice()) .expect("hand-built proto must decode"); - // Whether the narrowing fits, the only requirement is no panic. If - // it converts, the value must round-trip. let converted: Result = decoded.into_rust(); - if let Ok(orig) = converted { - assert_roundtrip(orig); - } + let fits = col_num <= u32::from(u16::MAX) && key_col <= u32::from(u16::MAX); + assert_eq!( + converted.is_ok(), + fits, + "u32 -> u16 narrowing must reject exactly the out-of-range values \ + (col_num={col_num}, key_col={key_col})" + ); + let Ok(orig) = converted else { + return; + }; + // In-range values must survive intact, neither truncated nor clamped. + // The arm builds exactly one column and one key, so this indexing + // holds. + assert_eq!(u32::from(orig.columns[0].col_num), col_num); + assert_eq!( + orig.keys.iter().next().expect("one key").cols, + vec![u16::try_from(key_col).expect("in range")], + ); + assert_roundtrip(orig); } // Targeted arm: a wire proto with duplicate and unsorted `keys`. The // repeated field maps to a Rust `BTreeSet`, which dedups + sorts. This // must collapse cleanly with no ordering/dup assertion firing. 2 => { - let mk = |oid: u32, col: u16| ProtoPostgresKeyDesc { + // Seed the key bodies from the input so the mutator can vary `cols` + // (length 0 to 3, so the empty-key case is reachable), the columns + // they reference, and the flags, while the duplicate/unsorted shape + // stays fixed. + let cols_at = |base: usize| -> Vec { + (0..usize::from(byte(rest, base) % 4)) + .map(|i| u32::from(byte(rest, base + 1 + i))) + .collect() + }; + let mk = |oid: u32, cols: Vec| ProtoPostgresKeyDesc { oid, - name: "k".into(), - cols: vec![u32::from(col)], - is_primary: false, - nulls_not_distinct: false, + name: format!("k{oid}"), + cols, + is_primary: oid & 1 == 0, + nulls_not_distinct: oid & 2 == 0, }; - // Intentionally out of order with a duplicate entry. + let key_a = mk(u32::from(byte(rest, 0)), cols_at(1)); + let key_b = mk(u32::from(byte(rest, 5)), cols_at(6)); + + // Give every column a key references a matching `col_num`. Nothing in + // this arm's purpose needs the desc to be internally inconsistent, + // and the `.expect` below would become a false crash the day + // `from_proto` starts validating what purification already checks + // downstream, that a key's columns exist. + let col_nums: BTreeSet = key_a.cols.iter().chain(&key_b.cols).copied().collect(); + let columns: Vec<_> = col_nums + .iter() + .map(|&col_num| ProtoPostgresColumnDesc { + name: format!("c{col_num}"), + type_oid: 23, + type_mod: -1, + nullable: false, + col_num: Some(col_num), + }) + .collect(); + + // Intentionally out of order with duplicate entries. let proto = ProtoPostgresTableDesc { name: "t".into(), namespace: "n".into(), oid: 7, - columns: vec![ProtoPostgresColumnDesc { - name: "c".into(), - type_oid: 23, - type_mod: -1, - nullable: false, - col_num: Some(1), - }], - keys: vec![mk(3, 2), mk(1, 9), mk(3, 2), mk(2, 0)], + columns, + keys: vec![key_b.clone(), key_a.clone(), key_a, key_b], }; let bytes = proto.encode_to_vec(); let decoded = ProtoPostgresTableDesc::decode(bytes.as_slice()) .expect("hand-built proto must decode"); - let orig: PostgresTableDesc = - decoded.into_rust().expect("duplicate/unsorted keys must convert"); + let orig: PostgresTableDesc = decoded + .clone() + .into_rust() + .expect("duplicate/unsorted keys must convert"); + + // Reversing the wire order must not change what we decode. + let mut reversed = decoded; + reversed.keys.reverse(); + let flipped: PostgresTableDesc = reversed + .into_rust() + .expect("duplicate/unsorted keys must convert"); + assert_eq!(orig, flipped, "decoded desc depends on wire key order"); + assert_roundtrip(orig); } // Raw-bytes arm: decode adversarial proto bytes, then round-trip. diff --git a/src/repr/fuzz/Cargo.toml b/src/repr/fuzz/Cargo.toml index 777a867a1a05f..a8ea570f69ab3 100644 --- a/src/repr/fuzz/Cargo.toml +++ b/src/repr/fuzz/Cargo.toml @@ -62,13 +62,6 @@ test = false doc = false bench = false -[[bin]] -name = "interval_proto_roundtrip" -path = "fuzz_targets/interval_proto_roundtrip.rs" -test = false -doc = false -bench = false - [[bin]] name = "mz_acl_item_proto_roundtrip" path = "fuzz_targets/mz_acl_item_proto_roundtrip.rs" diff --git a/src/repr/fuzz/fuzz_targets/acl_item_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/acl_item_proto_roundtrip.rs index 1629f7016313b..8fa52b2eaa7f5 100644 --- a/src/repr/fuzz/fuzz_targets/acl_item_proto_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/acl_item_proto_roundtrip.rs @@ -34,11 +34,11 @@ fn arbitrary_arm(seed: &[u8]) { } let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &buf); let mut runner = TestRunner::new_with_rng(Config::default(), rng); - let value = - match ::arbitrary().new_tree(&mut runner) { - Ok(tree) => tree.current(), - Err(_) => return, - }; + let value = match ::arbitrary().new_tree(&mut runner) + { + Ok(tree) => tree.current(), + Err(_) => return, + }; let proto = value.into_proto(); let back = AclItem::from_proto(proto).expect("valid AclItem must round-trip"); @@ -56,8 +56,8 @@ fn raw_arm(data: &[u8]) { let proto2 = >::from_rust(&orig); let bytes2 = proto2.encode_to_vec(); - let proto3 = ProtoAclItem::decode(bytes2.as_slice()) - .expect("re-encode of valid AclItem must decode"); + let proto3 = + ProtoAclItem::decode(bytes2.as_slice()).expect("re-encode of valid AclItem must decode"); let round: AclItem = proto3 .into_rust() .expect("re-encoded AclItem must convert back to Rust"); diff --git a/src/repr/fuzz/fuzz_targets/column_type_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/column_type_proto_roundtrip.rs index 82781158f9fe3..cf5a8dd38fdbf 100644 --- a/src/repr/fuzz/fuzz_targets/column_type_proto_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/column_type_proto_roundtrip.rs @@ -11,28 +11,113 @@ //! //! Two arms (the first byte selects): //! - Arbitrary arm: drive `SqlColumnType`'s proptest `Arbitrary` strategy from -//! the fuzzer bytes to build a *valid* column type pairing a deeply-nested -//! `SqlScalarType` with a nullable flag, and assert -//! `from_proto(into_proto(v)) == v`. Random proto bytes leave the inner -//! scalar type near-empty. This arm reaches the recursive scalar variants. +//! the fuzzer bytes to build a *valid* column type pairing a nested +//! `SqlScalarType` with a nullable flag, and assert it survives an +//! encode/decode through the wire codec. Valid values are where a proto3 +//! presence bug shows up: a default-valued field that silently stops being +//! written to the wire still passes an in-memory `RustType` round-trip. //! - Raw-bytes arm: decode arbitrary bytes as `ProtoColumnType`, into Rust, and //! re-encode, keeping coverage of the bare wire decoder against hostile -//! input. +//! input. `prost` is built with `no-recursion-limit`, so this is also the arm +//! that reaches deep nesting in the inner scalar type. +//! +//! Both arms also run a domain oracle over the decoded type parameters. The +//! round-trip oracle on its own cannot see decoder laxity here: `into_proto` +//! writes every type parameter back verbatim, so byte preservation holds for +//! out-of-domain values too and `assert_eq!(orig, round)` degenerates into "did +//! not panic". #![no_main] use libfuzzer_sys::fuzz_target; -use mz_proto::{ProtoType, RustType}; -use mz_repr::{ProtoColumnType, SqlColumnType}; +use mz_proto::{ProtoType, protobuf_roundtrip}; +use mz_repr::adt::char::CharLength; +use mz_repr::adt::numeric::NumericMaxScale; +use mz_repr::adt::timestamp::TimestampPrecision; +use mz_repr::adt::varchar::VarCharMaxLength; +use mz_repr::{ProtoColumnType, SqlColumnType, SqlScalarType}; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; use prost::Message; +/// Asserts every type parameter reachable from `t` is one the planner could have +/// produced. +/// +/// Each newtype enforces its domain in `TryFrom`, and consumers assume that +/// invariant holds however the value was built: a `Timestamp` precision above +/// `MAX_PRECISION` panics `CheckedTimestamp::round_to_precision`, which the SQL +/// Server source decoder reaches with the precision carried by a decoded +/// `SqlColumnType`. +fn assert_type_params_in_domain(t: &SqlScalarType) { + match t { + SqlScalarType::Timestamp { precision } | SqlScalarType::TimestampTz { precision } => { + if let Some(p) = precision { + assert!( + TimestampPrecision::try_from(i64::from(p.into_u8())).is_ok(), + "out-of-domain timestamp precision {}", + p.into_u8() + ); + } + } + SqlScalarType::Numeric { max_scale } => { + if let Some(s) = max_scale { + assert!( + NumericMaxScale::try_from(i64::from(s.into_u8())).is_ok(), + "out-of-domain numeric max scale {}", + s.into_u8() + ); + } + } + SqlScalarType::Char { length } => { + if let Some(l) = length { + assert!( + CharLength::try_from(i64::from(l.into_u32())).is_ok(), + "out-of-domain char length {}", + l.into_u32() + ); + } + } + SqlScalarType::VarChar { max_length } => { + if let Some(l) = max_length { + assert!( + VarCharMaxLength::try_from(i64::from(l.into_u32())).is_ok(), + "out-of-domain varchar max length {}", + l.into_u32() + ); + } + } + SqlScalarType::Array(inner) + | SqlScalarType::Range { + element_type: inner, + } => assert_type_params_in_domain(inner), + SqlScalarType::List { + element_type: inner, + .. + } + | SqlScalarType::Map { + value_type: inner, .. + } => assert_type_params_in_domain(inner), + SqlScalarType::Record { fields, .. } => { + for (_, ct) in fields.iter() { + assert_type_params_in_domain(&ct.scalar_type); + } + } + _ => {} + } +} + fn arbitrary_arm(seed: &[u8]) { let mut buf = [0u8; 32]; for (dst, src) in buf.iter_mut().zip(seed.iter()) { *dst = *src; } + // NOTE: hashing the fuzzer bytes into a ChaCha seed costs libFuzzer its + // mutation gradient, one flipped bit re-rolls the whole value. The obvious + // fix, `RngAlgorithm::PassThrough`, hangs: it feeds the fuzzer bytes in as + // the random stream and then yields zeros forever once they run out, and + // `rand`'s Lemire sampler loops until a draw clears `thresh`, which a zero + // never does for a range that is not a power of two. Every strategy here + // outdraws a 4096-byte input. let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &buf); let mut runner = TestRunner::new_with_rng(Config::default(), rng); let value = match ::arbitrary() @@ -42,8 +127,12 @@ fn arbitrary_arm(seed: &[u8]) { Err(_) => return, }; - let proto = value.into_proto(); - let back = SqlColumnType::from_proto(proto).expect("valid SqlColumnType must round-trip"); + // The generator is meant to model what the planner can produce, so a failure + // here is a generator defect, not a decoder defect. + assert_type_params_in_domain(&value.scalar_type); + + let back = protobuf_roundtrip::<_, ProtoColumnType>(&value) + .expect("valid SqlColumnType must round-trip"); assert_eq!(value, back, "SqlColumnType changed across proto roundtrip"); } @@ -55,6 +144,7 @@ fn raw_arm(data: &[u8]) { Ok(v) => v, Err(_) => return, }; + assert_type_params_in_domain(&orig.scalar_type); let proto2 = >::from_rust(&orig); let bytes2 = proto2.encode_to_vec(); diff --git a/src/repr/fuzz/fuzz_targets/interval_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/interval_proto_roundtrip.rs deleted file mode 100644 index 8e01d256cac7f..0000000000000 --- a/src/repr/fuzz/fuzz_targets/interval_proto_roundtrip.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright Materialize, Inc. and contributors. All rights reserved. -// -// Use of this software is governed by the Business Source License -// included in the LICENSE file. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0. - -//! Fuzz target: `Interval` proto round-trips losslessly. -//! -//! Two arms (the first byte selects): -//! - Arbitrary arm: drive `Interval`'s proptest `Arbitrary` strategy from the -//! fuzzer bytes to build a *valid* interval (months/days/micros across the -//! full range) and assert `from_proto(into_proto(v)) == v`. -//! - Raw-bytes arm: decode arbitrary bytes as `ProtoInterval`, into Rust, and -//! re-encode, keeping coverage of the bare wire decoder against hostile -//! input. - -#![no_main] - -use libfuzzer_sys::fuzz_target; -use mz_proto::{ProtoType, RustType}; -use mz_repr::adt::interval::{Interval, ProtoInterval}; -use proptest::strategy::{Strategy, ValueTree}; -use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; -use prost::Message; - -fn arbitrary_arm(seed: &[u8]) { - let mut buf = [0u8; 32]; - for (dst, src) in buf.iter_mut().zip(seed.iter()) { - *dst = *src; - } - let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &buf); - let mut runner = TestRunner::new_with_rng(Config::default(), rng); - let value = - match ::arbitrary().new_tree(&mut runner) { - Ok(tree) => tree.current(), - Err(_) => return, - }; - - let proto = value.into_proto(); - let back = Interval::from_proto(proto).expect("valid Interval must round-trip"); - assert_eq!(value, back, "Interval changed across proto roundtrip"); -} - -fn raw_arm(data: &[u8]) { - let Ok(proto) = ProtoInterval::decode(data) else { - return; - }; - let orig: Interval = match proto.into_rust() { - Ok(v) => v, - Err(_) => return, - }; - - let proto2 = >::from_rust(&orig); - let bytes2 = proto2.encode_to_vec(); - let proto3 = ProtoInterval::decode(bytes2.as_slice()) - .expect("re-encode of valid Interval must decode"); - let round: Interval = proto3 - .into_rust() - .expect("re-encoded Interval must convert back to Rust"); - - assert_eq!(orig, round, "Interval changed across proto roundtrip"); -} - -fuzz_target!(|data: &[u8]| { - let Some((&mode, rest)) = data.split_first() else { - return; - }; - if mode & 1 == 0 { - arbitrary_arm(rest); - } else { - raw_arm(rest); - } -}); diff --git a/src/repr/fuzz/fuzz_targets/numeric_arith.rs b/src/repr/fuzz/fuzz_targets/numeric_arith.rs index 47f2bb87c5f5f..7f600622db442 100644 --- a/src/repr/fuzz/fuzz_targets/numeric_arith.rs +++ b/src/repr/fuzz/fuzz_targets/numeric_arith.rs @@ -33,7 +33,7 @@ use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_repr::adt::numeric::{cx_datum, Numeric}; +use mz_repr::adt::numeric::{Numeric, cx_datum}; use mz_repr::strconv::parse_numeric; /// Generate one operand from the fuzzer's byte stream. diff --git a/src/repr/fuzz/fuzz_targets/range_ops.rs b/src/repr/fuzz/fuzz_targets/range_ops.rs index a280ca311dbd9..dcc7237277eb3 100644 --- a/src/repr/fuzz/fuzz_targets/range_ops.rs +++ b/src/repr/fuzz/fuzz_targets/range_ops.rs @@ -27,8 +27,8 @@ use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; -use mz_repr::adt::range::{Range, RangeBound}; use mz_repr::Datum; +use mz_repr::adt::range::{Range, RangeBound}; // None = infinite bound. Some((inclusive, value)) = finite bound. type BoundSpec = Option<(bool, i32)>; @@ -78,11 +78,7 @@ fn check(a: Range>, b: Range>, elem: i32) { "union must contain both operands" ); } - assert_eq!( - a.union(&b), - b.union(&a), - "union must be commutative" - ); + assert_eq!(a.union(&b), b.union(&a), "union must be commutative"); if let Ok(uaa) = a.union(&a) { assert_eq!(uaa, a, "union must be idempotent (a ∪ a == a)"); } @@ -107,10 +103,7 @@ fn check(a: Range>, b: Range>, elem: i32) { // --- Difference algebra: a ∖ b ⊆ a and disjoint from b. --------------- if let Ok(diff) = a.difference(&b) { let diff = to_static(diff); - assert!( - a.contains_range(&diff), - "a ∖ b must be a subset of a" - ); + assert!(a.contains_range(&diff), "a ∖ b must be a subset of a"); assert!( diff.intersection(&b).inner.is_none(), "a ∖ b must be disjoint from b" diff --git a/src/repr/fuzz/fuzz_targets/relation_desc_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/relation_desc_proto_roundtrip.rs index c967acdbfa5e6..ab3067180d40e 100644 --- a/src/repr/fuzz/fuzz_targets/relation_desc_proto_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/relation_desc_proto_roundtrip.rs @@ -11,46 +11,160 @@ //! `RelationDesc` is the schema of every persisted collection, so a decoder //! bug here corrupts catalog/persist state. //! -//! Two arms (the first byte selects): +//! Three arms (the first byte selects): //! - Arbitrary arm: drive `RelationDesc`'s proptest `Arbitrary` strategy from -//! the fuzzer bytes to build a *valid* desc: column names paired with -//! deeply-nested `SqlColumnType`s, where the names<->metadata length -//! invariant and the per-version migration metadata are well-formed. Assert -//! `from_proto(into_proto(v)) == v`. Random proto bytes never satisfy the -//! length invariant, so the encoder's rollup/migration-default paths are -//! only reached here. +//! the fuzzer bytes, then apply a generated batch of schema migrations to +//! it. The strategy alone only ever builds descs through `RelationDesc::new`, +//! whose metadata is all-default, so a dropped column, and with it the +//! encoder's non-default metadata path, enters the value space only via the +//! migrations. Assert the desc survives an encode/decode through the wire +//! codec. Valid values are where a proto3 presence bug shows up: a +//! default-valued field that silently stops being written to the wire still +//! passes an in-memory `RustType` round-trip. +//! - Versioned arm: build a desc through `VersionedRelationDesc`'s +//! `add_column`/`drop_column` and read it back `at_version`, which is the +//! only producer of *sparse* `ColumnIndex` keys. The proto has no field for +//! those keys, so the round trip renumbers them densely. Assert what does +//! survive, and pin the renumbering itself, since +//! `ColumnIndex::to_stable_name` is the arrow field name persist data is +//! written under. //! - Raw-bytes arm: decode arbitrary bytes as `ProtoRelationDesc`, into Rust, //! and re-encode, keeping coverage of the bare wire decoder against hostile -//! input (including descs that violate the length invariant on decode). +//! input. Equality across the re-encode is weak here, `from_proto` +//! canonicalizes what it decodes so it holds for anything that decodes at +//! all, so the arm also walks the decoded desc: the decoder must not admit +//! a shape that panics on first use. #![no_main] use libfuzzer_sys::fuzz_target; -use mz_proto::{ProtoType, RustType}; -use mz_repr::{ProtoRelationDesc, RelationDesc}; +use mz_proto::{ProtoType, protobuf_roundtrip}; +use mz_repr::{ + ColumnName, ProtoRelationDesc, RelationDesc, RelationVersion, RelationVersionSelector, + SqlColumnType, VersionedRelationDesc, arb_relation_desc_diff, +}; +use proptest::prelude::any; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; use prost::Message; -fn arbitrary_arm(seed: &[u8]) { +fn runner(seed: &[u8]) -> TestRunner { let mut buf = [0u8; 32]; for (dst, src) in buf.iter_mut().zip(seed.iter()) { *dst = *src; } + // NOTE: hashing the fuzzer bytes into a ChaCha seed costs libFuzzer its + // mutation gradient, one flipped bit re-rolls the whole value. The obvious + // fix, `RngAlgorithm::PassThrough`, hangs: it feeds the fuzzer bytes in as + // the random stream and then yields zeros forever once they run out, and + // `rand`'s Lemire sampler loops until a draw clears `thresh`, which a zero + // never does for a range that is not a power of two. Every strategy here + // outdraws a 4096-byte input. let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &buf); - let mut runner = TestRunner::new_with_rng(Config::default(), rng); - let value = match ::arbitrary() - .new_tree(&mut runner) - { - Ok(tree) => tree.current(), - Err(_) => return, + TestRunner::new_with_rng(Config::default(), rng) +} + +fn arbitrary_arm(seed: &[u8]) { + let mut runner = runner(seed); + let strat = any::().prop_flat_map(|desc| { + arb_relation_desc_diff(&desc).prop_map(move |diffs| (desc.clone(), diffs)) + }); + let Ok(tree) = strat.new_tree(&mut runner) else { + return; }; + let (mut value, diffs) = tree.current(); + for diff in diffs { + diff.apply(&mut value); + } - let proto = value.into_proto(); - let back = RelationDesc::from_proto(proto).expect("valid RelationDesc must round-trip"); + let back = protobuf_roundtrip::<_, ProtoRelationDesc>(&value) + .expect("valid RelationDesc must round-trip"); assert_eq!(value, back, "RelationDesc changed across proto roundtrip"); } +fn versioned_arm(seed: &[u8]) { + let mut runner = runner(seed); + let strat = ( + any::(), + proptest::collection::vec((any::(), any::(), any::()), 0..8), + any::(), + ); + let Ok(tree) = strat.new_tree(&mut runner) else { + return; + }; + let (desc, ops, version) = tree.current(); + + let mut versioned = VersionedRelationDesc::new(desc); + for (idx, (drop, pick, typ)) in ops.into_iter().enumerate() { + let live = versioned.at_version(RelationVersionSelector::Latest); + // `drop_column` panics on a column that is part of a key. `arb_relation_desc` doesn't + // generate keys, this only keeps the arm honest if that changes. + let droppable: Vec = if live.typ().keys.is_empty() { + live.iter_names().cloned().collect() + } else { + Vec::new() + }; + if drop && !droppable.is_empty() { + let name = droppable[usize::from(pick) % droppable.len()].clone(); + let _ = versioned.drop_column(name); + } else { + // `add_column` panics on a name that is already live. + let name = ColumnName::from(format!("fuzz_added_{idx}")); + if live.get_by_name(&name).is_none() { + let _ = versioned.add_column(name, typ); + } + } + } + + let value = versioned.at_version(RelationVersionSelector::Specific( + RelationVersion::from_raw(u64::from(version)), + )); + let back = protobuf_roundtrip::<_, ProtoRelationDesc>(&value) + .expect("valid RelationDesc must round-trip"); + + // The column data must survive in `ColumnIndex` order, as must the keys. + assert_eq!( + value.arity(), + back.arity(), + "arity changed: {value:?} {back:?}" + ); + assert!( + value.iter().eq(back.iter()), + "columns changed across proto roundtrip: {value:?} {back:?}" + ); + assert_eq!( + value.typ().keys, + back.typ().keys, + "keys changed across proto roundtrip: {value:?} {back:?}" + ); + + // The `ColumnIndex` keys themselves do not survive: the proto carries only the values, in + // index order, so decoding renumbers them to `0..n`. Pin that, both to catch indexes + // shifting some other way and so that teaching the proto to carry them has to come back + // through here and through the `to_stable_name` consumers. + assert!( + back.iter_all() + .enumerate() + .all(|(pos, (idx, _, _))| idx.to_raw() == pos), + "decoded ColumnIndexes are not dense: {back:?}" + ); + let was_dense = value + .iter_all() + .enumerate() + .all(|(pos, (idx, _, _))| idx.to_raw() == pos); + if was_dense { + assert_eq!( + value, back, + "dense RelationDesc changed across proto roundtrip" + ); + } + + // Whatever the indexes were, the renumbered desc is a fixed point. + let again = protobuf_roundtrip::<_, ProtoRelationDesc>(&back) + .expect("re-encoded RelationDesc must round-trip"); + assert_eq!(back, again, "RelationDesc roundtrip is not idempotent"); +} + fn raw_arm(data: &[u8]) { let Ok(proto) = ProtoRelationDesc::decode(data) else { return; @@ -60,6 +174,20 @@ fn raw_arm(data: &[u8]) { Err(_) => return, }; + // A desc that decodes must be *usable*. `iter()` indexes `typ.columns()` by `typ_idx` and + // `into_iter()` zips the metadata against `column_types` with `zip_eq`, so a shape the + // decoder lets through panics at first use, inside whichever timely worker touches the + // collection, rather than at the boundary. Walking it here turns that into a fuzz finding. + assert_eq!(orig.iter().count(), orig.arity()); + assert_eq!(orig.clone().into_iter().count(), orig.arity()); + for key in orig.typ().keys.iter().flatten() { + assert!( + *key < orig.arity(), + "key {key} out of bounds for {} columns", + orig.arity() + ); + } + let proto2 = >::from_rust(&orig); let bytes2 = proto2.encode_to_vec(); let proto3 = ProtoRelationDesc::decode(bytes2.as_slice()) @@ -75,9 +203,9 @@ fuzz_target!(|data: &[u8]| { let Some((&mode, rest)) = data.split_first() else { return; }; - if mode & 1 == 0 { - arbitrary_arm(rest); - } else { - raw_arm(rest); + match mode % 3 { + 0 => arbitrary_arm(rest), + 1 => versioned_arm(rest), + _ => raw_arm(rest), } }); diff --git a/src/repr/fuzz/fuzz_targets/row_arrow_roundtrip.rs b/src/repr/fuzz/fuzz_targets/row_arrow_roundtrip.rs index 76e7051dab274..47ebab10915f3 100644 --- a/src/repr/fuzz/fuzz_targets/row_arrow_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/row_arrow_roundtrip.rs @@ -44,7 +44,7 @@ use mz_repr::adt::array::ArrayDimension; use mz_repr::adt::char::CharLength; use mz_repr::adt::date::Date; use mz_repr::adt::interval::Interval; -use mz_repr::adt::numeric::{cx_datum, Numeric, NumericMaxScale}; +use mz_repr::adt::numeric::{Numeric, NumericMaxScale, cx_datum}; use mz_repr::adt::range::{Range, RangeBound, RangeLowerBound, RangeUpperBound}; use mz_repr::adt::timestamp::CheckedTimestamp; use mz_repr::adt::varchar::VarCharMaxLength; @@ -308,7 +308,9 @@ fn push_datum( .try_push_array(&dims, elems.iter().map(Borrow::borrow)) .is_err() { - packer.try_push_array(&[], std::iter::empty::()).unwrap(); + packer + .try_push_array(&[], std::iter::empty::()) + .unwrap(); } } // A `Map` of string keys to `value_type` values. Keys must be unique @@ -357,10 +359,7 @@ fn gen_scalar_datums<'a>( /// Only the `Copy` `Datum` variants (those produced by `gen_element_type` / /// `gen_range_element_type`) are handled, so the returned `Datum` carries no /// borrow and the caller can collect it into a `Vec`. -fn gen_scalar_datum<'a>( - u: &mut Unstructured, - ty: &SqlScalarType, -) -> arbitrary::Result> { +fn gen_scalar_datum<'a>(u: &mut Unstructured, ty: &SqlScalarType) -> arbitrary::Result> { Ok(match ty { SqlScalarType::Bool => { if bool::arbitrary(u)? { @@ -377,7 +376,8 @@ fn gen_scalar_datum<'a>( SqlScalarType::Float64 => Datum::Float64(f64::arbitrary(u)?.into()), SqlScalarType::Numeric { .. } => Datum::Numeric(gen_numeric(u)?), SqlScalarType::Date => Datum::Date( - Date::from_pg_epoch(i32::arbitrary(u)?).unwrap_or_else(|_| Date::from_pg_epoch(0).unwrap()), + Date::from_pg_epoch(i32::arbitrary(u)?) + .unwrap_or_else(|_| Date::from_pg_epoch(0).unwrap()), ), SqlScalarType::Time => { let secs = u.int_in_range(0u32..=86_399)?; @@ -425,10 +425,7 @@ fn push_range( let upper: RangeUpperBound = RangeBound::new(upper_d, bool::arbitrary(u)?); // An out-of-order range (lower > upper) is an `InvalidRangeError`. On // failure just fall back to the empty range so the column stays valid. - if packer - .push_range(Range::new(Some((lower, upper)))) - .is_err() - { + if packer.push_range(Range::new(Some((lower, upper)))).is_err() { let _ = packer.push_range(Range { inner: None }); } Ok(()) diff --git a/src/repr/fuzz/fuzz_targets/row_codec_roundtrip.rs b/src/repr/fuzz/fuzz_targets/row_codec_roundtrip.rs index ddec355bf8efa..b9db80b1a4667 100644 --- a/src/repr/fuzz/fuzz_targets/row_codec_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/row_codec_roundtrip.rs @@ -51,7 +51,13 @@ fn arb_relation_desc(u: &mut Unstructured) -> RelationDesc { for i in 0..ncols { let scalar_type = arb_scalar_type(u); let nullable = bool::arbitrary(u).unwrap_or(true); - builder = builder.with_column(format!("c{i}"), SqlColumnType { scalar_type, nullable }); + builder = builder.with_column( + format!("c{i}"), + SqlColumnType { + scalar_type, + nullable, + }, + ); } builder.finish() } @@ -91,5 +97,8 @@ fuzz_target!(|data: &[u8]| { let mut buf = Vec::new(); orig.encode(&mut buf); let round = Row::decode(&buf, &schema).expect("re-encode of a valid Row must decode"); - assert_eq!(orig, round, "Row changed across Codec roundtrip (schema = {schema:?})"); + assert_eq!( + orig, round, + "Row changed across Codec roundtrip (schema = {schema:?})" + ); }); diff --git a/src/repr/fuzz/fuzz_targets/row_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/row_proto_roundtrip.rs index 4698cb43a6f76..d459887da0b60 100644 --- a/src/repr/fuzz/fuzz_targets/row_proto_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/row_proto_roundtrip.rs @@ -31,8 +31,7 @@ fuzz_target!(|data: &[u8]| { let proto2 = >::from_rust(&orig); let bytes2 = proto2.encode_to_vec(); - let proto3 = ProtoRow::decode(bytes2.as_slice()) - .expect("re-encode of valid Row must decode"); + let proto3 = ProtoRow::decode(bytes2.as_slice()).expect("re-encode of valid Row must decode"); let round: Row = proto3 .into_rust() .expect("re-encoded Row must convert back to Rust"); diff --git a/src/repr/fuzz/fuzz_targets/scalar_type_proto_roundtrip.rs b/src/repr/fuzz/fuzz_targets/scalar_type_proto_roundtrip.rs index 74f020306f4a7..bc73964eb9bc6 100644 --- a/src/repr/fuzz/fuzz_targets/scalar_type_proto_roundtrip.rs +++ b/src/repr/fuzz/fuzz_targets/scalar_type_proto_roundtrip.rs @@ -13,19 +13,21 @@ //! //! Two arms (the first byte selects): //! - Arbitrary arm: drive `SqlScalarType`'s proptest `Arbitrary` strategy from -//! the fuzzer bytes to build a *valid, deeply-nested* type (the recursive -//! `List`/`Map`/`Array`/`Record`/`Range` variants, boundary `max_scale` and -//! char/varchar `length`, custom OIDs, etc.) and assert -//! `from_proto(into_proto(v)) == v`. Random proto bytes almost never reach -//! these variants, so this arm is what actually exercises the encoder. +//! the fuzzer bytes to build a *valid* type (boundary `max_scale` and +//! char/varchar `length`, custom OIDs, the recursive `List`/`Map`/`Record` +//! variants up to the strategy's depth cap of 2) and assert it survives an +//! encode/decode through the wire codec. Valid values are where a proto3 +//! presence bug shows up: a default-valued field that silently stops being +//! written to the wire still passes an in-memory `RustType` round-trip. //! - Raw-bytes arm: decode arbitrary bytes as `ProtoScalarType`, into Rust, and //! re-encode, keeping coverage of the bare wire decoder against hostile -//! input. +//! input. `prost` is built with `no-recursion-limit`, so this is also the arm +//! that reaches deep nesting, far past what the strategy above produces. #![no_main] use libfuzzer_sys::fuzz_target; -use mz_proto::{ProtoType, RustType}; +use mz_proto::{ProtoType, protobuf_roundtrip}; use mz_repr::{ProtoScalarType, SqlScalarType}; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; @@ -36,6 +38,13 @@ fn arbitrary_arm(seed: &[u8]) { for (dst, src) in buf.iter_mut().zip(seed.iter()) { *dst = *src; } + // NOTE: hashing the fuzzer bytes into a ChaCha seed costs libFuzzer its + // mutation gradient, one flipped bit re-rolls the whole value. The obvious + // fix, `RngAlgorithm::PassThrough`, hangs: it feeds the fuzzer bytes in as + // the random stream and then yields zeros forever once they run out, and + // `rand`'s Lemire sampler loops until a draw clears `thresh`, which a zero + // never does for a range that is not a power of two. Every strategy here + // outdraws a 4096-byte input. let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &buf); let mut runner = TestRunner::new_with_rng(Config::default(), rng); let value = match ::arbitrary() @@ -45,8 +54,8 @@ fn arbitrary_arm(seed: &[u8]) { Err(_) => return, }; - let proto = value.into_proto(); - let back = SqlScalarType::from_proto(proto).expect("valid SqlScalarType must round-trip"); + let back = protobuf_roundtrip::<_, ProtoScalarType>(&value) + .expect("valid SqlScalarType must round-trip"); assert_eq!(value, back, "SqlScalarType changed across proto roundtrip"); } diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_date.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_date.rs index 08fad748842dc..6781078895709 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_date.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_date.rs @@ -8,26 +8,37 @@ // by the Apache License, Version 2.0. //! Fuzz target: `strconv::parse_date` parses untrusted DATE literal text. A -//! re-parseable rendering of a parsed value must yield the same value. +//! rendering of a parsed value must re-parse to the same value, and +//! `format_date`'s `Nestable` verdict must be honest about that rendering. #![no_main] use libfuzzer_sys::fuzz_target; -use mz_repr::strconv::{format_date, parse_date}; +use mz_repr::strconv::{Nestable, element_needs_escaping, format_date, parse_date}; fuzz_target!(|data: &str| { let Ok(d) = parse_date(data) else { return; }; let mut buf = String::new(); - format_date(&mut buf, d); - // The renderer can emit text the parser rejects, a known cluster of - // date/time round-trip gaps (a leap second `:60` that PG carries, a - // >4-digit year, etc.), tracked separately, not panics. Tolerate those and - // only assert that a *re-parseable* rendering preserves the value (drift), - // and that nothing panics. - let Ok(reparsed) = parse_date(&buf) else { - return; - }; + // `Nestable` is the other half of `format_date`'s contract, and it is + // consumed: `stringify_datum` and the pgwire text encoder use it to decide + // whether to escape a date nested in an array, list, map, record or range. + // A wrong `Yes` is invisible to the round trip below, because the + // wrongly-unquoted rendering still re-parses, so it needs its own assertion. + if let Nestable::Yes = format_date(&mut buf, d) { + assert!( + !element_needs_escaping(buf.as_bytes()), + "format_date claimed Nestable::Yes for a rendering that needs escaping: {buf:?}" + ); + } + // Re-parse is total over this output space, so a rejection is a renderer bug + // rather than something to tolerate. `format_date` writes no time component, + // so the leap-second round-trip gap cannot appear here, and a year of more + // than four digits parses: `fill_pdt_date` reads a 6-digit leading number + // followed by a dash as a full year. Sweeping the whole `Date` range leaves + // four reachable forms, a 4, 5 or 6 digit Common Era year and a 4 digit BC + // year, and all of them parse. + let reparsed = parse_date(&buf).expect("format_date emitted text parse_date rejects"); assert_eq!(d, reparsed, "date changed across parse/format round trip"); }); diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_interval.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_interval.rs index 6ffdf6cc699cf..cc3767856b809 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_interval.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_interval.rs @@ -9,26 +9,35 @@ //! Fuzz target: `strconv::parse_interval` parses untrusted INTERVAL literal //! text. It drives a complex datetime token state machine (the most intricate -//! parser in strconv). Beyond not panicking, its `Display` rendering must -//! re-parse to the same interval. +//! parser in strconv). Beyond not panicking, its rendering must re-parse to the +//! same interval. +//! +//! Rendering goes through `strconv::format_interval` rather than `Display` +//! directly, because that is the entry point `::text` output 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 ever stops being true. #![no_main] use libfuzzer_sys::fuzz_target; -use mz_repr::strconv::parse_interval; +use mz_repr::strconv::{format_interval, parse_interval}; fuzz_target!(|data: &str| { let Ok(iv) = parse_interval(data) else { return; }; - let formatted = iv.to_string(); - // `Display` collapses sub-day time into one unbounded hours field, and - // re-parsing multiplies that hour count back out with checked arithmetic. A - // valid interval with micros near `i64::MAX` formats to an hour count whose - // re-parse overflows, so re-parse is not total. Tolerate the failure and - // only assert the round trip preserves the value when it does re-parse. - let Ok(reparsed) = parse_interval(&formatted) else { - return; - }; - assert_eq!(iv, reparsed, "interval changed across parse/format round trip"); + let mut formatted = String::new(); + format_interval(&mut formatted, iv); + // Re-parse is total, so a rejection is a renderer bug rather than something + // to tolerate. The hours field is the only unbounded one, and it 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. + let reparsed = + parse_interval(&formatted).expect("format_interval emitted text parse_interval rejects"); + assert_eq!( + iv, reparsed, + "interval changed across parse/format round trip" + ); }); diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_numeric.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_numeric.rs index 38e48f7a1ff92..ba342bb93ba07 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_numeric.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_numeric.rs @@ -33,8 +33,18 @@ use mz_repr::strconv::parse_numeric; /// Special spellings: the infinity/NaN family the post-parse validation rejects /// (or accepts as overflow-`inf`), plus assorted casing. const SPECIALS: &[&str] = &[ - "NaN", "nan", "-NaN", "+NaN", "sNaN", "Infinity", "-Infinity", "+Infinity", "inf", "-inf", - "Inf", "INFINITY", + "NaN", + "nan", + "-NaN", + "+NaN", + "sNaN", + "Infinity", + "-Infinity", + "+Infinity", + "inf", + "-inf", + "Inf", + "INFINITY", ]; fn push_digits(u: &mut Unstructured, out: &mut String, n: usize) -> arbitrary::Result<()> { @@ -117,7 +127,10 @@ fn check(s: &str) { }; let formatted = n.0.to_standard_notation_string(); let reparsed = parse_numeric(&formatted).expect("canonical numeric rendering must re-parse"); - assert_eq!(n, reparsed, "numeric changed across parse/format round trip"); + assert_eq!( + n, reparsed, + "numeric changed across parse/format round trip" + ); } fuzz_target!(|data: &[u8]| { diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_range.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_range.rs index a48f1da53f5d8..d4dc37bd35e37 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_range.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_range.rs @@ -73,22 +73,29 @@ fn run(mut u: Unstructured) -> arbitrary::Result<()> { } } else { // Independent open/close framing so mismatched brackets/parens arise. - s.push(if u.int_in_range(0u8..=1)? == 0 { '[' } else { '(' }); + s.push(if u.int_in_range(0u8..=1)? == 0 { + '[' + } else { + '(' + }); push_bound(&mut u, &mut s)?; s.push(','); push_bound(&mut u, &mut s)?; // Sometimes drop the closer entirely (truncated framing). if u.int_in_range(0u8..=5)? != 0 { - s.push(if u.int_in_range(0u8..=1)? == 0 { ']' } else { ')' }); + s.push(if u.int_in_range(0u8..=1)? == 0 { + ']' + } else { + ')' + }); } } // 1-in-5: splice extra structural noise to exercise the error paths. if u.int_in_range(0u8..=4)? == 0 { s.push_str(u.choose(&["[", "]", "(", ")", ",", "\"", "\\", " ", "empty"])?); } - let _ = mz_repr::strconv::parse_range(&s, |e| { - Ok::<_, std::convert::Infallible>(e.into_owned()) - }); + let _ = + mz_repr::strconv::parse_range(&s, |e| Ok::<_, std::convert::Infallible>(e.into_owned())); Ok(()) } diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_time.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_time.rs index 07a22be65983d..ea096cf13ba74 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_time.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_time.rs @@ -8,29 +8,61 @@ // by the Apache License, Version 2.0. //! Fuzz target: `strconv::parse_time` parses untrusted TIME literal text. +//! +//! Unlike TIMESTAMP, TIME has no precision modifier and its cast does not round, +//! so a `Row` holds every parsed nanosecond while the renderer writes only +//! microseconds. The renderer is therefore the rounding step, and the oracle is +//! that it rounds rather than mangles: re-parsing its output must land within +//! half a microsecond of the value it was handed. #![no_main] -use chrono::Timelike; +use chrono::{NaiveTime, Timelike}; use libfuzzer_sys::fuzz_target; use mz_repr::strconv::{format_time, parse_time}; +/// The nanosecond count of `t` since midnight, counting chrono's leap second +/// (spelled as a sub-second of a full second at `23:59:59`) as the nanosecond +/// after `23:59:59.999999999`. +/// +/// Rendering can move a value across a second, a minute, or an hour boundary by +/// rounding its fraction up, so the round trip has to be measured on one number +/// rather than field by field. +fn nanos_from_midnight(t: NaiveTime) -> i64 { + i64::from(t.num_seconds_from_midnight()) * 1_000_000_000 + i64::from(t.nanosecond()) +} + fuzz_target!(|data: &str| { let Ok(t) = parse_time(data) else { return; }; - // mz TIME keeps nanosecond precision (its cast, unlike TIMESTAMP's, does not - // round) but renders microseconds, and the parser accepts a leap second - // (`:60`) the renderer can't round-trip. Both are known TIME/PG-compat gaps - // tracked separately. Skip sub-microsecond and leap-second values. - let nanos = t.nanosecond(); - if nanos % 1_000 != 0 || nanos >= 1_000_000_000 { - return; - } + + // Rendering is unconditional. A sub-microsecond fraction and a leap second + // are the two input classes that have broken the renderer before, so they + // have to reach it. let mut buf = String::new(); format_time(&mut buf, t); - let Ok(reparsed) = parse_time(&buf) else { - return; + // Every rendering is `HH:MM:SS[.ffffff]`, reaching `:60` only for a leap + // second, which the parser accepts back. A rejection means the renderer + // emitted text no client can read back. + let reparsed = parse_time(&buf).expect("format_time emitted text parse_time rejects"); + + // The renderer rounds half away from zero, so a faithful rendering never + // moves the value by more than half a microsecond. + // + // The exception is a round up out of the last second of the day, which has + // no second to carry into: a `NaiveTime` wraps to midnight rather than + // reaching PostgreSQL's `24:00:00`, so the fraction saturates at `.999999` + // and the value can move by just under a full microsecond instead. + let saturates = t.num_seconds_from_midnight() == 86_399 + && (999_999_500..1_000_000_000).contains(&t.nanosecond()); + let bound = match saturates { + true => 1_000, + false => 500, }; - assert_eq!(t, reparsed, "time changed across parse/format round trip"); + let drift = nanos_from_midnight(reparsed) - nanos_from_midnight(t); + assert!( + drift.abs() <= bound, + "time moved {drift}ns across parse/format round trip: {t} -> {buf} -> {reparsed}" + ); }); diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_timestamp.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_timestamp.rs index d5ca025c3e0ff..67f16cfa70eb6 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_timestamp.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_timestamp.rs @@ -8,42 +8,72 @@ // by the Apache License, Version 2.0. //! Fuzz target: `strconv::parse_timestamp` parses untrusted TIMESTAMP literal -//! text. `CastStringToTimestamp` rounds the parsed value to the type's -//! precision (microseconds by default) before storage, so mirror that. +//! text. Two consumers read a parsed value back, and each gets its own oracle. +//! +//! `mz_pgrepr::Value::decode_text`, which backs COPY in TEXT and CSV format and +//! text-format extended-protocol bind parameters, does not round: it stores the +//! parsed nanoseconds as they are, and `Row` keeps all of them. For that +//! consumer the renderer is itself the rounding step, so the oracle is that +//! rendering and re-parsing lands exactly on the microsecond-rounded value. +//! +//! `CastStringToTimestamp` instead rounds to the column's precision before +//! storage. That precision comes from the input rather than being fixed at +//! microseconds, because the cast carries an arbitrary +//! `Option` taken from the type modifier. Precisions below 6 +//! are the only ones that reach `round_to_precision`'s rounding branch at all: +//! at precision 6 the rounding quantum is a single microsecond, so every value +//! is already on a boundary and the branch is dead. #![no_main] -use chrono::Timelike; use libfuzzer_sys::fuzz_target; +use mz_repr::adt::timestamp::TimestampPrecision; use mz_repr::strconv::{format_timestamp, parse_timestamp}; -fuzz_target!(|data: &str| { +fuzz_target!(|input: (u8, &str)| { + let (precision, data) = input; + // `None` (the default) plus every declarable precision, 0 through 6. + let precision = match precision % 8 { + 0 => None, + p => Some(TimestampPrecision::try_from(i64::from(p) - 1).expect("0..=6 is in range")), + }; + let Ok(ts) = parse_timestamp(data) else { return; }; - // The parser accepts a leap second (`:60`), stored as chrono's leap - // representation (sub-second >= 1s). PostgreSQL carries `:60` to the next - // minute and our microsecond renderer mis-encodes the leap, so such values - // do not round-trip. This is a known parser/PG-compat gap tracked - // separately, not a panic. Skip them (rounding to precision below can't - // create a leap). - if ts.nanosecond() >= 1_000_000_000 { - return; + + // The unrounded consumer. `format_timestamp` renders microseconds, so + // re-parsing its output has to land on the microsecond-rounded value: any + // other result is a value that displays as something it is not. + // + // `round_to_precision` reports out of range exactly where rounding up leaves + // chrono's range, which is the one case the renderer cannot mirror. It has + // no error channel, so it saturates the fraction at `.999999` instead, and + // there is nothing for the two to agree on. + let mut buf = String::new(); + format_timestamp(&mut buf, &ts); + let reparsed = + parse_timestamp(&buf).expect("format_timestamp emitted text parse_timestamp rejects"); + if let Ok(rounded) = ts.round_to_precision(None) { + assert_eq!( + rounded, reparsed, + "rendering a timestamp did not round it to microseconds" + ); } - let Ok(ts) = ts.round_to_precision(None) else { + + // The cast. A value already rounded to `precision` sits on a microsecond + // boundary, so the renderer has nothing left to round and the round trip is + // exact. `expect` rather than a carve-out: a renderer that starts emitting + // text the parser rejects is a bug this oracle should report, not tolerate. + let Ok(ts) = ts.round_to_precision(precision) else { return; }; let mut buf = String::new(); format_timestamp(&mut buf, &ts); - // The renderer can also emit text the parser rejects (e.g. a >4-digit - // year), also tracked separately. Tolerate that (re-parse failure) and only - // assert that a re-parseable rendering preserves the value, plus no panics. - let Ok(reparsed) = parse_timestamp(&buf) else { - return; - }; - let Ok(reparsed) = reparsed.round_to_precision(None) else { - return; - }; + let reparsed = parse_timestamp(&buf) + .expect("format_timestamp emitted text parse_timestamp rejects") + .round_to_precision(precision) + .expect("re-rounding an already-rounded timestamp cannot overflow"); assert_eq!( ts, reparsed, "timestamp changed across parse/format round trip" diff --git a/src/repr/fuzz/fuzz_targets/strconv_parse_timestamptz.rs b/src/repr/fuzz/fuzz_targets/strconv_parse_timestamptz.rs index d4428d5b7a5c8..9c3b8d3ee537d 100644 --- a/src/repr/fuzz/fuzz_targets/strconv_parse_timestamptz.rs +++ b/src/repr/fuzz/fuzz_targets/strconv_parse_timestamptz.rs @@ -8,42 +8,70 @@ // by the Apache License, Version 2.0. //! Fuzz target: `strconv::parse_timestamptz` parses untrusted TIMESTAMPTZ -//! literal text. `CastStringToTimestampTz` rounds to the type's precision -//! (microseconds by default) before storage, so mirror that. +//! literal text. Two consumers read a parsed value back, and each gets its own +//! oracle. See `strconv_parse_timestamp` for the same pair spelled out at +//! length. +//! +//! `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. `CastStringToTimestampTz` instead rounds to the column's +//! precision first. That precision comes from the input rather than being fixed +//! at microseconds, because the cast carries an arbitrary +//! `Option` taken from the type modifier. Precisions below 6 +//! are the only ones that reach `round_to_precision`'s rounding branch at all: +//! at precision 6 the rounding quantum is a single microsecond, so every value +//! is already on a boundary and the branch is dead. #![no_main] -use chrono::Timelike; use libfuzzer_sys::fuzz_target; +use mz_repr::adt::timestamp::TimestampPrecision; use mz_repr::strconv::{format_timestamptz, parse_timestamptz}; -fuzz_target!(|data: &str| { +fuzz_target!(|input: (u8, &str)| { + let (precision, data) = input; + // `None` (the default) plus every declarable precision, 0 through 6. + let precision = match precision % 8 { + 0 => None, + p => Some(TimestampPrecision::try_from(i64::from(p) - 1).expect("0..=6 is in range")), + }; + let Ok(ts) = parse_timestamptz(data) else { return; }; - // The parser accepts a leap second (`:60`), stored as chrono's leap - // representation (sub-second >= 1s). PostgreSQL carries `:60` to the next - // minute and our microsecond renderer mis-encodes the leap, so such values - // do not round-trip. This is a known parser/PG-compat gap tracked - // separately, not a panic. Skip them (rounding to precision below can't - // create a leap). - if ts.nanosecond() >= 1_000_000_000 { - return; + // The unrounded consumer. The renderer writes microseconds, so re-parsing + // its output has to land on the microsecond-rounded value. The carve-out is + // the one case the renderer cannot mirror: where rounding up would leave + // chrono's range it has no error channel and saturates the fraction instead. + // + // A leap second needs no carve-out here. chrono stores a parsed `:60` as a + // sub-second of one second or more, which its `%S` renders back as `60`, and + // an offset that would move it off `:59` (where the representation is + // unconstructable) is folded during parsing. + let mut buf = String::new(); + format_timestamptz(&mut buf, &ts); + let reparsed = + parse_timestamptz(&buf).expect("format_timestamptz emitted text parse_timestamptz rejects"); + if let Ok(rounded) = ts.round_to_precision(None) { + assert_eq!( + rounded, reparsed, + "rendering a timestamptz did not round it to microseconds" + ); } - let Ok(ts) = ts.round_to_precision(None) else { + + // The cast. Rounding is idempotent, so re-rounding the re-parsed value to + // the same precision must land back on `ts`. `expect` rather than a + // carve-out: a renderer that starts emitting text the parser rejects is a + // bug this oracle should report rather than tolerate. + let Ok(ts) = ts.round_to_precision(precision) else { return; }; let mut buf = String::new(); format_timestamptz(&mut buf, &ts); - // The renderer can also emit text the parser rejects (e.g. a >4-digit - // year), also tracked separately. Tolerate that (re-parse failure) and only - // assert that a re-parseable rendering preserves the value, plus no panics. - let Ok(reparsed) = parse_timestamptz(&buf) else { - return; - }; - let Ok(reparsed) = reparsed.round_to_precision(None) else { - return; - }; + let reparsed = parse_timestamptz(&buf) + .expect("format_timestamptz emitted text parse_timestamptz rejects") + .round_to_precision(precision) + .expect("re-rounding an already-rounded timestamp cannot overflow"); assert_eq!( ts, reparsed, "timestamptz changed across parse/format round trip" diff --git a/src/repr/fuzz/prepare-corpus.sh b/src/repr/fuzz/prepare-corpus.sh new file mode 100755 index 0000000000000..26ef6d302fd8a --- /dev/null +++ b/src/repr/fuzz/prepare-corpus.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. +# +# prepare-corpus.sh populates the corpora of the `ProtoScalarType`-shaped proto +# round-trip targets with hand-encoded, structurally valid messages. +# +# A byte-wise mutator cannot assemble a nested length-delimited protobuf on its +# own: every enclosing message carries a length prefix that has to agree with +# the payload it wraps, and prost rejects the whole message the moment one does +# not, so no partial attempt ever earns coverage feedback to build on. Measured +# from an empty corpus, 1.4M executions of `column_type_proto_roundtrip` never +# once produced a `Timestamp` scalar type. Dictionary tokens for the tags do not +# help, because the tag is not the part the mutator gets wrong. +# +# The seeds therefore cover the parameterized variants of `ProtoScalarType`, +# which are the ones carrying a type parameter that a decoder can get wrong: +# `Numeric`, `Timestamp`, `TimestampTz`, `Char` and `VarChar`, plus the +# recursive wrappers `Array`, `List`, `Map`, `Record` and `Range` that nest +# them. Mutating a valid seed's parameter bytes is cheap, so this is what puts +# the domain oracles in those targets within reach. +# +# Every seed carries the one-byte mode prefix the target reads off the front of +# the input, which is the byte that selects the raw-bytes arm these seeds are +# for. The value differs per target, so it is spelled out at each `write` call +# rather than shared. Without it the seed's own leading tag is eaten as the mode +# byte and the remainder decodes as garbage. + +set -euo pipefail + +cd "$(dirname "$0")" + +python3 - <<'PY' +import os + +def varint(n: int) -> bytes: + out = bytearray() + while True: + b = n & 0x7F + n >>= 7 + out.append(b | (0x80 if n else 0)) + if not n: + return bytes(out) + +def tag(field: int, wire_type: int) -> bytes: + return varint((field << 3) | wire_type) + +def ld(field: int, payload: bytes) -> bytes: + """A length-delimited field: nested message, string or bytes.""" + return tag(field, 2) + varint(len(payload)) + payload + +def vi(field: int, value: int) -> bytes: + return tag(field, 0) + varint(value) + +# Field numbers from `src/repr/src/relation_and_scalar.proto`. The oneof is not +# dense and not ordered, so these are spelled out rather than derived. +EMPTY_KINDS = {"bool": 1, "int64": 4, "date": 8, "string": 15, "jsonb": 18} +NUMERIC, CHAR, VARCHAR, ARRAY, LIST, RECORD, MAP = 7, 16, 17, 20, 21, 22, 24 +RANGE, TIMESTAMP, TIMESTAMPTZ = 33, 37, 38 + +def scalar(kind_field: int, payload: bytes = b"") -> bytes: + return ld(kind_field, payload) + +# ProtoOptionalNumericMaxScale { ProtoNumericMaxScale value = 1 }, whose own +# `value = 1` is the u8 scale. An absent inner message is `numeric` with no +# declared scale, which is a distinct decode path worth its own seed. +def numeric(max_scale: int | None) -> bytes: + inner = b"" if max_scale is None else ld(1, vi(1, max_scale)) + return scalar(NUMERIC, inner) + +def timestamp(kind_field: int, precision: int | None) -> bytes: + inner = b"" if precision is None else ld(1, vi(1, precision)) + return scalar(kind_field, inner) + +def char_like(kind_field: int, length: int | None) -> bytes: + inner = b"" if length is None else ld(1, vi(1, length)) + return scalar(kind_field, inner) + +def column_type(scalar_bytes: bytes, nullable: bool) -> bytes: + body = ld(1, scalar_bytes) + if nullable: + body += vi(2, 1) + return body + +BOOL = scalar(EMPTY_KINDS["bool"]) +INT64 = scalar(EMPTY_KINDS["int64"]) + +# The full set the seeds are built from. Values sit at the edges of each +# parameter's legal domain, which is where an off-by-one in a decoder's bound +# check shows up. +SCALARS = { + **{name: scalar(field) for name, field in EMPTY_KINDS.items()}, + "numeric_none": numeric(None), + "numeric_0": numeric(0), + "numeric_39": numeric(39), + "timestamp_none": timestamp(TIMESTAMP, None), + "timestamp_0": timestamp(TIMESTAMP, 0), + "timestamp_6": timestamp(TIMESTAMP, 6), + "timestamptz_none": timestamp(TIMESTAMPTZ, None), + "timestamptz_6": timestamp(TIMESTAMPTZ, 6), + "char_none": char_like(CHAR, None), + "char_1": char_like(CHAR, 1), + "char_max": char_like(CHAR, 10_485_759), + "varchar_none": char_like(VARCHAR, None), + "varchar_1": char_like(VARCHAR, 1), + "varchar_max": char_like(VARCHAR, 10_485_759), + # Recursive wrappers, each nesting a parameterized leaf so that mutating the + # inner parameter stays reachable from a seed. + "array_int64": scalar(ARRAY, INT64), + "array_timestamp": scalar(ARRAY, timestamp(TIMESTAMP, 6)), + "list_char": scalar(LIST, ld(1, char_like(CHAR, 1))), + "map_varchar": scalar(MAP, ld(1, char_like(VARCHAR, 1))), + "range_timestamp": scalar(RANGE, ld(1, timestamp(TIMESTAMP, 6))), + # ProtoRecord { repeated ProtoRecordField fields = 1 }, where a field is + # { ProtoColumnName ColumnName = 1, ProtoColumnType ColumnType = 2 }. + "record_two_fields": scalar( + RECORD, + ld(1, ld(1, ld(1, b"a")) + ld(2, column_type(INT64, False))) + + ld(1, ld(1, ld(1, b"b")) + ld(2, column_type(numeric(39), True))), + ), +} + +def write(target: str, raw_mode: int, seeds: dict[str, bytes]) -> None: + corpus = os.path.join("corpus", target) + os.makedirs(corpus, exist_ok=True) + for stale in os.listdir(corpus): + if stale.startswith("seed_") and stale.endswith(".bin"): + os.remove(os.path.join(corpus, stale)) + for name, blob in seeds.items(): + with open(os.path.join(corpus, f"seed_{name}.bin"), "wb") as f: + f.write(bytes([raw_mode]) + blob) + print(f" {corpus:<46} {len(seeds):4d} seeds") + +print("Seeded:") + +# `mode & 1 == 1` selects the raw-bytes arm. +write("scalar_type_proto_roundtrip", 1, SCALARS) + +# `nullable` is a bare proto3 bool, so `false` is absent from the wire. Both +# spellings are seeded to keep a presence regression on that field visible. +write( + "column_type_proto_roundtrip", + 1, + { + **{f"{name}_notnull": column_type(s, False) for name, s in SCALARS.items()}, + **{f"{name}_nullable": column_type(s, True) for name, s in SCALARS.items()}, + }, +) + +# ProtoRelationDesc { ProtoRelationType typ = 1, repeated ProtoColumnName names = 2, +# repeated ProtoColumnMetadata metadata = 3 } +# ProtoRelationType { repeated ProtoColumnType column_types = 1, repeated ProtoKey keys = 2 } +# The decoder zips `metadata` against `column_types`, so a seed with a mismatched +# count is rejected at the boundary and teaches the mutator nothing. Seeds keep +# the counts equal and let mutation break them. +def relation_desc(scalars: list[bytes], keys: list[list[int]]) -> bytes: + columns = b"".join(ld(1, column_type(s, i % 2 == 1)) for i, s in enumerate(scalars)) + key_msgs = b"".join(ld(2, b"".join(vi(1, k) for k in key)) for key in keys) + names = b"".join(ld(2, ld(1, f"c{i}".encode())) for i in range(len(scalars))) + # ProtoColumnMetadata { ProtoRelationVersion added = 1, dropped = 2 }, a + # version being { uint64 value = 1 }. Column 0 is dropped at version 2 so + # that the added/dropped bookkeeping is exercised, not just the happy path. + metadata = b"".join( + ld(3, ld(1, vi(1, 0)) + (ld(2, vi(1, 2)) if i == 0 else b"")) + for i in range(len(scalars)) + ) + return ld(1, columns + key_msgs) + names + metadata + +# This target has three arms and dispatches on `mode % 3`, so 2 is the raw one. +write( + "relation_desc_proto_roundtrip", + 2, + { + # `typ` is a required field, so a zero-column desc still carries it. An + # entirely empty message is rejected at the boundary like any garbage. + "no_cols": relation_desc([], []), + "one_col": relation_desc([INT64], []), + "keyed": relation_desc([INT64, BOOL], [[0], [0, 1]]), + "parameterized": relation_desc( + [timestamp(TIMESTAMP, 6), char_like(CHAR, 1), numeric(39)], [[1]] + ), + }, +) +PY diff --git a/src/repr/fuzz/strconv_parse_timestamp.dict b/src/repr/fuzz/strconv_parse_timestamp.dict new file mode 100644 index 0000000000000..839d58aa11f04 --- /dev/null +++ b/src/repr/fuzz/strconv_parse_timestamp.dict @@ -0,0 +1,51 @@ +# libFuzzer dictionary for the strconv_parse_timestamp target, resolved by +# `dict_for` in test/cargo-fuzz/mzcompose.py. +# +# The interesting inputs sit on the two boundaries of `CheckedTimestamp`'s +# accepted range, and neither is reachable by mutation alone. `HIGH_DATE` is the +# single date `262142-12-31`, and reaching it means mutating a 6-digit ASCII +# integer onto one exact value while a late time-of-day is already in place. +# There is no coverage gradient to climb: the range check yields one edge for +# "accepted" and one for "rejected", both saturated within the first few +# executions. Spelling the boundary dates as tokens is what puts the arithmetic +# just past them within reach. +# +# Measured on the un-parameterized version of this target, 13M executions +# reached `parse_timestamp` successfully 395,498 times and `round_to_precision` +# returned `Err` zero times, i.e. neither end of the date range was ever +# approached. + +# The range boundaries: `HIGH_DATE` is exactly `chrono::NaiveDate::MAX`, and the +# low bound is PostgreSQL's 4713 BC. +"262142-12-31" +"262143-01-01" +"4713-12-31" +"4714-01-01" +" BC" + +# Times that round *up* across the day boundary, one per precision. The 7th +# fractional digit drives the microsecond nudge, and it is also what makes the +# renderer carry a full second into the seconds field. The shorter forms drive +# the rounding branch that only a precision below 6 reaches, where a single `.5` +# is enough to round up a whole second. +"23:59:59.9999995" +"23:59:59.99999" +"23:59:59.9995" +"23:59:59.5" + +# Leap seconds. chrono stores a parsed `:60` as a sub-second of one second or +# more, a representation that is unconstructable at any second-of-minute other +# than `:59`, so anything rebuilding a `NaiveTime` from its parts trips on it. +":60" +"23:59:60" +"00:00:60" + +# Separators and forms the parser keys off, so a mutated token still lands in a +# plausible position. +"-" +":" +"." +" " +"T" +"epoch" +"infinity" diff --git a/src/repr/fuzz/strconv_parse_timestamptz.dict b/src/repr/fuzz/strconv_parse_timestamptz.dict new file mode 100644 index 0000000000000..588780c26c228 --- /dev/null +++ b/src/repr/fuzz/strconv_parse_timestamptz.dict @@ -0,0 +1,57 @@ +# libFuzzer dictionary for the strconv_parse_timestamptz target, resolved by +# `dict_for` in test/cargo-fuzz/mzcompose.py. +# +# The interesting inputs sit on the two boundaries of `CheckedTimestamp`'s +# accepted range, and neither is reachable by mutation alone. `HIGH_DATE` is the +# single date `262142-12-31`, and reaching 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. There is no coverage gradient to climb: the range check +# yields one edge for "accepted" and one for "rejected", both saturated within +# the first few executions. Spelling the boundary dates as tokens is what puts +# the arithmetic just past them within reach. +# +# The offsets and leap-second forms matter for the same reason. Overflow at the +# high boundary needs the offset to shift the value *out* of range, and the +# leap-second representation (chrono's sub-second >= 1s, from a `:60` literal) is +# unconstructable at any second-of-minute other than `:59`, so an offset that is +# not a whole number of minutes is what exercises the normalization. + +# The range boundaries: `HIGH_DATE` is exactly `chrono::NaiveDate::MAX`, and the +# low bound is PostgreSQL's 4713 BC. +"262142-12-31" +"262143-01-01" +"4713-12-31" +"4714-01-01" +" BC" + +# Times that round *up* across the day boundary, one per precision. The 7th +# fractional digit drives the microsecond nudge; the shorter forms drive the +# rounding branch that only a precision below 6 reaches. +"23:59:59.9999995" +"23:59:59.9995" +"23:59:59.5" + +# Leap seconds, at `:59` where chrono can represent them and elsewhere where it +# cannot. +":60" +"23:59:60" +"00:00:60" + +# Offsets. A sub-minute offset shifts the second-of-minute off `:59`; a westward +# offset is what pushes the high boundary out of range. +"-01" +"+01" +"+00" +"+00:00:30" +"-05:30" +"+16:60" +" Europe/Amsterdam" +" UTC" + +# Separators the parser keys off, so a mutated token still lands in a plausible +# position. +"-" +":" +"." +" " +"T" diff --git a/src/repr/src/adt/interval.rs b/src/repr/src/adt/interval.rs index a61d64b1253e5..50f79b7ce437e 100644 --- a/src/repr/src/adt/interval.rs +++ b/src/repr/src/adt/interval.rs @@ -909,6 +909,9 @@ impl FixedSizeCodec for PackedInterval { #[cfg(test)] mod test { + use mz_ore::assert_ok; + use mz_proto::protobuf_roundtrip; + use super::*; use proptest::prelude::*; @@ -1341,4 +1344,18 @@ mod test { sort_intervals(interval); }); } + + // `Interval` <-> `ProtoInterval` is a field-for-field copy today, so this only + // bites once the two structs drift: a field added to `Interval` but not carried + // through `ProtoInterval` silently decodes as that field's default. NOTE: the + // guard is blind unless `Interval::arbitrary` also generates the new field, so + // extend the strategy alongside the field. + proptest! { + #[mz_ore::test] + fn interval_protobuf_roundtrip(expect in any::()) { + let actual = protobuf_roundtrip::<_, ProtoInterval>(&expect); + assert_ok!(actual); + assert_eq!(actual.unwrap(), expect); + } + } } diff --git a/src/repr/src/strconv.rs b/src/repr/src/strconv.rs index e5be98b9ba413..fadbe43e61723 100644 --- a/src/repr/src/strconv.rs +++ b/src/repr/src/strconv.rs @@ -1888,6 +1888,18 @@ impl ElementEscaper for RecordElementEscaper { } } +/// Reports whether `elem` would be quoted by the list, map or record element +/// escaper. +/// +/// This is the union of the three, because one rendering can be nested in any of +/// them. Returning [`Nestable::Yes`] is a promise that this is false, so a +/// formatter's oracle can check the promise rather than restate the rules. +pub fn element_needs_escaping(elem: &[u8]) -> bool { + ListElementEscaper::needs_escaping(elem) + || MapElementEscaper::needs_escaping(elem) + || RecordElementEscaper::needs_escaping(elem) +} + /// Escapes a list, record, or map element in place. /// /// The element must start at `start` and extend to the end of the buffer. The diff --git a/src/sql-parser/fuzz/build.rs b/src/sql-parser/fuzz/build.rs index 6bf65989fbb93..7bce4f670ed5b 100644 --- a/src/sql-parser/fuzz/build.rs +++ b/src/sql-parser/fuzz/build.rs @@ -69,7 +69,12 @@ fn main() { // Parse each body into alternatives of items. let rules: Vec>> = bodies .iter() - .map(|body| split_unquoted(body, '|').iter().map(|a| tokenize(a)).collect()) + .map(|body| { + split_unquoted(body, '|') + .iter() + .map(|a| tokenize(a)) + .collect() + }) .collect(); // Emit the rule table. @@ -110,7 +115,10 @@ fn main() { out.push_str(&format!(" ], leaf_alt: {leaf_alt} }},\n")); } out.push_str("];\n"); - out.push_str(&format!("pub static START: usize = {};\n", index_of("statement"))); + out.push_str(&format!( + "pub static START: usize = {};\n", + index_of("statement") + )); let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); fs::write(Path::new(&out_dir).join("grammar.rs"), out).expect("write grammar.rs"); diff --git a/src/sql-parser/fuzz/fuzz_targets/grammar.rs b/src/sql-parser/fuzz/fuzz_targets/grammar.rs index 3760e29e71ee2..b388d5e3e3850 100644 --- a/src/sql-parser/fuzz/fuzz_targets/grammar.rs +++ b/src/sql-parser/fuzz/fuzz_targets/grammar.rs @@ -88,15 +88,6 @@ impl<'a, T: AstInfo> VisitMut<'a, T> for RemoveParens { } } -/// Reparse errors that are a known printer/parser asymmetry rather than a bug. -fn benign_reparse_error(msg: &str) -> bool { - msg.contains("exceeds nested expression limit") - || msg.contains("Expected left square bracket") - || msg.contains("Expected left parenthesis") - || msg.contains("Expected IN, found") - || msg.contains("Expected arrow, found") -} - fn check_pretty(sql: &str, orig_ast: &Statement) { for width in [100, 1] { let pretty = match pretty_str_simple(sql, width) { @@ -106,15 +97,16 @@ fn check_pretty(sql: &str, orig_ast: &Statement) { let reparsed = match parse_statements(&pretty) { Ok(r) => r, Err(e) => { - if benign_reparse_error(&e.to_string()) { - continue; - } - panic!("pretty output failed to reparse: pretty={pretty:?} width={width} err={e}"); + panic!("pretty output failed to reparse: pretty={pretty:?} width={width} err={e}") } }; - let Some(stmt) = reparsed.into_iter().next() else { - continue; - }; + assert_eq!( + reparsed.len(), + 1, + "pretty output reparsed to {} statements, expected 1\ninput: {sql:?}\nwidth: {width}\npretty: {pretty:?}", + reparsed.len(), + ); + let stmt = reparsed.into_iter().next().unwrap(); let mut reparsed_ast = stmt.ast; normalize(&mut reparsed_ast); assert_eq!( @@ -128,16 +120,14 @@ fn check_display(orig_ast: &Statement) { let displayed = orig_ast.to_ast_string_simple(); let reparsed = match parse_statements(&displayed) { Ok(r) => r, - Err(e) => { - if benign_reparse_error(&e.to_string()) { - return; - } - panic!("AstDisplay output failed to reparse: displayed={displayed:?} err={e}"); - } + Err(e) => panic!("AstDisplay output failed to reparse: displayed={displayed:?} err={e}"), }; - if reparsed.len() != 1 { - return; - } + assert_eq!( + reparsed.len(), + 1, + "AstDisplay output reparsed to {} statements, expected 1\ndisplayed: {displayed:?}", + reparsed.len(), + ); let mut reparsed_ast = reparsed.into_iter().next().unwrap().ast; // Normalize the reparse too (mirroring `check_pretty`): the parser may // re-insert a semantically-redundant `Expr::Nested` (e.g. it parenthesizes a @@ -168,13 +158,36 @@ fn check_display(orig_ast: &Statement) { /// printer's quoting decision (bare names, quoted keyword collisions, names that /// only round-trip when quoted). const IDENTS: &[&str] = &[ - "a", "b", "c", "x", "y", "col", "foo", "bar", "t1", "t2", "\"select\"", "\"from\"", "\"any\"", - "\"Mixed\"", "\"with space\"", "\"a.b\"", "\"1col\"", "\"qu\"\"ote\"", + "a", + "b", + "c", + "x", + "y", + "col", + "foo", + "bar", + "t1", + "t2", + "\"select\"", + "\"from\"", + "\"any\"", + "\"Mixed\"", + "\"with space\"", + "\"a.b\"", + "\"1col\"", + "\"qu\"\"ote\"", ]; /// String literals for `@str`, weighted toward lexing/escaping edge cases. const STRINGS: &[&str] = &[ - "'a'", "''", "'foo bar'", "'it''s'", "'a\"b'", "'%'", "'_'", "'100'", + "'a'", + "''", + "'foo bar'", + "'it''s'", + "'a\"b'", + "'%'", + "'_'", + "'100'", ]; /// Every keyword the lexer knows, for `@kw` (a bare keyword used as an diff --git a/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs b/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs index 428d6a553aace..ec76ad158a394 100644 --- a/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs +++ b/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs @@ -71,7 +71,7 @@ use libfuzzer_sys::arbitrary::{Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_sql_parser::ast::display::AstDisplay; use mz_sql_parser::ast::visit_mut::{self, VisitMut}; -use mz_sql_parser::ast::{AstInfo, Expr, Raw, Statement}; +use mz_sql_parser::ast::{AlterRoleOption, AstInfo, Expr, Raw, RoleAttribute, Statement}; use mz_sql_parser::parser::parse_statements; use mz_sql_pretty::pretty_str_simple; @@ -97,6 +97,38 @@ fn normalize(stmt: &mut Statement) { RemoveParens.visit_statement_mut(stmt); } +/// Rewrite every `PASSWORD ''` to the placeholder the `AstDisplay` +/// printer emits in its place. +/// +/// `RoleAttribute::Password(Some(_))` renders as the fixed string +/// `PASSWORD ''` in *every* `AstDisplay` format mode, so a password +/// cannot survive an `AstDisplay` print/reparse cycle. Applying this to both +/// sides of that comparison keeps the rest of the statement under the oracle +/// rather than exempting the whole statement. +/// +/// Only `check_display` needs this. The pretty printer deliberately preserves +/// the value (`mz_sql_pretty`'s `doc_role_attribute`), so `check_pretty` compares +/// passwords exactly and must keep doing so. +fn redact_passwords(stmt: &mut Statement) { + let attrs: &mut [RoleAttribute] = match stmt { + // `Declare`/`Prepare` print their inner statement through the same + // redacting printer, so a secret nested under them is affected too. + Statement::Declare(d) => return redact_passwords(&mut d.stmt), + Statement::Prepare(p) => return redact_passwords(&mut p.stmt), + Statement::CreateRole(c) => &mut c.options, + Statement::AlterRole(a) => match &mut a.option { + AlterRoleOption::Attributes(attrs) => attrs, + AlterRoleOption::Variable(_) => return, + }, + _ => return, + }; + for attr in attrs { + if let RoleAttribute::Password(Some(password)) = attr { + *password = "".into(); + } + } +} + struct RemoveParens; impl<'a, T: AstInfo> VisitMut<'a, T> for RemoveParens { @@ -108,15 +140,6 @@ impl<'a, T: AstInfo> VisitMut<'a, T> for RemoveParens { } } -/// Reparse errors that are a known printer/parser asymmetry rather than a bug. -fn benign_reparse_error(msg: &str) -> bool { - msg.contains("exceeds nested expression limit") - || msg.contains("Expected left square bracket") - || msg.contains("Expected left parenthesis") - || msg.contains("Expected IN, found") - || msg.contains("Expected arrow, found") -} - fn check_pretty(sql: &str, orig_ast: &Statement) { // The line width must not affect the AST: wrapping is purely cosmetic, so // both a wide layout (everything on one line) and a narrow one (maximally @@ -130,15 +153,16 @@ fn check_pretty(sql: &str, orig_ast: &Statement) { let reparsed = match parse_statements(&pretty) { Ok(r) => r, Err(e) => { - if benign_reparse_error(&e.to_string()) { - continue; - } - panic!("pretty output failed to reparse: pretty={pretty:?} width={width} err={e}"); + panic!("pretty output failed to reparse: pretty={pretty:?} width={width} err={e}") } }; - let Some(stmt) = reparsed.into_iter().next() else { - continue; - }; + assert_eq!( + reparsed.len(), + 1, + "pretty output reparsed to {} statements, expected 1\npretty: {pretty:?}\nwidth: {width}", + reparsed.len(), + ); + let stmt = reparsed.into_iter().next().unwrap(); let mut reparsed_ast = stmt.ast; normalize(&mut reparsed_ast); assert_eq!( @@ -152,12 +176,7 @@ fn check_display(orig_ast: &Statement) { let displayed = orig_ast.to_ast_string_simple(); let reparsed = match parse_statements(&displayed) { Ok(r) => r, - Err(e) => { - if benign_reparse_error(&e.to_string()) { - return; - } - panic!("AstDisplay output failed to reparse: displayed={displayed:?} err={e}"); - } + Err(e) => panic!("AstDisplay output failed to reparse: displayed={displayed:?} err={e}"), }; // One statement must print as exactly one statement. Any other count means // the printer emitted text that reparses to a different number of @@ -176,6 +195,11 @@ fn check_display(orig_ast: &Statement) { // free to add or drop. Stripping them from both sides leaves a genuine // structural drift to still trip the assert. normalize(&mut reparsed_ast); + // `AstDisplay` prints a password as a fixed placeholder, so neither side can + // carry the original value through this comparison. See `redact_passwords`. + let mut orig_ast = orig_ast.clone(); + redact_passwords(&mut orig_ast); + redact_passwords(&mut reparsed_ast); // Compare ASTs *structurally*, not by re-printed string. A printer that drops // a needed paren can map two distinct ASTs onto the same string (e.g. // `IsExpr(a, DistinctFrom(Or(b, c)))` and `Or(IsExpr(a, DistinctFrom(b)), c)` @@ -183,7 +207,7 @@ fn check_display(orig_ast: &Statement) { // to those collisions, but the structural comparison catches them. The stable // strings are still shown for a readable diff. assert_eq!( - *orig_ast, + orig_ast, reparsed_ast, "AstDisplay roundtrip drifted\ndisplayed: {displayed:?}\norig: {}\nreparsed: {}", orig_ast.to_ast_string_stable(), @@ -335,9 +359,51 @@ const BIN_OPS: &[&str] = &[ /// (which must never panic on any input), and occasionally a valid-but-unusual /// statement the structured grammar wouldn't assemble. const NOISE: &[&str] = &[ - "(", ")", "[", "]", "{", "}", ",", ";", ".", "::", ":", "*", "@", "?", "!", "\\", "\"", "'", - "->", "->>", "#>>", "||", "<>", "=>", "%", "~", "&", "|", "$1", "$$", "''", "\"\"", "/*", "*/", - "--", " ", "\t", "\n", "1e999", "0x1", "-0", ".", "e", "E'\\x41'", "U&'\\0041'", + "(", + ")", + "[", + "]", + "{", + "}", + ",", + ";", + ".", + "::", + ":", + "*", + "@", + "?", + "!", + "\\", + "\"", + "'", + "->", + "->>", + "#>>", + "||", + "<>", + "=>", + "%", + "~", + "&", + "|", + "$1", + "$$", + "''", + "\"\"", + "/*", + "*/", + "--", + " ", + "\t", + "\n", + "1e999", + "0x1", + "-0", + ".", + "e", + "E'\\x41'", + "U&'\\0041'", ]; // The parser's AST source, embedded so the fuzzed connector option space stays @@ -1236,12 +1302,72 @@ impl<'a, 'u> Gen<'a, 'u> { } } + fn kafka_aws_privatelink(&mut self) { + self.out.push_str("USING AWS PRIVATELINK "); + self.qualified_name(); + match self.pick(4) { + 0 => {} + 1 => self.out.push_str(" (AVAILABILITY ZONE = 'use1-az1')"), + 2 => self.out.push_str(" (PORT = 9092)"), + _ => { + self.out + .push_str(" (AVAILABILITY ZONE = 'use1-az1', PORT = 9092)"); + } + } + } + + fn kafka_broker(&mut self) { + self.string_value(); + match self.pick(3) { + 0 => {} + 1 => { + self.out.push_str(" USING SSH TUNNEL "); + self.qualified_name(); + } + _ => { + self.out.push(' '); + self.kafka_aws_privatelink(); + } + } + } + + fn kafka_matching_broker_rule(&mut self) { + self.out.push_str("MATCHING "); + self.one_of(&["'*'", "'*.example.com:*'", "'broker:*'", "'a''b*'"]); + self.out.push(' '); + self.kafka_aws_privatelink(); + } + + fn kafka_brokers(&mut self) { + self.out.push_str(" = "); + let (open, close) = if self.chance(1, 2) { + ('(', ')') + } else { + ('[', ']') + }; + self.out.push(open); + let n = 1 + self.pick(3); + for i in 0..n { + if i > 0 { + self.out.push_str(", "); + } + // Keep at least one static broker in every list. Additional entries + // exercise the distinct MATCHING-rule display path. + if i == 0 || self.chance(2, 3) { + self.kafka_broker(); + } else { + self.kafka_matching_broker_rule(); + } + } + self.out.push(close); + } + /// One `NAME [= value]` config-option clause. Most options take the generic /// `option_value`. The handful with a dedicated value grammar the parser /// dispatches by name are special-cased: `PARTITION BY` (an expression), /// `RETAIN HISTORY` (`FOR ''`), `TEXT`/`EXCLUDE COLUMNS` (an ident - /// sequence), `BROKER` (a broker string), and the `… CONNECTION` / `SSH - /// TUNNEL` object references (an item name). + /// sequence), `BROKER`/`BROKERS` (broker values), and the `… CONNECTION` / + /// `SSH TUNNEL` object references (an item name). fn config_option(&mut self, name: &str) { self.out.push_str(name); match name { @@ -1265,6 +1391,7 @@ impl<'a, 'u> Gen<'a, 'u> { self.out.push(')'); } "BROKER" => self.out.push_str(" 'localhost:9092'"), + "BROKERS" => self.kafka_brokers(), "AWS CONNECTION" | "GCP CONNECTION" | "SSH TUNNEL" => { self.out.push_str(" = "); self.ident(); @@ -1403,7 +1530,10 @@ impl<'a, 'u> Gen<'a, 'u> { self.ident_list(1, 3); if self.chance(1, 2) { self.out.push_str(" WITH"); - self.config_option_list(option_names("AlterSourceAddSubsourceOptionName"), true); + self.config_option_list( + option_names("AlterSourceAddSubsourceOptionName"), + true, + ); } } else { self.out.push_str("VALIDATE CONNECTION "); diff --git a/src/sql-server-util/fuzz/fuzz_targets/sql_server_table_desc_proto_roundtrip.rs b/src/sql-server-util/fuzz/fuzz_targets/sql_server_table_desc_proto_roundtrip.rs index dd7b74e849a2f..4a881db411d4a 100644 --- a/src/sql-server-util/fuzz/fuzz_targets/sql_server_table_desc_proto_roundtrip.rs +++ b/src/sql-server-util/fuzz/fuzz_targets/sql_server_table_desc_proto_roundtrip.rs @@ -26,11 +26,21 @@ //! //! 2. **Constraint-string arm.** Drives the *raw-ingest* path //! `SqlServerTableConstraint::try_from(SqlServerTableConstraintRaw)`, -//! which parses the `constraint_type` *string* (`"PRIMARY KEY"` / -//! `"UNIQUE"` are accepted, everything else is rejected). It feeds both -//! the two valid strings and fuzzer-controlled garbage, and proto -//! round-trips any constraint that parses. This covers the -//! string-validation boundary that the proto oneof never sees. +//! which parses the `constraint_type` *string*. Only the exact spellings +//! `"PRIMARY KEY"` and `"UNIQUE"` are accepted. The arm asserts both +//! directions of that boundary and the variant each accepted spelling maps +//! to, then proto round-trips the constraint inside a table desc. +//! +//! The assertions are the point of the arm, not decoration. Its input space +//! is only `CONSTRAINT_TYPES.len() * 4` fixed cases, so a round-trip-only +//! oracle would contribute nothing beyond "does not panic", and it would be +//! blind to all three ways this parser can regress: accepting a normalized +//! or unknown spelling, rejecting a valid one (which makes the whole arm +//! inert and indistinguishable from a passing arm), and swapping the two +//! variants (which a round trip preserves). The parsed variant 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. //! //! 3. **Decode-type arm.** Builds a `SqlServerColumnRaw` from a real SQL //! Server type name (`bit`, `tinyint`, `uniqueidentifier`, `xml`, @@ -46,18 +56,36 @@ #![no_main] -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use libfuzzer_sys::fuzz_target; use mz_proto::{ProtoType, RustType}; +use mz_sql_server_util::ProtoSqlServerTableDesc; use mz_sql_server_util::desc::{ - SqlServerColumnRaw, SqlServerTableConstraint, SqlServerTableConstraintRaw, SqlServerTableDesc, + SqlServerColumnDesc, SqlServerColumnRaw, SqlServerTableConstraint, SqlServerTableConstraintRaw, + SqlServerTableConstraintType, SqlServerTableDesc, }; -use mz_sql_server_util::{ProtoSqlServerColumnDesc, ProtoSqlServerTableDesc}; -use proptest::strategy::{Strategy, ValueTree}; +use proptest::strategy::{BoxedStrategy, Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; use prost::Message; +// `Arbitrary::arbitrary()` rebuilds the entire boxed strategy graph on every +// call: `SqlScalarType`'s ~31-variant `Union` plus a second copy of it for +// `Array`, the `prop_recursive` wrapper, and a `.*` regex compile per +// `any::()` leaf. `Config::default()` re-reads the process environment. +// Both are per-process constants, so pay for them once instead of once per +// execution. libFuzzer runs a single execution at a time per process, so a +// `thread_local` suffices for the non-`Sync` strategy. +thread_local! { + static DESC_STRATEGY: BoxedStrategy = + ::arbitrary().boxed(); +} + +fn config() -> Config { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(Config::default).clone() +} + /// Real SQL Server data-type spellings, chosen to exercise every branch of the /// product `parse_data_type` mapping and therefore every supported /// `SqlServerColumnDecodeType`. The trailing entries deliberately steer into @@ -68,8 +96,8 @@ const DATA_TYPES: &[&str] = &[ "smallint", // I16 "int", // I32 "bigint", // I64 - "real", // F32 (precision <= 24) - "float", // F64 + "real", // F32, selected by max_length == 4 + "float", // F64, selected by max_length == 8 "char", // String "varchar", // String "nvarchar", // String @@ -93,7 +121,8 @@ const DATA_TYPES: &[&str] = &[ ]; /// Constraint-type strings: the two the product accepts, plus garbage that -/// `SqlServerTableConstraint::try_from` must reject. +/// `SqlServerTableConstraint::try_from` must reject. Parsing is an exact match, +/// so the near-misses (case, whitespace) belong in the rejected set. const CONSTRAINT_TYPES: &[&str] = &[ "PRIMARY KEY", "UNIQUE", @@ -108,6 +137,10 @@ const CONSTRAINT_TYPES: &[&str] = &[ /// Assert that a `SqlServerTableDesc` survives a full Rust round-trip through /// its proto representation unchanged, including a re-encode/decode of the /// wire bytes. +/// +/// This covers each column and constraint too: `into_proto`/`from_proto` +/// delegate per element, and equality is structural, so a leaf that failed to +/// round-trip would show up here. fn assert_rust_roundtrip(orig: &SqlServerTableDesc) { let proto = orig.into_proto(); let bytes = proto.encode_to_vec(); @@ -149,47 +182,62 @@ fn craft_column(data: &[u8], idx: usize) -> SqlServerColumnRaw { // could, otherwise we trip the assertion on structurally-impossible input. // Every other type legitimately carries a range of lengths, so keep // fuzzing those across -1 (max), 16, and assorted small/arbitrary values. + // + // 4 and 8 are pinned because they are the *only* lengths that reach the + // `F32` and `F64` decode types: `real`/`float`/`double precision` choose by + // byte width rather than by name or precision. Leaving them to the two + // random branches made two documented decode types a coincidence. let max_length = if matches!(data_type, "text" | "ntext" | "image") { 16 } else { - match pick(2) % 4 { + match pick(2) % 6 { 0 => -1, 1 => 16, - 2 => i16::from(pick(3)), + 2 => 4, + 3 => 8, + 4 => i16::from(pick(3)), _ => i16::from_le_bytes([pick(3), pick(4)]), } }; + // Modulo 45, not 39: `parse_data_type` rejects a `precision` above 39, and + // rejects a `scale` that `NumericMaxScale` cannot hold. Capping both at 38 + // made those two branches unreachable by construction rather than merely + // rare. `SqlServerColumnDesc::new` turns either rejection into an + // `Unsupported` decode type, which still round-trips. SqlServerColumnRaw { name: format!("col{idx}").into(), data_type: data_type.into(), is_nullable: pick(1) & 1 == 0, max_length, - precision: pick(5) % 39, - scale: pick(6) % 39, + precision: pick(5) % 45, + scale: pick(6) % 45, is_computed: pick(7) & 1 == 0, } } fuzz_target!(|data: &[u8]| { - // Reserve the first byte as a mode selector and the next 32 bytes as the - // proptest seed. Everything after that feeds the raw-bytes / crafting - // logic so a single input can drive any arm. + // The first byte selects the arm. Every arm then reads from byte 1 on: + // only the proptest arm consumes a seed, and it is the one that consumes it, + // so there is no shared reservation to skip past. Carving out a fixed + // 32-byte seed window for all four arms would starve the other three, since + // libFuzzer grows inputs up from empty and their selector bytes would sit + // past the window at a length it takes a long time to reach. It would also + // decapitate any genuine encoded `ProtoSqlServerTableDesc` dropped into the + // corpus (which `--corpus-sync` accumulates) before the raw-bytes arm saw it. let mode = data.first().copied().unwrap_or(0); - let mut seed = [0u8; 32]; - let seed_src = data.get(1..33).unwrap_or(&[]); - seed[..seed_src.len()].copy_from_slice(seed_src); - let rest = data.get(33..).unwrap_or(&[]); + let tail = data.get(1..).unwrap_or(&[]); match mode % 4 { 0 => { - // Valid-value arm: drive proptest's Arbitrary from the seed. - let mut runner = TestRunner::new_with_rng( - Config::default(), - TestRng::from_seed(RngAlgorithm::ChaCha, &seed), - ); - let value = match ::arbitrary() - .new_tree(&mut runner) - { + // Valid-value arm: drive proptest's Arbitrary from the seed. Padded + // with zeros rather than requiring all 32 bytes, so short inputs + // still steer generation instead of all reusing the zero seed. + let mut seed = [0u8; 32]; + let n = tail.len().min(32); + seed[..n].copy_from_slice(&tail[..n]); + let mut runner = + TestRunner::new_with_rng(config(), TestRng::from_seed(RngAlgorithm::ChaCha, &seed)); + let value = match DESC_STRATEGY.with(|s| s.new_tree(&mut runner)) { Ok(tree) => tree.current(), Err(_) => return, }; @@ -198,23 +246,56 @@ fuzz_target!(|data: &[u8]| { 1 => { // Constraint-string arm: exercise the raw-ingest string parser for // both accepted and rejected `constraint_type` spellings. - let ty_idx = rest.first().copied().unwrap_or(0) as usize % CONSTRAINT_TYPES.len(); - let n_cols = (rest.get(1).copied().unwrap_or(0) % 4) as usize; - let columns: Vec = (0..n_cols).map(|i| format!("c{i}")).collect(); + let ty_idx = tail.first().copied().unwrap_or(0) as usize % CONSTRAINT_TYPES.len(); + let ty = CONSTRAINT_TYPES[ty_idx]; + let n_cols = (tail.get(1).copied().unwrap_or(0) % 4) as usize; + let column_names: Vec = (0..n_cols).map(|i| format!("c{i}")).collect(); let raw = SqlServerTableConstraintRaw { constraint_name: "fuzz_constraint".to_string(), - constraint_type: CONSTRAINT_TYPES[ty_idx].to_string(), - columns, + constraint_type: ty.to_string(), + columns: column_names.clone(), }; - // Garbage strings must be rejected. Valid ones must parse and then - // survive a proto round-trip inside a table desc. - let Ok(constraint) = SqlServerTableConstraint::try_from(raw) else { + let parsed = SqlServerTableConstraint::try_from(raw); + + let expected = match ty { + "PRIMARY KEY" => Some(SqlServerTableConstraintType::PrimaryKey), + "UNIQUE" => Some(SqlServerTableConstraintType::Unique), + _ => None, + }; + let Some(expected) = expected else { + assert!( + parsed.is_err(), + "constraint_type {ty:?} must be rejected, parsed as {parsed:?}" + ); return; }; + let constraint = parsed.expect("an accepted constraint_type must parse"); + assert_eq!( + constraint.constraint_type, expected, + "constraint_type {ty:?} mapped to the wrong variant" + ); + + // Give the desc the columns its constraint names, so the descriptor + // is structurally consistent. A future consistency check in + // `from_proto` would otherwise report this arm's own inputs. + let columns: Box<[SqlServerColumnDesc]> = column_names + .iter() + .map(|name| { + SqlServerColumnDesc::new(&SqlServerColumnRaw { + name: name.as_str().into(), + data_type: "int".into(), + is_nullable: false, + max_length: 4, + precision: 0, + scale: 0, + is_computed: false, + }) + }) + .collect(); let desc = SqlServerTableDesc { schema_name: "dbo".into(), name: "fuzz".into(), - columns: Box::new([]), + columns, constraints: vec![constraint], }; assert_rust_roundtrip(&desc); @@ -222,17 +303,17 @@ fuzz_target!(|data: &[u8]| { 2 => { // Decode-type arm: run the product type-mapping over real type // spellings and round-trip the resulting columns. - let n_cols = 1 + (rest.first().copied().unwrap_or(0) % 6) as usize; + let n_cols = 1 + (tail.first().copied().unwrap_or(0) % 6) as usize; let mut columns = Vec::with_capacity(n_cols); for i in 0..n_cols { // Give each column a distinct 8-byte window of the input. let off = 1 + i * 8; - let window = rest.get(off..).unwrap_or(&[]); + let window = tail.get(off..).unwrap_or(&[]); let raw = craft_column(window, i); - let mut desc = mz_sql_server_util::desc::SqlServerColumnDesc::new(&raw); + let mut desc = SqlServerColumnDesc::new(&raw); // Occasionally populate the deprecated PK-constraint field so // the `Option>` round-trip is covered too. - if rest.get(off).copied().unwrap_or(0) & 0x80 != 0 { + if tail.get(off).copied().unwrap_or(0) & 0x80 != 0 { desc.primary_key_constraint = Some(Arc::from("pk_fuzz")); } columns.push(desc); @@ -244,20 +325,10 @@ fuzz_target!(|data: &[u8]| { constraints: vec![], }; assert_rust_roundtrip(&desc); - - // Also assert the per-column proto leaf round-trips independently, - // which isolates the `decode_type` oneof + `column_type` mapping. - for col in desc.columns.iter() { - let proto: ProtoSqlServerColumnDesc = col.into_proto(); - let back: mz_sql_server_util::desc::SqlServerColumnDesc = proto - .into_rust() - .expect("column desc must convert back to Rust"); - assert_eq!(col, &back, "SqlServerColumnDesc changed across roundtrip"); - } } _ => { // Raw-bytes arm: decode arbitrary bytes directly. - check_decoded(rest); + check_decoded(tail); } } }); diff --git a/src/sql/src/session/vars/definitions.rs b/src/sql/src/session/vars/definitions.rs index f5251b85355ac..aa343cb5ca61b 100644 --- a/src/sql/src/session/vars/definitions.rs +++ b/src/sql/src/session/vars/definitions.rs @@ -2399,6 +2399,12 @@ mod tests { // We do this in a roundabout way, by first constructing all-false `OptimizerFeatures` and // then assigning them to their respective system vars, to ensure we don't forget to update // this test when new optimizer features are added. + // + // NOTE: if the new feature ships enabled, also turn it on in + // `mz_transform_fuzz::fuzz_features`, which the cargo-fuzz optimizer targets plan with. + // That helper falls back to `Default` (all-`false`) for anything it does not name, so a + // flag missing from it silently fuzzes the disabled path. This exhaustive destructuring is + // the tripwire for both. let false_features = OptimizerFeatures::default(); let OptimizerFeatures { enable_eq_classes_withholding_errors, diff --git a/src/storage-types/fuzz/fuzz_targets/csv_decode.rs b/src/storage-types/fuzz/fuzz_targets/csv_decode.rs index 7e5526a453bc6..d1f20fcd4a048 100644 --- a/src/storage-types/fuzz/fuzz_targets/csv_decode.rs +++ b/src/storage-types/fuzz/fuzz_targets/csv_decode.rs @@ -14,21 +14,46 @@ //! //! The first two bytes pick the decoder config (column count 1..=4, the field //! delimiter, and whether the first row is a validated header). The rest is fed -//! as the CSV stream, draining it through `decode` exactly as the storage decode -//! operator does. This exercises the buffer-growth (`OutputFull`/`OutputEndsFull`), -//! column-count-mismatch, invalid-UTF-8, and header-validation paths. +//! as a CSV object. Once its ordinary input is consumed, the target makes the +//! empty-input EOF call and resets the decoder as the storage decode operator +//! does. It then reuses the state for a second object. This exercises the +//! buffer-growth (`OutputFull`/`OutputEndsFull`), column-count-mismatch, +//! invalid-UTF-8, header-validation, EOF-finalization, and object-reset paths. #![no_main] use libfuzzer_sys::fuzz_target; use mz_storage_types::sources::encoding::{ColumnSpec, CsvDecoderState, CsvEncoding}; +fn decode_object(state: &mut CsvDecoderState, mut chunk: &[u8]) { + // Drain ordinary input a record at a time. Leave the empty slice for the + // explicit EOF call below. + while !chunk.is_empty() { + let before = chunk.len(); + match state.decode(&mut chunk) { + Ok(None) => break, + Ok(Some(_)) | Err(_) => { + if chunk.len() == before { + return; + } + } + } + } + + if chunk.is_empty() { + // csv_core needs a separate empty-input call to emit or reject a final + // record without a line terminator. + let _ = state.decode(&mut chunk); + state.reset_for_new_object(); + } +} + fuzz_target!(|data: &[u8]| { // First two bytes are the config, the remainder is the CSV stream. if data.len() < 2 { return; } - let (cfg, mut chunk) = data.split_at(2); + let (cfg, input) = data.split_at(2); let n_cols = usize::from(cfg[0] % 4) + 1; // Usually the standard comma, but sometimes an arbitrary delimiter byte // (which csv_core accepts) to reach unusual framing. @@ -42,20 +67,6 @@ fuzz_target!(|data: &[u8]| { }; let mut state = CsvDecoderState::new(CsvEncoding { columns, delimiter }); - - // Drain the stream a record at a time, like the decode operator. Each call - // returns one decoded record (or an error for a malformed one, having - // consumed it) until the input is exhausted (`Ok(None)`). The progress guard - // is belt-and-suspenders against a non-advancing call. - loop { - let before = chunk.len(); - match state.decode(&mut chunk) { - Ok(None) => break, - Ok(Some(_)) | Err(_) => { - if chunk.len() == before { - break; - } - } - } - } + decode_object(&mut state, input); + decode_object(&mut state, &input[input.len() / 2..]); }); diff --git a/src/storage-types/fuzz/fuzz_targets/dataflow_error_proto_roundtrip.rs b/src/storage-types/fuzz/fuzz_targets/dataflow_error_proto_roundtrip.rs index 8606af95bfcea..2150bcbd0ab8a 100644 --- a/src/storage-types/fuzz/fuzz_targets/dataflow_error_proto_roundtrip.rs +++ b/src/storage-types/fuzz/fuzz_targets/dataflow_error_proto_roundtrip.rs @@ -30,9 +30,9 @@ use libfuzzer_sys::fuzz_target; use mz_proto::ProtoType; use mz_storage_types::errors::{DataflowError, ProtoDataflowError}; -use prost::Message; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; +use prost::Message; /// Build a 32-byte proptest seed from `bytes` (zero-padded / truncated). fn seed_from(bytes: &[u8]) -> [u8; 32] { @@ -66,8 +66,8 @@ fuzz_target!(|data: &[u8]| { Config::default(), TestRng::from_seed(RngAlgorithm::ChaCha, &seed), ); - let Ok(tree) = ::arbitrary() - .new_tree(&mut runner) + let Ok(tree) = + ::arbitrary().new_tree(&mut runner) else { return; }; diff --git a/src/storage-types/fuzz/fuzz_targets/source_data_proto_roundtrip.rs b/src/storage-types/fuzz/fuzz_targets/source_data_proto_roundtrip.rs index 351994c15222d..e3ea748e4ca1a 100644 --- a/src/storage-types/fuzz/fuzz_targets/source_data_proto_roundtrip.rs +++ b/src/storage-types/fuzz/fuzz_targets/source_data_proto_roundtrip.rs @@ -34,9 +34,9 @@ use mz_proto::ProtoType; use mz_repr::Row; use mz_storage_types::errors::DataflowError; use mz_storage_types::sources::{ProtoSourceData, SourceData}; -use prost::Message; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; +use prost::Message; /// Build a 32-byte proptest seed from `bytes` (zero-padded / truncated). fn seed_from(bytes: &[u8]) -> [u8; 32] { @@ -72,14 +72,15 @@ fuzz_target!(|data: &[u8]| { TestRng::from_seed(RngAlgorithm::ChaCha, &seed), ); let value = if mode & 2 == 0 { - let Ok(tree) = ::arbitrary().new_tree(&mut runner) + let Ok(tree) = + ::arbitrary().new_tree(&mut runner) else { return; }; SourceData(Ok(tree.current())) } else { - let Ok(tree) = - ::arbitrary().new_tree(&mut runner) + let Ok(tree) = ::arbitrary() + .new_tree(&mut runner) else { return; }; diff --git a/src/storage-types/fuzz/fuzz_targets/source_export_statement_details_proto_roundtrip.rs b/src/storage-types/fuzz/fuzz_targets/source_export_statement_details_proto_roundtrip.rs index f6e9fef389758..4cdddc59e5a5b 100644 --- a/src/storage-types/fuzz/fuzz_targets/source_export_statement_details_proto_roundtrip.rs +++ b/src/storage-types/fuzz/fuzz_targets/source_export_statement_details_proto_roundtrip.rs @@ -25,11 +25,10 @@ //! * **Raw-bytes arm.** Decodes arbitrary bytes straight into the proto, //! exercising the decoder against malformed/adversarial wire input (including //! the SQL Server `Lsn` `try_from` length guard, which is only reachable from -//! raw bytes since a re-encoded `Lsn` is always exactly 10 bytes). -//! -//! `SourceExportStatementDetails` doesn't derive `PartialEq`/`Debug`, so -//! losslessness is asserted by comparing the canonical re-encoded bytes from -//! two successive `Rust -> Proto` round trips. +//! raw bytes since a re-encoded `Lsn` is always exactly 10 bytes). Since this +//! arm has no independently known Rust value, it checks rejection behavior +//! and stability, not whether each protobuf discriminant has the right Rust +//! mapping. #![no_main] @@ -40,9 +39,9 @@ use mz_proto::ProtoType; use mz_sql_server_util::desc::SqlServerTableDesc; use mz_storage_types::sources::load_generator::LoadGeneratorOutput; use mz_storage_types::sources::{ProtoSourceExportStatementDetails, SourceExportStatementDetails}; -use prost::Message; use proptest::strategy::{Strategy, ValueTree}; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; +use prost::Message; /// Build a 32-byte proptest seed from `bytes` (zero-padded / truncated). fn seed_from(bytes: &[u8]) -> [u8; 32] { @@ -66,7 +65,7 @@ fn encode(details: &SourceExportStatementDetails) -> Vec { .encode_to_vec() } -/// `Rust -> Proto -> Rust -> Proto` must reproduce the same canonical bytes. +/// `Rust -> Proto -> Rust` must preserve the value and canonical encoding. fn assert_roundtrip(orig: SourceExportStatementDetails) { let canonical = encode(&orig); let reparsed = ProtoSourceExportStatementDetails::decode(canonical.as_slice()) @@ -74,13 +73,44 @@ fn assert_roundtrip(orig: SourceExportStatementDetails) { let round: SourceExportStatementDetails = reparsed .into_rust() .expect("re-encoded SourceExportStatementDetails must convert back to Rust"); + assert_eq!( + orig, round, + "SourceExportStatementDetails value changed across proto roundtrip" + ); assert_eq!( canonical, encode(&round), - "SourceExportStatementDetails changed across proto roundtrip" + "SourceExportStatementDetails canonical encoding changed across proto roundtrip" ); } +fn load_generator_output(selector: u8) -> LoadGeneratorOutput { + use mz_storage_types::sources::load_generator::{AuctionView, MarketingView, TpchView}; + + match selector % 20 { + 0 => LoadGeneratorOutput::Default, + 1 => LoadGeneratorOutput::Auction(AuctionView::Organizations), + 2 => LoadGeneratorOutput::Auction(AuctionView::Users), + 3 => LoadGeneratorOutput::Auction(AuctionView::Accounts), + 4 => LoadGeneratorOutput::Auction(AuctionView::Auctions), + 5 => LoadGeneratorOutput::Auction(AuctionView::Bids), + 6 => LoadGeneratorOutput::Marketing(MarketingView::Customers), + 7 => LoadGeneratorOutput::Marketing(MarketingView::Impressions), + 8 => LoadGeneratorOutput::Marketing(MarketingView::Clicks), + 9 => LoadGeneratorOutput::Marketing(MarketingView::Leads), + 10 => LoadGeneratorOutput::Marketing(MarketingView::Coupons), + 11 => LoadGeneratorOutput::Marketing(MarketingView::ConversionPredictions), + 12 => LoadGeneratorOutput::Tpch(TpchView::Supplier), + 13 => LoadGeneratorOutput::Tpch(TpchView::Part), + 14 => LoadGeneratorOutput::Tpch(TpchView::Partsupp), + 15 => LoadGeneratorOutput::Tpch(TpchView::Customer), + 16 => LoadGeneratorOutput::Tpch(TpchView::Orders), + 17 => LoadGeneratorOutput::Tpch(TpchView::Lineitem), + 18 => LoadGeneratorOutput::Tpch(TpchView::Nation), + _ => LoadGeneratorOutput::Tpch(TpchView::Region), + } +} + fuzz_target!(|data: &[u8]| { let Some((&mode, rest)) = data.split_first() else { return; @@ -140,26 +170,15 @@ fuzz_target!(|data: &[u8]| { } } 3 => { - // Cover every `LoadGeneratorOutput` discriminant. - let output = match rest.first().copied().unwrap_or(0) % 4 { - 0 => LoadGeneratorOutput::Default, - 1 => LoadGeneratorOutput::Auction( - mz_storage_types::sources::load_generator::AuctionView::Bids, - ), - 2 => LoadGeneratorOutput::Marketing( - mz_storage_types::sources::load_generator::MarketingView::Leads, - ), - _ => LoadGeneratorOutput::Tpch( - mz_storage_types::sources::load_generator::TpchView::Customer, - ), - }; + let output = load_generator_output(rest.first().copied().unwrap_or(0)); SourceExportStatementDetails::LoadGenerator { output } } _ => SourceExportStatementDetails::Kafka {}, }; assert_roundtrip(value); } else { - // Raw-bytes arm: decode adversarial wire bytes, then round-trip. + // Raw-bytes arm: exercise rejection and fixed-point stability for + // adversarial wire bytes. The structured arm verifies known mappings. let Ok(proto) = ProtoSourceExportStatementDetails::decode(rest) else { return; }; diff --git a/src/storage-types/src/sources.rs b/src/storage-types/src/sources.rs index 9bbfd9ca5e50e..1533ee36906e7 100644 --- a/src/storage-types/src/sources.rs +++ b/src/storage-types/src/sources.rs @@ -908,6 +908,7 @@ impl crate::AlterCompatible for SourceExportDetails { /// to generate the appropriate `SourceExportDetails` struct during planning. /// NOTE that this is serialized as proto to the catalog, so any changes here /// must be backwards compatible or will require a migration. +#[derive(Debug, Eq, PartialEq)] pub enum SourceExportStatementDetails { Postgres { table: mz_postgres_util::desc::PostgresTableDesc, diff --git a/src/storage/fuzz/fuzz_targets/upsert_consolidate.rs b/src/storage/fuzz/fuzz_targets/upsert_consolidate.rs index fcaf5bc142556..9f1ab36272766 100644 --- a/src/storage/fuzz/fuzz_targets/upsert_consolidate.rs +++ b/src/storage/fuzz/fuzz_targets/upsert_consolidate.rs @@ -121,9 +121,9 @@ fn push_scalar(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Resul i32::arbitrary(u)?, i64::arbitrary(u)?, ))), - 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes( - <[u8; 16]>::arbitrary(u)?, - ))), + 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes(<[u8; 16]>::arbitrary( + u, + )?))), 11 => packer.push(Datum::MzTimestamp(Timestamp::from(u64::arbitrary(u)?))), 12 => { let len = u.int_in_range(0usize..=20)?; diff --git a/src/storage/fuzz/fuzz_targets/upsert_runtime.rs b/src/storage/fuzz/fuzz_targets/upsert_runtime.rs index 0f6d2259f1e25..6721ae725ec69 100644 --- a/src/storage/fuzz/fuzz_targets/upsert_runtime.rs +++ b/src/storage/fuzz/fuzz_targets/upsert_runtime.rs @@ -73,7 +73,9 @@ use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; // throughput bottleneck). static PARTS: OnceLock = OnceLock::new(); thread_local! { - static CFG: SourceExportCreationConfig = PARTS.get_or_init(FuzzUpsertParts::new).source_config(); + static CFG: SourceExportCreationConfig = { + PARTS.get_or_init(FuzzUpsertParts::new).source_config() + }; } fn rt() -> &'static tokio::runtime::Runtime { @@ -148,9 +150,9 @@ fn push_scalar(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Resul i32::arbitrary(u)?, i64::arbitrary(u)?, ))), - 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes( - <[u8; 16]>::arbitrary(u)?, - ))), + 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes(<[u8; 16]>::arbitrary( + u, + )?))), 11 => packer.push(Datum::MzTimestamp(Timestamp::from(u64::arbitrary(u)?))), 12 => { let len = u.int_in_range(0usize..=20)?; @@ -376,7 +378,11 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { }) .collect(); - let drain_to = commands.iter().map(|(ts, ..)| *ts).max().map_or(0, |m| m + 1); + let drain_to = commands + .iter() + .map(|(ts, ..)| *ts) + .max() + .map_or(0, |m| m + 1); let hook_commands: Vec<(u64, UpsertKey, u64, Option)> = commands .iter() diff --git a/src/storage/fuzz/fuzz_targets/upsert_state_consolidate.rs b/src/storage/fuzz/fuzz_targets/upsert_state_consolidate.rs index 0878367694fa4..7544cbdcfb93d 100644 --- a/src/storage/fuzz/fuzz_targets/upsert_state_consolidate.rs +++ b/src/storage/fuzz/fuzz_targets/upsert_state_consolidate.rs @@ -129,9 +129,9 @@ fn push_scalar(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Resul i32::arbitrary(u)?, i64::arbitrary(u)?, ))), - 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes( - <[u8; 16]>::arbitrary(u)?, - ))), + 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes(<[u8; 16]>::arbitrary( + u, + )?))), 11 => packer.push(Datum::MzTimestamp(Timestamp::from(u64::arbitrary(u)?))), 12 => { let len = u.int_in_range(0usize..=20)?; diff --git a/src/storage/fuzz/fuzz_targets/upsert_value_roundtrip_v2.rs b/src/storage/fuzz/fuzz_targets/upsert_value_roundtrip_v2.rs index 9cccc0f642ead..82cb9b6603139 100644 --- a/src/storage/fuzz/fuzz_targets/upsert_value_roundtrip_v2.rs +++ b/src/storage/fuzz/fuzz_targets/upsert_value_roundtrip_v2.rs @@ -83,7 +83,9 @@ fn push_scalar(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Resul } 7 => { if let Some(dt) = gen_naive_dt(u)? { - packer.push(Datum::Timestamp(CheckedTimestamp::from_timestamplike(dt).unwrap())); + packer.push(Datum::Timestamp( + CheckedTimestamp::from_timestamplike(dt).unwrap(), + )); } else { packer.push(Datum::Null); } @@ -103,9 +105,9 @@ fn push_scalar(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Resul i32::arbitrary(u)?, i64::arbitrary(u)?, ))), - 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes( - <[u8; 16]>::arbitrary(u)?, - ))), + 10 => packer.push(Datum::Uuid(uuid::Uuid::from_bytes(<[u8; 16]>::arbitrary( + u, + )?))), 11 => packer.push(Datum::MzTimestamp(Timestamp::from(u64::arbitrary(u)?))), 12 => { let len = u.int_in_range(0usize..=20)?; @@ -218,10 +220,7 @@ fn push_datum(packer: &mut RowPacker, u: &mut Unstructured) -> arbitrary::Result /// Generate a vector of `n` scalar datums by packing them into a scratch row and /// borrowing them back. (Composite packers need an iterator of `Datum`.) -fn gen_scalar_vec<'a>( - u: &mut Unstructured, - n: usize, -) -> arbitrary::Result>> { +fn gen_scalar_vec<'a>(u: &mut Unstructured, n: usize) -> arbitrary::Result>> { // We only emit `Copy`, `'static`-safe scalar datums here so the returned // `Datum`s don't borrow from a scratch buffer. That covers ints, bools, // numerics, dates, timestamps, intervals, uuids, and mz-timestamps. @@ -336,7 +335,10 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { container.push_into(row); let decoded = datum_seq_to_upsert_value(container.index(0)); - assert_eq!(value, decoded, "v2 upsert value encoding did not round-trip"); + assert_eq!( + value, decoded, + "v2 upsert value encoding did not round-trip" + ); Ok(()) } diff --git a/src/transform/fuzz/fuzz_targets/full_optimizer_equiv.rs b/src/transform/fuzz/fuzz_targets/full_optimizer_equiv.rs index 625eda2040522..c20d790b3af99 100644 --- a/src/transform/fuzz/fuzz_targets/full_optimizer_equiv.rs +++ b/src/transform/fuzz/fuzz_targets/full_optimizer_equiv.rs @@ -33,47 +33,83 @@ //! row is null in it), and //! * exact keys and cardinality inferred from the actual rows. //! -//! `NonNullRequirements`, `Demand`, `ReduceElision` (group key provably unique), -//! `RedundantJoin`, and `SemijoinIdempotence` all take code paths off that exact -//! information that opaque, all-nullable `Get`s never trigger. And they run here -//! in real pipeline order with their interactions, which the per-transform -//! `mir_relation_transforms` target (transforms in isolation) also cannot reach. -//! So this target sits in a real gap between the other two. +//! Two transforms actually see that: `NonNullRequirements`, which runs before the +//! first `fuse_and_collapse_fixpoint`, and `RedundantJoin`, which runs inside +//! `FuseAndCollapse` ahead of that fixpoint's trailing constant folding. Both +//! take code paths off exact nullability and keys that opaque, all-nullable +//! `Get`s never trigger, as do the `NormalizeOps`/`FuseAndCollapse` +//! canonicalizations. And they run here in real pipeline order with their +//! interactions, which the per-transform `mir_relation_transforms` target +//! (transforms in isolation) cannot reach. +//! +//! NOTE: `Demand`, `ReduceElision` and `SemijoinIdempotence` do *not* belong on +//! that list, though they branch on the same exact information. `Demand` sits in +//! `fixpoint_logical_01` and the other two in `fixpoint_logical_02`, both after +//! step 2's trailing `fold_constants_fixpoint`, and every operator this generator +//! emits has a `FoldConstants` arm, so by then the plan is a single `Constant` and +//! they are no-ops. The only executions that reach them with real relational +//! structure are the ones where folding bails at `FOLD_CONSTANTS_LIMIT`, which +//! needs roughly two nested 4-way joins over 4-row leaves. Nothing in the suite +//! covers those three against exact key/cardinality facts: the symbolic target's +//! `Get` deliberately carries the constant's stored all-nullable, keyless type. +//! Closing that would mean a leaf whose `Get` declares `constant.typ()`, which +//! belongs in `gen_get` there rather than here. //! //! Oracle: fold the input to its `(row, diff)` multiset, run the optimizer, fold //! the result. When both fold to a constant, the multisets must be equal. A //! divergence is a miscompile. The comparison is conservative (we only assert -//! when both sides fold, and skip when the optimizer returns an error, e.g. the -//! `Typecheck` pass rejecting a plan shape), so a surviving assertion failure or -//! a panic inside the optimizer is a genuine finding. +//! when both sides fold, and skip a plan matching the open bug CLU-137 via +//! `hits_non_strict_error_fold`), so a surviving assertion failure or a panic +//! inside the optimizer is a genuine finding. An optimizer *error* is not a skip: +//! a plan shape the typechecker rejects panics inside `Typecheck` rather than +//! becoming an `Err`, so every `TransformError` reaching us is an invariant +//! violation. See `mz_transform_fuzz::optimize`. #![no_main] use libfuzzer_sys::arbitrary::Unstructured; use libfuzzer_sys::fuzz_target; -use mz_transform_fuzz::{fold_to_multiset, gen_constant, gen_rel, optimize}; +use mz_transform_fuzz::{ + Collapse, collapse, fold_to_multiset, gen_constant, gen_rel, hits_non_strict_error_fold, + optimize, +}; fn run(u: &mut Unstructured) -> libfuzzer_sys::arbitrary::Result<()> { let mut leaf = gen_constant; let (rel, _schema, _nn) = gen_rel(u, 4, &mut leaf)?; + // Skip the shape of the open bug CLU-137, which the optimizer gets wrong for + // reasons unrelated to whatever else the plan exercises. + if hits_non_strict_error_fold(&rel) { + return Ok(()); + } + // The input must fold to actual rows for there to be anything to compare. let Some(baseline) = fold_to_multiset(rel.clone()) else { return Ok(()); }; - let Some(optimized) = optimize(rel.clone()) else { - return Ok(()); - }; + let optimized = optimize(rel.clone()); // The optimizer is semantics-preserving: the optimized plan must fold to the - // same multiset. We only assert when the optimized plan also folds (it should, - // since all leaves are constant), staying conservative about fold limitations. - if let Some(after) = fold_to_multiset(optimized) { - assert_eq!( + // same multiset. + // + // Fold the optimized side with `collapse`, not a single `FoldConstants` pass. + // `RelationCSE` can bind a repeated subexpression to a `Let`, and this + // generator hands it perfect candidates because the `Union` arm clones `inner` + // into both branches. `FoldConstants` does not propagate constants through + // `Let`/`Get`, so a single pass leaves such a plan unfolded and the assertion + // is skipped. That skip lands on exactly the executions worth checking: a + // `Let` survives only when the plan was still relational after step 2, i.e. + // when folding bailed at the row limit, which is also the only window in which + // the post-collapse stages ran on real structure at all. + match collapse(optimized) { + Collapse::Const(after) => assert_eq!( baseline, after, "the optimizer changed the result multiset\n{rel:?}" - ); + ), + // A genuine fold limitation, or still simplifying at the budget. + Collapse::StuckFixpoint | Collapse::BudgetExhausted => {} } Ok(()) } diff --git a/src/transform/fuzz/fuzz_targets/mir_relation_transforms.rs b/src/transform/fuzz/fuzz_targets/mir_relation_transforms.rs index fa6bc491492b5..e6023a2363350 100644 --- a/src/transform/fuzz/fuzz_targets/mir_relation_transforms.rs +++ b/src/transform/fuzz/fuzz_targets/mir_relation_transforms.rs @@ -22,7 +22,16 @@ //! Transforms exercised: `FoldConstants` itself, `CanonicalizeMfp` (Map/Filter/ //! Project chains), `UnionBranchCancellation`, the structural fusions //! (`Filter`/`Project`/`Map`/`Negate`/`Union`) and `ProjectionExtraction`, plus -//! a hand-written semantics-preserving structural rewrite. Where +//! a hand-written semantics-preserving structural rewrite. +//! +//! NOTE: the generator deliberately builds some nodes as raw variants rather than +//! through `MirRelationExpr`'s smart constructors. Those constructors 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`, and +//! `filter` merges, sorts and dedups predicates. Building only through them left +//! `MapFusion`, `NegateFusion` and `UnionFusion` unable to fire on any input, so +//! their assertions compared a plan against a clone of itself. Where //! `full_optimizer_equiv` runs the whole pipeline over constant-rooted plans, //! this target checks each transform in isolation, so a divergence points at a //! single transform rather than an interaction. @@ -42,6 +51,7 @@ use std::collections::BTreeMap; use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; +use mz_expr::visit::Visit; use mz_expr::{MirRelationExpr, MirScalarExpr}; use mz_repr::{Diff, ReprRelationType, Row}; use mz_transform::canonicalization::ProjectionExtraction; @@ -50,7 +60,8 @@ use mz_transform::fold_constants::FoldConstants; use mz_transform::fusion; use mz_transform::union_cancel::UnionBranchCancellation; use mz_transform_fuzz::{ - Ty, apply_recursively, fold_to_multiset, gen_constant, gen_scalar, rand_ty, + FOLD_ROW_LIMIT, Ty, apply_recursively, fold_to_multiset, gen_constant, gen_scalar, + hits_non_strict_error_fold, rand_ty, }; fn gen_rel(u: &mut Unstructured, depth: u32) -> arbitrary::Result<(MirRelationExpr, Vec)> { @@ -62,10 +73,26 @@ fn gen_rel(u: &mut Unstructured, depth: u32) -> arbitrary::Result<(MirRelationEx // Filter: 1-2 boolean predicates over the input columns, shape unchanged. 0 => { let n = u.int_in_range(1usize..=2)?; - let preds = (0..n) + let mut preds = (0..n) .map(|_| gen_scalar(u, Ty::Bool, &schema, 2)) .collect::>>()?; - (inner.filter(preds), schema) + // `filter` merges into an existing `Filter` and sorts/dedups, so the + // smart constructor cannot build the nested `Filter` that + // `fusion::filter`'s own loop looks for. Split the predicates across + // two real nodes sometimes. See `renest` note in the module doc. + let rel = if preds.len() == 2 && u.ratio(1u8, 2u8)? { + let outer = preds.pop().expect("len 2"); + MirRelationExpr::Filter { + input: Box::new(MirRelationExpr::Filter { + input: Box::new(inner), + predicates: preds, + }), + predicates: vec![outer], + } + } else { + inner.filter(preds) + }; + (rel, schema) } // Map: append one computed column. 1 => { @@ -73,7 +100,18 @@ fn gen_rel(u: &mut Unstructured, depth: u32) -> arbitrary::Result<(MirRelationEx let e = gen_scalar(u, ty, &schema, 2)?; let mut s = schema.clone(); s.push(ty); - (inner.map(vec![e]), s) + // `map` extends an existing `Map`'s scalar list rather than nesting, + // so `Map { input: Map { .. } }` is unbuildable through it, and that + // is the only shape `fusion::map` collapses. + let rel = if u.ratio(1u8, 2u8)? { + MirRelationExpr::Map { + input: Box::new(inner), + scalars: vec![e], + } + } else { + inner.map(vec![e]) + }; + (rel, s) } // Project: pick a (possibly reordered/duplicated) subset of columns. 2 => { @@ -83,10 +121,34 @@ fn gen_rel(u: &mut Unstructured, depth: u32) -> arbitrary::Result<(MirRelationEx for _ in 0..k { outputs.push(u.int_in_range(0..=len - 1)?); } - let s = outputs.iter().map(|&i| schema[i]).collect(); - (inner.project(outputs), s) + let s: Vec = outputs.iter().map(|&i| schema[i]).collect(); + // `project` composes into an existing `Project` instead of nesting. + let rel = if u.ratio(1u8, 2u8)? { + MirRelationExpr::Project { + input: Box::new(inner), + outputs, + } + } else { + inner.project(outputs) + }; + (rel, s) + } + 3 => { + // `negate` cancels against an existing `Negate`, so the doubled shape + // `fusion::negate` exists to collapse has to be built by hand. Two + // negations are semantically the identity, so the fold result is + // `inner`'s either way. + let rel = if u.ratio(1u8, 2u8)? { + MirRelationExpr::Negate { + input: Box::new(MirRelationExpr::Negate { + input: Box::new(inner), + }), + } + } else { + inner.negate() + }; + (rel, schema) } - 3 => (inner.negate(), schema), 4 => (inner.distinct(), schema), // Union `inner` with a cancelling counterpart. Instead of the trivial // `inner ∪ -inner`, the counterpart is `inner` wrapped in a random chain @@ -145,19 +207,38 @@ fn gen_rel(u: &mut Unstructured, depth: u32) -> arbitrary::Result<(MirRelationEx // `inner` filtered by a fresh predicate. let distinct_pred = gen_scalar(u, Ty::Bool, &schema, 2)?; let extra = inner.clone().filter(vec![distinct_pred]); - // Randomize branch order so the matcher's position search is exercised - // (`.union` flattens, so this yields a single 3-input `Union`). + // Randomize branch order so the matcher's position search is exercised. let [b0, b1, b2] = match u.int_in_range(0u8..=2)? { 0 => [right, extra, left], 1 => [extra, left, right], _ => [left, right, extra], }; - (b0.union(b1).union(b2), schema) + // `union` flattens both operands, so it yields a single 3-input + // `Union` and never the nested shape `fusion::union` collapses. Build + // that shape by hand sometimes; nesting does not change the multiset. + let rel = if u.ratio(1u8, 2u8)? { + MirRelationExpr::Union { + base: Box::new(b0), + inputs: vec![MirRelationExpr::Union { + base: Box::new(b1), + inputs: vec![b2], + }], + } + } else { + b0.union(b1).union(b2) + }; + (rel, schema) } }) } /// Wrap `rel` in a transformation that preserves its `(row, diff)` multiset. +/// +/// Every arm is built from raw variants. Through the smart constructors two of +/// these were unconditional no-ops, which silently reduced the oracle to +/// `fold(rel) == fold(rel)`: `filter` drops a literal-true predicate outright, +/// and `negate` cancels against the `Negate` the first call just added, so +/// `rel.negate().negate()` is the identity on every plan this generator builds. fn wrap_preserving( u: &mut Unstructured, rel: MirRelationExpr, @@ -165,9 +246,27 @@ fn wrap_preserving( ) -> arbitrary::Result { let identity = || (0..arity).collect::>(); Ok(match u.int_in_range(0u8..=3)? { - 0 => rel.project(identity()), - 1 => rel.filter(vec![MirScalarExpr::literal_true()]), - 2 => rel.negate().negate(), + // A reversing projection composed with its own inverse. + 0 => { + let rev: Vec = (0..arity).rev().collect(); + MirRelationExpr::Project { + input: Box::new(MirRelationExpr::Project { + input: Box::new(rel), + outputs: rev.clone(), + }), + outputs: rev, + } + } + // A `Filter` node that is actually present in the tree. + 1 => MirRelationExpr::Filter { + input: Box::new(rel), + predicates: vec![MirScalarExpr::literal_true()], + }, + 2 => MirRelationExpr::Negate { + input: Box::new(MirRelationExpr::Negate { + input: Box::new(rel), + }), + }, _ => rel .map(vec![MirScalarExpr::literal_true()]) .project(identity()), @@ -208,6 +307,13 @@ fn assert_same_rows( fn run(u: &mut Unstructured) -> arbitrary::Result<()> { let (rel, schema) = gen_rel(u, 5)?; + + // Skip the shape of the open bug CLU-137. `CanonicalizeMfp` and + // `FoldConstants` both reach the `reduce` fold it lives in. + if hits_non_strict_error_fold(&rel) { + return Ok(()); + } + let baseline = fold_to_multiset(rel.clone()); // A hand-written semantics-preserving structural rewrite. @@ -235,32 +341,49 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { } // Structural fusions. Each is a purely local, semantics-preserving rewrite - // applied across the whole tree (pre-order, matching their real drivers). - // None changes the result multiset or the output shape, on any input. - for (who, action) in [ + // applied across the whole tree, in the traversal order its real driver uses: + // the `Filter`/`Project`/`Map`/`Negate` fusions call `visit_mut_pre`, while + // `fusion::union` and `ProjectionExtraction` call `visit_mut_post`. None + // changes the result multiset or the output shape, on any input. + for (who, action, post_order) in [ ( "FilterFusion", fusion::filter::Filter::action as fn(&mut MirRelationExpr), + false, ), - ("ProjectFusion", fusion::project::Project::action), - ("MapFusion", fusion::map::Map::action), - ("NegateFusion", fusion::negate::Negate::action), - ("UnionFusion", fusion::union::Union::action), - ("ProjectionExtraction", ProjectionExtraction::action), + ("ProjectFusion", fusion::project::Project::action, false), + ("MapFusion", fusion::map::Map::action, false), + ("NegateFusion", fusion::negate::Negate::action, false), + ("UnionFusion", fusion::union::Union::action, true), + ("ProjectionExtraction", ProjectionExtraction::action, true), ] { let mut r = rel.clone(); let before = r.typ(); - r.visit_pre_mut(action); + let mut action = action; + if post_order { + r.visit_mut_post(&mut action); + } else { + r.visit_pre_mut(action); + } assert_shape(&before, &r.typ(), who, &rel); assert_same_rows(&baseline, r, who, &rel); } // FoldConstants: the evaluator itself must at least preserve shape. { - let mut r = rel; + let mut r = rel.clone(); let before = r.typ(); - if apply_recursively(FoldConstants { limit: None }, &mut r).is_ok() { - assert_shape(&before, &r.typ(), "FoldConstants", &r); + if apply_recursively( + FoldConstants { + limit: Some(FOLD_ROW_LIMIT), + }, + &mut r, + ) + .is_ok() + { + // Report the input plan, not the folded one: the folded output is the + // harder direction to triage a divergence from. + assert_shape(&before, &r.typ(), "FoldConstants", &rel); } } Ok(()) diff --git a/src/transform/fuzz/fuzz_targets/optimizer_symbolic_equiv.rs b/src/transform/fuzz/fuzz_targets/optimizer_symbolic_equiv.rs index 195ce44decf64..16d240bd026ee 100644 --- a/src/transform/fuzz/fuzz_targets/optimizer_symbolic_equiv.rs +++ b/src/transform/fuzz/fuzz_targets/optimizer_symbolic_equiv.rs @@ -31,9 +31,13 @@ //! `Let`/local `Get` bindings (e.g. from CSE) are collapsed by `collapse`, which //! iterates `FoldConstants` + `NormalizeLets` until the plan reduces to a //! `Constant`. The comparison is conservative (only asserted when both sides -//! fold, a `Typecheck`/optimizer error is a skip), so a surviving divergence or -//! an optimizer panic is a genuine finding. It covers the symbolic-input -//! planning that the constant-rooted target cannot reach. +//! fold, and a plan matching the open bug CLU-137 is skipped via +//! `hits_non_strict_error_fold`), so a surviving divergence or an optimizer panic +//! is a genuine finding. An optimizer *error* is not a skip: a rejected plan +//! shape panics inside `Typecheck` long before it could become one, so every +//! `TransformError` that gets here is an invariant violation. See +//! `mz_transform_fuzz::optimize`. It covers the +//! symbolic-input planning that the constant-rooted target cannot reach. #![no_main] @@ -42,11 +46,10 @@ use std::collections::BTreeMap; use libfuzzer_sys::arbitrary::{self, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_expr::{Id, MirRelationExpr}; -use mz_repr::optimize::OptimizerFeatures; -use mz_repr::{Diff, GlobalId, Row}; -use mz_transform::fold_constants::FoldConstants; -use mz_transform::normalize_lets::NormalizeLets; -use mz_transform_fuzz::{Ty, apply_recursively, gen_constant, gen_rel, optimize}; +use mz_repr::GlobalId; +use mz_transform_fuzz::{ + Collapse, Ty, collapse, gen_constant, gen_rel, hits_non_strict_error_fold, optimize, +}; /// A symbolic `Get` leaf bound (in `data`) to a fresh constant collection. fn gen_get( @@ -89,69 +92,6 @@ fn substitute(mut rel: MirRelationExpr, data: &BTreeMap) - rel } -/// Outcome of trying to fold a (`Get`-free) plan all the way to a `Constant`. -enum Collapse { - /// Reduced to a `Constant` of `Ok` rows. The consolidated `(row, diff)` - /// multiset is the actual result. - Const(BTreeMap), - /// Reached a fixpoint of `FoldConstants` + `NormalizeLets` (applying them no - /// longer changes the plan) that is *not* a constant, e.g. the plan errors, - /// or folding genuinely cannot evaluate it. This is a legitimate - /// fold-limitation skip, not a coverage gap. - StuckFixpoint, - /// Hit the iteration budget without reaching either a constant or a - /// fixpoint. The plan was still simplifying when we ran out of passes. Kept - /// distinct from `StuckFixpoint` only to name the two skip reasons. - /// `FoldConstants` does not promise a constant input collapses to a - /// `Constant` within any limit, so this is a conservative skip too. - BudgetExhausted, -} - -/// Fold a (now `Get`-free) plan to a `Constant` by iterating `FoldConstants` + -/// `NormalizeLets` (to collapse any `Let`s the optimizer's CSE introduced) until -/// it either becomes a `Constant`, reaches a fixpoint, or exhausts the budget. -/// -/// This loops to a genuine fixpoint (stops only when a pass leaves the plan -/// unchanged), so a plan that just needs a few more passes converges rather than -/// being dropped. The budget is a generous guard against a non-terminating -/// rewrite. -fn collapse(mut rel: MirRelationExpr) -> Collapse { - let features = OptimizerFeatures::default(); - const BUDGET: usize = 64; - for _ in 0..BUDGET { - let before = rel.clone(); - if apply_recursively(FoldConstants { limit: None }, &mut rel).is_err() { - return Collapse::StuckFixpoint; - } - if rel.as_const().is_some() { - break; - } - if NormalizeLets::new(true) - .action(&mut rel, &features) - .is_err() - { - return Collapse::StuckFixpoint; - } - // A full pass that changed nothing means we will never reach a constant. - if rel == before { - return Collapse::StuckFixpoint; - } - } - let Some(constant) = rel.as_const() else { - // Still simplifying when the budget ran out. - return Collapse::BudgetExhausted; - }; - let (Ok(rows), _) = constant else { - return Collapse::StuckFixpoint; - }; - let mut multiset: BTreeMap = BTreeMap::new(); - for (row, diff) in rows { - *multiset.entry(row.clone()).or_insert(Diff::ZERO) += *diff; - } - multiset.retain(|_, d| *d != Diff::ZERO); - Collapse::Const(multiset) -} - fn run(u: &mut Unstructured) -> arbitrary::Result<()> { let mut next_id = 0u64; let mut data = BTreeMap::new(); @@ -160,18 +100,35 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { gen_rel(u, 3, &mut leaf)? }; + // Skip the shape of the open bug CLU-137, which the optimizer gets wrong for + // reasons unrelated to whatever else the plan exercises. The bound data is + // checked too: a `Get`'s constant collection carries no scalars today, but + // `substitute` inlines it into the plan the optimizer sees. + if hits_non_strict_error_fold(&plan) || data.values().any(hits_non_strict_error_fold) { + return Ok(()); + } + // Ground truth: inline the data into the input plan and fold. Only proceed // when the *input* (which has no optimizer-introduced `Let`s) folds to a // constant, that is what gives us a result to compare against. + // Optimize with the Gets still symbolic, then inline the same data and fold. + // + // Optimize *before* deciding whether the baseline is comparable. A plan whose + // result is an evaluation error folds to an `Err` constant, which `collapse` + // reports as a skip, and returning at that point would mean never calling the + // optimizer on it at all. That is a large and deliberately generated class: + // `gen_scalar` spends one of its three literal-leaf choices on + // `EvalError::DivisionByZero`, and any such literal under a `Map`/`Filter` + // over a non-empty collection poisons the whole fold, as does a `Reduce` over + // a net-negative collection. Optimizing first keeps the "a panic in the + // optimizer is a finding" coverage over those inputs, which is the coverage + // they were generated for. + let optimized = optimize(plan.clone()); + let baseline = match collapse(substitute(plan.clone(), &data)) { Collapse::Const(b) => b, Collapse::StuckFixpoint | Collapse::BudgetExhausted => return Ok(()), }; - - // Optimize with the Gets still symbolic, then inline the same data and fold. - let Some(optimized) = optimize(plan.clone()) else { - return Ok(()); - }; match collapse(substitute(optimized, &data)) { Collapse::Const(after) => assert_eq!( baseline, after, diff --git a/src/transform/fuzz/src/lib.rs b/src/transform/fuzz/src/lib.rs index 01d0ed32b7961..cb09a96ee3d3e 100644 --- a/src/transform/fuzz/src/lib.rs +++ b/src/transform/fuzz/src/lib.rs @@ -36,6 +36,7 @@ use mz_repr::optimize::OptimizerFeatures; use mz_repr::{Datum, Diff, GlobalId, ReprColumnType, ReprRelationType, ReprScalarType, Row}; use mz_transform::dataflow::DataflowMetainfo; use mz_transform::fold_constants::FoldConstants; +use mz_transform::normalize_lets::NormalizeLets; use mz_transform::{Optimizer, Transform, TransformCtx, TransformError, typecheck}; /// The scalar types the fuzz targets generate over. @@ -233,6 +234,14 @@ fn gen_aggregate(u: &mut Unstructured, schema: &[Ty]) -> arbitrary::Result<(Aggr /// `Get` (and records its backing data on the side). Either way `leaf` returns a /// relation and its column schema; leaves are assumed non-negative. /// +/// `leaf` 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 the `Reduce` fallback references `column(0)`. Worse than +/// either, `MirRelationExpr::join_scalars` drops an arity-0 single-row input +/// *after* `join` has computed the equivalences' global column offsets over the +/// full input list, so both the equivalences and the schema returned here would +/// silently point at the wrong columns. +/// /// The non-negativity flag is the contract `TopK` (and every dataflow reduction) /// requires of its input, so we only place a `TopK` directly over a non-negative /// subtree. See the `TopK` arm. @@ -415,6 +424,59 @@ where }) } +/// The `OptimizerFeatures` every target here plans with: production's defaults. +/// +/// NOTE: `OptimizerFeatures::default()` is all-`false`, which is *not* what any +/// deployment runs. Several transforms branch on these, and +/// `EquivalencePropagation` reads three of them, so planning with the derived +/// default exercises only the legacy paths. `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. +/// +/// Only the flags whose production default is `true` are listed; the rest come +/// from `Default`. A newly added flag therefore arrives here as `false`, which is +/// wrong if it ships enabled, but the tripwire for that lives where it belongs: +/// `mz_sql`'s `optimizer_features_no_enable_for_item_parsing` destructures +/// `OptimizerFeatures` exhaustively, so a new field fails a fast unit test rather +/// than this crate's multi-minute sanitizer build. Source of truth for the values +/// is each flag's `default:` in `mz_sql::session::vars::definitions`. +pub fn fuzz_features() -> OptimizerFeatures { + OptimizerFeatures { + enable_new_outer_join_lowering: true, + enable_reduce_mfp_fusion: true, + enable_variadic_left_join_lowering: true, + enable_letrec_fixpoint_analysis: true, + enable_projection_pushdown_after_relation_cse: true, + enable_less_reduce_in_eqprop: true, + enable_dequadratic_eqprop_map: true, + enable_eq_classes_withholding_errors: true, + enable_cast_elimination: true, + enable_simplify_quantified_comparisons: true, + enable_simplify_from_less_existence: true, + enable_coalesce_case_transform: true, + enable_will_distinct_propagation: true, + enable_fixed_correlated_cte_lowering: true, + persist_fast_path_limit: 25, + ..Default::default() + } +} + +/// Row cap for the oracles' constant folding. +/// +/// `FoldConstants`' join arm materializes the full cross product *before* +/// applying equivalences, and `limit: None` disables its only size check, so an +/// unbounded fold is the harness asking for a `Vec<(Row, Diff)>` bounded only by +/// the product of every leaf's row count. Generated join trees nest, so that is +/// reachable in principle and would surface as an `oom-*`/`timeout-*` artifact +/// blamed on the optimizer. +/// +/// Declining to fold is already a benign skip on both oracles, so a cap costs +/// nothing: it is well above the largest product measured over these generators +/// (~5e5 rows) while keeping peak allocation in the hundreds of MB against the +/// runner's `-rss_limit_mb=4096`. +pub const FOLD_ROW_LIMIT: usize = 1_000_000; + /// Apply `transform` over the whole plan through its recursive driver /// (`Transform::transform` -> `actually_perform_transform`), not `action`. /// @@ -427,7 +489,7 @@ pub fn apply_recursively( transform: T, rel: &mut MirRelationExpr, ) -> Result<(), TransformError> { - let features = OptimizerFeatures::default(); + let features = fuzz_features(); let typecheck_ctx = typecheck::empty_typechecking_context(); let mut df_meta = DataflowMetainfo::default(); let mut ctx = TransformCtx::local( @@ -442,8 +504,26 @@ pub fn apply_recursively( /// Fold `rel`. If it reduces to a `Constant` of `Ok` rows, return the /// consolidated `(row, diff)` multiset, otherwise `None`. +/// +/// `None` covers two benign cases: the plan did not fold all the way down to a +/// `Constant`, and it folded to an `EvalError`. The latter is not a blind spot +/// but a required tolerance. The optimizer is knowingly imprecise about error +/// semantics, `predicate_pushdown` will push a predicate that can error into a +/// join input and manufacture an error the unoptimized plan never raises (see +/// the comment there and database-issues#6258), so one side folding to an error +/// while the other yields rows is accepted behaviour and would otherwise fire +/// roughly once in every 1,500 executions. +/// +/// A `TransformError` is a different matter and panics, see [`optimize`]. pub fn fold_to_multiset(mut rel: MirRelationExpr) -> Option> { - apply_recursively(FoldConstants { limit: None }, &mut rel).ok()?; + if let Err(e) = apply_recursively( + FoldConstants { + limit: Some(FOLD_ROW_LIMIT), + }, + &mut rel, + ) { + panic!("FoldConstants returned an error: {e}"); + } let (Ok(rows), _) = rel.as_const()? else { return None; }; @@ -455,11 +535,60 @@ pub fn fold_to_multiset(mut rel: MirRelationExpr) -> Option> Some(multiset) } -/// Run the full logical optimizer. Returns `None` if it errors (e.g. the -/// `Typecheck` pass rejects the plan). Only a panic is a finding here. +/// True if `rel` has an erroring operand under a non-strict `AND`/`OR`/ +/// `error_if_null`, the shape of the open bug CLU-137. +/// +/// Those three swallow an operand's error once another operand fixes the result: +/// `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. `reduce`'s generic variadic fold nonetheless replaces the whole call +/// with an operand's literal error, and `undistribute_and_or` can recombine an +/// erroring operand across the short-circuit boundary. Either way the optimizer +/// can turn a row the plan should emit into an error, and, once the folded +/// literal is typed non-nullable, into a *different* row: that is how the count +/// of a nullable aggregate becomes the count of a non-nullable one. +/// +/// CLU-137 tracks the fix (see the closed PR #37299 for a full one). Until it +/// lands, the equivalence oracles skip these plans rather than rediscover it on +/// every run. +/// +/// Deliberately conservative. It asks whether an operand *could* error rather +/// than whether it already holds a literal error, because `reduce` folds a +/// column-free fallible operand (`9223372036854775807 + 1`, `1 / 0`) to a +/// literal error first and absorbs it after. The cost is that a plan whose +/// AND/OR operands merely *might* error is skipped even where the fold could not +/// have fired: measured at 3.6% of `gen_rel(depth = 4)` plans. +pub fn hits_non_strict_error_fold(rel: &MirRelationExpr) -> bool { + let mut hit = false; + rel.visit_scalars(&mut |scalar| { + // One definition of the shape, in `mz_expr` next to the fold it describes, + // so the several oracles that skip CLU-137 cannot drift apart on which + // functions count as non-strict. They already had: this predicate covered + // `ErrorIfNull` while `mir_scalar_reduce`'s copy did not. + hit |= scalar.could_hit_nonstrict_error_fold(); + }); + hit +} + +/// Run the full logical optimizer. +/// +/// NOTE: a `TransformError` from here is itself a finding, so this panics rather +/// than reporting one. The tempting reading, that a plan shape the optimizer +/// rejects comes back as an error to skip, is wrong in both directions. +/// `Typecheck` returns `Ok(())` on every path and routes a type error through +/// `type_error!(true, ..)` -> `soft_panic_or_log!`, and `Fixpoint` +/// non-convergence does the same. Soft assertions default to +/// `cfg!(debug_assertions)`, which cargo-fuzz enables, so a rejected plan is +/// already a crash before it can reach us. What is left that can error are +/// optimizer invariant violations: a `Let`/`Get` on an unbound local id, a `Let` +/// whose type changed under it, an ANF rebinding that lost an identifier. Those +/// surface to users as `internal error`, so swallowing them would leave this +/// harness green through exactly the `normalize_lets`/`cse` regressions it is +/// best placed to catch. #[allow(deprecated)] -pub fn optimize(rel: MirRelationExpr) -> Option { - let features = OptimizerFeatures::default(); +pub fn optimize(rel: MirRelationExpr) -> MirRelationExpr { + let features = fuzz_features(); let typecheck_ctx = typecheck::empty_typechecking_context(); let mut df_meta = DataflowMetainfo::default(); let mut ctx = TransformCtx::local( @@ -470,8 +599,83 @@ pub fn optimize(rel: MirRelationExpr) -> Option { Some(GlobalId::Transient(1)), ); let optimizer = Optimizer::logical_optimizer(&mut ctx); - optimizer - .optimize(rel, &mut ctx) - .ok() - .map(|o| o.into_inner()) + match optimizer.optimize(rel, &mut ctx) { + Ok(optimized) => optimized.into_inner(), + Err(e) => panic!("logical optimizer returned an error: {e}"), + } +} + +/// Outcome of trying to fold a (`Get`-free) plan all the way to a `Constant`. +pub enum Collapse { + /// Reduced to a `Constant` of `Ok` rows. The consolidated `(row, diff)` + /// multiset is the actual result. + Const(BTreeMap), + /// Either folding reached a fixpoint that is not a constant (a legitimate + /// fold limitation), or it folded all the way to an `Err` constant. + /// + /// NOTE: those two are not the same thing, and the second is not a fold + /// limitation at all: an `EvalError` constant is a fully determined result. + /// They share a variant because neither yields a `(row, diff)` multiset to + /// compare. Splitting them would let an oracle notice a baseline of + /// `Ok(rows)` becoming an `Err` after optimization, but only in that + /// direction: `Err -> Ok` is legitimate, since `Demand` can drop an unused + /// erroring `Map` column. Even the `Ok -> Err` direction is not assertable + /// today, because `predicate_pushdown` knowingly manufactures errors the + /// unoptimized plan never raises (database-issues#6258). + StuckFixpoint, + /// Hit the iteration budget without reaching either a constant or a + /// fixpoint. The plan was still simplifying when we ran out of passes. Kept + /// distinct from `StuckFixpoint` only to name the two skip reasons. + /// `FoldConstants` does not promise a constant input collapses to a + /// `Constant` within any limit, so this is a conservative skip too. + BudgetExhausted, +} + +/// Fold a (now `Get`-free) plan to a `Constant` by iterating `FoldConstants` + +/// `NormalizeLets` (to collapse any `Let`s the optimizer's CSE introduced) until +/// it either becomes a `Constant`, reaches a fixpoint, or exhausts the budget. +/// +/// This loops to a genuine fixpoint (stops only when a pass leaves the plan +/// unchanged), so a plan that just needs a few more passes converges rather than +/// being dropped. The budget is a generous guard against a non-terminating +/// rewrite. +pub fn collapse(mut rel: MirRelationExpr) -> Collapse { + let features = fuzz_features(); + const BUDGET: usize = 64; + for _ in 0..BUDGET { + let before = rel.clone(); + // A `TransformError` here is an optimizer invariant violation, not a + // reason to give up on the plan. See `mz_transform_fuzz::optimize`. + if let Err(e) = apply_recursively( + FoldConstants { + limit: Some(FOLD_ROW_LIMIT), + }, + &mut rel, + ) { + panic!("FoldConstants returned an error: {e}"); + } + if rel.as_const().is_some() { + break; + } + if let Err(e) = NormalizeLets::new(true).action(&mut rel, &features) { + panic!("NormalizeLets returned an error: {e}"); + } + // A full pass that changed nothing means we will never reach a constant. + if rel == before { + return Collapse::StuckFixpoint; + } + } + let Some(constant) = rel.as_const() else { + // Still simplifying when the budget ran out. + return Collapse::BudgetExhausted; + }; + let (Ok(rows), _) = constant else { + return Collapse::StuckFixpoint; + }; + let mut multiset: BTreeMap = BTreeMap::new(); + for (row, diff) in rows { + *multiset.entry(row.clone()).or_insert(Diff::ZERO) += *diff; + } + multiset.retain(|_, d| *d != Diff::ZERO); + Collapse::Const(multiset) } diff --git a/test/cargo-fuzz/mzcompose.py b/test/cargo-fuzz/mzcompose.py index d17eb4ddc3984..7666aa729c0a5 100644 --- a/test/cargo-fuzz/mzcompose.py +++ b/test/cargo-fuzz/mzcompose.py @@ -53,10 +53,14 @@ # The highest-yield targets, the ones that keep surfacing bugs deep into a run, # or that guard a bug-prone path / actively-developed subsystem where a find # would be catastrophic. `--profile fruitful` restricts the run to these, which -# is the right focus for the long (24h) release-qualification run that should -# spend its cores where bugs still hide. Substring-matched against +# points a short run at the code where bugs still hide. Substring-matched against # `crate::target`, like the positional `filters`. # +# NOTE: This is *not* what release qualification runs. That run passes +# `--profile all`. A target nobody ever 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. +# # This set is pruned by productivity. Targets over well-tested, stable code that # fuzz clean round after round (the arithmetic/range oracles, internal # encode/decode round-trips, simple jsonb access) are dropped, since they've @@ -76,10 +80,18 @@ "strconv_parse_timestamptz", "strconv_parse_date", "strconv_parse_time", + "strconv_parse_interval", "strconv_parse_bytes", "strconv_parse_uuid", + # The two durable-state decoders (rollup = full snapshot, state diff = + # incremental). Both are read from blob/consensus on every state load, and a + # decode panic there makes the shard unloadable, so they stay paired here. "rollup_proto_roundtrip", + "state_diff_proto_roundtrip", "copy_decode", + # The pgwire frontend decoder and the pre-auth SASL/password grammars behind + # it, reachable by any client that can open a socket. + "codec_decode", "protobuf_decode_fuzzed_schema", "json_encode", "avro_decode_fuzzed_schema", @@ -330,9 +342,16 @@ class FuzzRunner: fail_fast: bool triple: str = "" # None => don't pass --sanitizer (use cargo-fuzz's default, i.e. ASan). - # The CLI defaults this to "none" (see below): our targets find panics / - # round-trip drifts, not memory-corruption bugs, so ASan adds no detection - # power here but ~2-3x slowdown. Pass --sanitizer=address to opt back in. + # The CLI defaults this to "none" (see below): our targets mostly find + # panics / round-trip drifts, and ASan costs a ~2-3x slowdown. Pass + # --sanitizer=address to opt back in. + # + # NOTE: a few targets do reach memory-unsafe code through FFI, e.g. + # mz-repr's `ProtoNumeric` decode calling libdecnumber's unchecked + # `decPackedToNumber`. ASan would not report those writes even when + # enabled: cargo-fuzz instruments via RUSTFLAGS only, while the C is built + # by the `cc` crate, so covering it needs CFLAGS=-fsanitize=address too. + # Bound such input in the decoder rather than relying on a sanitizer. sanitizer: str | None = None wall_budget: int = 0 minimize: bool = True @@ -998,8 +1017,8 @@ def workflow_default(c: Composition, parser: WorkflowArgumentParser) -> None: help="`fruitful` restricts the run to the historically high-yield " "targets (see FRUITFUL): the SQL-parser round-trip oracles and the rich " "hand-written PG parsers/decoders that keep finding bugs, ideal for a " - "long local run. `all` (default) runs every target. A `filters` list " - "narrows further within the profile.", + "short local run. `all` (default, and what release qualification uses) " + "runs every target. A `filters` list narrows further within the profile.", ) parser.add_argument( "filters", From adcf05280a888b5488fe5df046bdd2dcb9fc4601 Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Fri, 31 Jul 2026 19:02:18 +0000 Subject: [PATCH 3/4] tests: canonicalize sign-folded numeric literals in the sql_roundtrip oracle The `sql_roundtrip` fuzz target's `normalize` stripped `Expr::Nested` but did not canonicalize the sign of a numeric literal, so `- (2)` normalized to `Op("-", Number("2"))`. The parser never produces that AST: a `-` directly in front of a nonzero number folds into `Number("-2")`. `check_display` then printed the normalized AST as `- 2`, reparsed it back to `Number("-2")`, and reported structural drift. A false positive, not a printer bug. Port the canonicalization the sibling `grammar` target has carried since both targets were added: rewrite `Number("-n")` to a unary minus over the bare number, on both sides of the comparison. The parser chooses between the two forms by context (a leading `- 1` folds, `a + - 1` does not), so they have to compare equal. The rewrite keys off the leading `-` in the literal text, which only the parser's own fold can put there, so `- -2` does not become `--2`. `- 0` keeps its `Op` form on both sides (the parser only folds nonzero numbers), leaving the signed-zero print path under the oracle. Co-Authored-By: Claude Opus 5 (1M context) --- .../fuzz/fuzz_targets/sql_roundtrip.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs b/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs index ec76ad158a394..a1aa6dbeb029b 100644 --- a/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs +++ b/src/sql-parser/fuzz/fuzz_targets/sql_roundtrip.rs @@ -71,7 +71,9 @@ use libfuzzer_sys::arbitrary::{Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_sql_parser::ast::display::AstDisplay; use mz_sql_parser::ast::visit_mut::{self, VisitMut}; -use mz_sql_parser::ast::{AlterRoleOption, AstInfo, Expr, Raw, RoleAttribute, Statement}; +use mz_sql_parser::ast::{ + AlterRoleOption, AstInfo, Expr, Op, Raw, RoleAttribute, Statement, Value, +}; use mz_sql_parser::parser::parse_statements; use mz_sql_pretty::pretty_str_simple; @@ -80,8 +82,12 @@ use mz_sql_pretty::pretty_str_simple; // --------------------------------------------------------------------------- /// Strip syntactic noise so AST equality reflects *semantic* fidelity: -/// `Declare`/`Prepare` capture raw text, and `Expr::Nested` records parens that -/// the printer is free to add or drop. See `parse_pretty_roundtrip` for detail. +/// `Declare`/`Prepare` capture raw text, `Expr::Nested` records parens that the +/// printer is free to add or drop, and a negative numeric literal is the same +/// value whether the parser folded the sign in (`Number("-1")`) or left a unary +/// op (`- 1`). The parser chooses by *context* (a leading `- 1` folds, `a + - 1` +/// does not), so the two forms must compare equal. See `parse_pretty_roundtrip` +/// for detail. fn normalize(stmt: &mut Statement) { match stmt { Statement::Declare(d) => { @@ -137,6 +143,19 @@ impl<'a, T: AstInfo> VisitMut<'a, T> for RemoveParens { if let Expr::Nested(inner) = expr { *expr = (**inner).clone(); } + // Canonicalize a negative numeric literal to a unary minus over the bare + // number, so it compares equal to the unfolded `- ` form the + // parser produces in non-leading position. (Positive literals are never + // sign-prefixed by the parser, so only `-` needs handling.) + if let Expr::Value(Value::Number(n)) = expr { + if let Some(rest) = n.strip_prefix('-') { + *expr = Expr::Op { + op: Op::bare("-"), + expr1: Box::new(Expr::Value(Value::Number(rest.to_string()))), + expr2: None, + }; + } + } } } From 34621edd99ee28395238e576dfc578bbc0407b46 Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Mon, 3 Aug 2026 07:15:38 +0000 Subject: [PATCH 4/4] tests: skip absorbed AND/OR operand errors row-precisely in mfp_optimize `MapFilterProject::optimize` may legally change what a row yields once lazy `And`/`Or` evaluation absorbs an operand error, in two ways. Reduce's error propagation folds the absorbed error into the plan (the open bug CLU-137), and memoization hoists a fallible subexpression shared under the `And`/`Or` into a mapped column that `SafeMfpPlan::evaluate_inner` runs eagerly, surfacing the error. The latter is not a bug: AND/OR evaluation order is undefined, in Materialize as in Postgres (STG-54), so the surfaced error is a legal outcome of the same plan. It is what failed release qualification 1330, 1332, 1334 and 1335, and after review #37974 no longer changes the product to prevent it, so the oracle has to tolerate it or release qualification keeps tripping on it. The coarse expression-level skip this target used drops the entire input whenever any `And`/`Or` has a statically fallible operand. That is 2.2% of MFPs, but they are exactly the AND/OR-over-fallible shapes where optimize's memoization is busiest, so the value and filter arms went blind where a real miscompile is most likely to sit. Port `mir_scalar_reduce`'s row-precise `absorbs_and_or_operand_error` instead and skip exactly the rows whose lazy evaluation absorbs an operand error. Temporal mode keeps the coarse skip: its reference side substitutes a literal for `mz_now()` and re-runs `optimize` per probed time, so a per-row answer is not well-defined there. Verified in both directions against the unfixed product. A hand-crafted input generating `((#2 * #2) <= (#2 * #2)) IS NULL OR #4` with an overflowing `#2` crashes the pre-existing oracle with the release qualification assertion and passes the row-precise one. A twin input hiding the shared fallible part in an untaken `If` branch still crashes the row-precise oracle when `eager_children`'s `If` guard is deliberately removed, so the tolerance does not extend to genuine eager-evaluation bugs. Co-Authored-By: Claude Fable 5 --- src/expr/fuzz/fuzz_targets/mfp_optimize.rs | 149 ++++++++++++++++++--- 1 file changed, 131 insertions(+), 18 deletions(-) diff --git a/src/expr/fuzz/fuzz_targets/mfp_optimize.rs b/src/expr/fuzz/fuzz_targets/mfp_optimize.rs index 2788fed6afa64..e0c65a6e37b3f 100644 --- a/src/expr/fuzz/fuzz_targets/mfp_optimize.rs +++ b/src/expr/fuzz/fuzz_targets/mfp_optimize.rs @@ -39,9 +39,12 @@ //! (possibly fallible) map expressions, and it is allowed to *introduce* one, //! because sorting predicates by position can surface an error the raw order //! rejected the row before reaching. So when either side errors we assert -//! nothing. Otherwise: a row the raw MFP passes cleanly with output `out` the -//! optimized plan must pass with the byte-identical `out`, and a row the raw -//! MFP cleanly filters the optimized plan must not pass. +//! nothing. A row whose lazy evaluation absorbs an `AND`/`OR` operand error +//! is skipped outright, because `optimize` may legally surface that error or +//! fold on it (see `absorbs_and_or_operand_error`). Otherwise: a row the raw +//! MFP passes cleanly with output `out` the optimized plan must pass with +//! the byte-identical `out`, and a row the raw MFP cleanly filters the +//! optimized plan must not pass. //! //! * **Temporal lowering.** Add predicates of the form `mz_now() e` (and //! conjunctions of them) over a bounded `mz_timestamp` expression `e`, then @@ -68,7 +71,8 @@ use std::collections::{BTreeMap, BTreeSet}; use libfuzzer_sys::arbitrary::{self, Arbitrary, Unstructured}; use libfuzzer_sys::fuzz_target; use mz_expr::{ - Eval, EvalError, MapFilterProject, MirScalarExpr, SafeMfpPlan, UnmaterializableFunc, func, + Eval, EvalError, MapFilterProject, MirScalarExpr, SafeMfpPlan, UnmaterializableFunc, + VariadicFunc, func, }; use mz_repr::{Datum, Diff, ReprScalarType, Row, RowArena, Timestamp}; @@ -430,6 +434,96 @@ fn eval_raw(mfp: &MapFilterProject, row: &Row) -> Option> { Some(Some(out)) } +/// Does evaluating `expr` on this row absorb an `And`/`Or` operand error? +/// +/// This is `mir_scalar_reduce`'s `absorbs_and_or_operand_error`, the per-row +/// refinement of `MirScalarExpr::could_hit_nonstrict_error_fold`, minus the +/// `ErrorIfNull` arm for a function this target's vocabulary lacks. Rows it +/// reports are skipped: reduce's error propagation may fold the absorbed error +/// into the plan (CLU-137), and memoization may hoist a fallible subexpression +/// shared under the `And`/`Or` into a mapped column that is evaluated eagerly, +/// surfacing the error. Both outcomes are legal for `optimize`, because +/// `AND`/`OR` evaluation order is undefined (STG-54), so such a row constrains +/// nothing. See `run` for the two mechanisms side by side. +/// +/// NOTE: this deliberately evaluates *every* operand of an `And`/`Or`, even +/// though `And::eval` short-circuits on the first `false` and so may never +/// reach a later erroring operand. Error propagation and memoization are both +/// position-insensitive, so a short-circuit-aware version would report "no +/// absorption" for rows the optimized plan nonetheless errors on. +fn absorbs_and_or_operand_error<'a>( + expr: &'a MirScalarExpr, + datums: &[Datum<'a>], + arena: &'a RowArena, +) -> bool { + match expr { + MirScalarExpr::Column(..) + | MirScalarExpr::Literal(..) + | MirScalarExpr::CallUnmaterializable(_) => false, + MirScalarExpr::CallUnary { expr, .. } => absorbs_and_or_operand_error(expr, datums, arena), + MirScalarExpr::CallBinary { expr1, expr2, .. } => { + absorbs_and_or_operand_error(expr1, datums, arena) + || absorbs_and_or_operand_error(expr2, datums, arena) + } + MirScalarExpr::CallVariadic { func, exprs } => { + let nested = exprs + .iter() + .any(|expr| absorbs_and_or_operand_error(expr, datums, arena)); + + if !matches!(func, VariadicFunc::And(_) | VariadicFunc::Or(_)) { + return nested; + } + + let is_and = matches!(func, VariadicFunc::And(_)); + let mut has_absorbing_value = false; + let mut has_error = false; + for expr in exprs { + match expr.eval(datums, arena) { + Ok(Datum::False) if is_and => has_absorbing_value = true, + Ok(Datum::True) if !is_and => has_absorbing_value = true, + Err(_) => has_error = true, + _ => {} + } + } + + nested || (has_absorbing_value && has_error) + } + MirScalarExpr::If { cond, then, els } => { + if absorbs_and_or_operand_error(cond, datums, arena) { + return true; + } + match cond.eval(datums, arena) { + Ok(Datum::True) => absorbs_and_or_operand_error(then, datums, arena), + Ok(Datum::False | Datum::Null) => absorbs_and_or_operand_error(els, datums, arena), + _ => false, + } + } + } +} + +/// `absorbs_and_or_operand_error` over every map and predicate of `mfp`, in the +/// column context `eval_raw` evaluates them in. +/// +/// A map expression that fails to re-evaluate counts as absorbing. That arm is +/// reachable only for a row `eval_raw` filtered before reaching the map, and +/// "assert nothing" is the safe answer for a row this function cannot model. +fn mfp_absorbs_and_or_operand_error(mfp: &MapFilterProject, row: &Row) -> bool { + let arena = RowArena::new(); + let mut datums: Vec = row.iter().collect(); + for expr in &mfp.expressions { + if absorbs_and_or_operand_error(expr, &datums, &arena) { + return true; + } + match expr.eval(&datums, &arena) { + Ok(datum) => datums.push(datum), + Err(_) => return true, + } + } + mfp.predicates + .iter() + .any(|(_, predicate)| absorbs_and_or_operand_error(predicate, &datums, &arena)) +} + /// Non-temporal preservation: raw unoptimized MFP semantics vs. the `optimize`d /// plan's output. fn run_nontemporal( @@ -456,6 +550,13 @@ fn run_nontemporal( continue; }; + // Neither does a row that absorbs an `And`/`Or` operand error: optimize + // may legally turn its outcome into an error, a different value, or a + // different pass/fail. See `run`. + if mfp_absorbs_and_or_operand_error(&mfp, &row) { + continue; + } + let arena = RowArena::new(); let mut datums_p: Vec = row.iter().collect(); let mut buf_p = Row::default(); @@ -617,20 +718,32 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { .filter(filters) .project(projection); - // Skip the shape of the open bug CLU-137. `optimize` reduces every map - // expression and predicate, and reduce's generic error propagation replaces a - // non-strict `And`/`Or`/`ErrorIfNull` with any operand's literal error even - // though `eval` would have absorbed it. All three of this oracle's arms are - // exposed: the folded error makes a passing row error, the literal is typed - // non-nullable so a nullability-dependent rewrite above it can yield a - // different projected value, and a predicate folded that way can drop a row - // outright. This target's existing tolerance does not cover any of that, since - // it only forgives an error on a row the raw MFP *filtered*. - if mfp - .expressions - .iter() - .chain(mfp.predicates.iter().map(|(_, p)| p)) - .any(|e| e.could_hit_nonstrict_error_fold()) + // Two mechanisms let `optimize` legally change what a row yields once lazy + // `And`/`Or` evaluation absorbs an operand error (`Or::eval` returns `true` + // the moment it sees one, dropping any error it collected, and `And::eval` + // does the same for `false`). Reduce's generic error propagation, run by + // `optimize` on every expression, replaces the whole call with any + // operand's literal error even though `eval` would have absorbed it (the + // open bug CLU-137), and the folded literal's non-nullable type can license + // a rewrite above it into a different projected value or a dropped row. And + // memoization hoists a fallible subexpression shared under an `And`/`Or` + // into a mapped column that `evaluate_inner` runs eagerly, surfacing the + // absorbed error. The latter is not a bug: `AND`/`OR` evaluation order is + // undefined, in Materialize as in Postgres (STG-54), so the surfaced error + // is a legal outcome of the same plan. + // + // Non-temporal mode handles both row-precisely, skipping exactly the rows + // that absorb an operand error (see `absorbs_and_or_operand_error`). + // Temporal mode skips the whole MFP on the coarse, expression-level + // predicate: its reference side substitutes a literal for `mz_now()` and + // re-runs `optimize` per probed time, so "absorbs on this row" has no + // single answer there. + if temporal + && mfp + .expressions + .iter() + .chain(mfp.predicates.iter().map(|(_, p)| p)) + .any(|e| e.could_hit_nonstrict_error_fold()) { return Ok(()); }