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..e0c65a6e37b3f 100644 --- a/src/expr/fuzz/fuzz_targets/mfp_optimize.rs +++ b/src/expr/fuzz/fuzz_targets/mfp_optimize.rs @@ -33,13 +33,18 @@ //! 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. 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 @@ -51,18 +56,23 @@ //! 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, + VariadicFunc, func, }; use mz_repr::{Datum, Diff, ReprScalarType, Row, RowArena, Timestamp}; @@ -275,9 +285,13 @@ fn gen_ts_expr(u: &mut Unstructured, cols: &[Ty]) -> arbitrary::Result MirScalarExpr { MirScalarExpr::literal_ok( @@ -335,13 +349,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(); @@ -419,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( @@ -440,24 +545,52 @@ 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; }; + // 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(); - 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 +609,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 +663,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 +718,36 @@ fn run(u: &mut Unstructured) -> arbitrary::Result<()> { .filter(filters) .project(projection); + // 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(()); + } + 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 f2bd2c6635af7..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; @@ -1968,7 +2006,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 +2505,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() { 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..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::{AstInfo, Expr, Raw, 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) => { @@ -97,6 +103,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 { @@ -105,18 +143,22 @@ 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, + }; + } + } } } -/// 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 +172,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 +195,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 +214,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 +226,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 +378,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 +1321,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 +1410,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 +1549,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",