diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index fd27793..798f09e 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -228,6 +228,50 @@ jobs: gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body \ "The nightly proptest run (PROPTEST_CASES=100000) failed: $RUN. The minimal failing input is in the job log; the seed is the run's \`proptest-regressions\` artifact." + # `sizes_vs_core`'s proptest twin, and the tiers too large for a vector + # (runtime/fuzz/tests/sizes.rs): core's byte ranges held to the real + # encoders. In release: the large values are megabytes. A pull request runs + # proptest's default count; the nightly run 20000 cases, and opens an issue + # on a failure. + sizes: + name: sizes (proptest) + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + issues: write + env: + NIGHTLY: ${{ github.event_name != 'pull_request' }} + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install nightly --profile minimal + - name: Size tests + env: + PROPTEST_CASES: ${{ github.event_name == 'pull_request' && '256' || '20000' }} + run: cargo test --release --manifest-path runtime/fuzz/Cargo.toml --test sizes + - name: Upload the failing seeds + if: failure() + uses: actions/upload-artifact@v4 + with: + name: sizes-proptest-regressions + path: runtime/fuzz/tests/sizes.proptest-regressions + if-no-files-found: ignore + - name: Open an issue + if: failure() && env.NIGHTLY == 'true' + env: + GH_TOKEN: ${{ github.token }} + TITLE: "Nightly size tests failed" + RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + open=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 200 \ + --json title --jq "map(select(.title == env.TITLE)) | length") + if [ "$open" != "0" ]; then + echo "An issue titled '$TITLE' is already open." + exit 0 + fi + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body \ + "The nightly size tests (runtime/fuzz/tests/sizes.rs, PROPTEST_CASES=20000) failed: $RUN. The minimal failing input is in the job log; the seed is the run's \`sizes-proptest-regressions\` artifact." + report: name: fuzz report needs: [targets, fuzz, properties] diff --git a/runtime/fuzz/Cargo.toml b/runtime/fuzz/Cargo.toml index c612b72..3f0375f 100644 --- a/runtime/fuzz/Cargo.toml +++ b/runtime/fuzz/Cargo.toml @@ -23,6 +23,11 @@ comline-runtime-ffi = { path = "../ffi" } serde = { version = "1.0.190", features = ["derive"] } # The seed-vs-derive mirrors (`conformance/tests/common`) need it. serde-big-array = "0.5" +serde_json = "1.0.108" +# The compiler's byte ranges and witnesses, which `sizes_vs_core` holds to the +# real encoders: the IR alone (no parser), and only here. The runtime library +# never depends on it. +comline-core = { git = "https://github.com/ComlineProject/core", rev = "d1b678a983b90453c8c9288ef44a7c00d26711a5", default-features = false } # The targets: these [[bin]]s are the one list of them. fuzz.yml's matrix and # its report's `expect` read it (.github/scripts/fuzz-targets.py), which fails @@ -83,8 +88,17 @@ test = false doc = false bench = false +[[bin]] +name = "sizes_vs_core" +path = "fuzz_targets/sizes_vs_core.rs" +test = false +doc = false +bench = false + [dev-dependencies] -serde_json = "1.0.108" +# The proptest twin of `sizes_vs_core` (`tests/sizes.rs`); `PROPTEST_CASES` +# raises the count. +proptest = "1.5" # Writes the seed corpora from the conformance vectors; see seed-corpus.sh. [[example]] diff --git a/runtime/fuzz/README.md b/runtime/fuzz/README.md index 9881c40..799d01e 100644 --- a/runtime/fuzz/README.md +++ b/runtime/fuzz/README.md @@ -44,6 +44,7 @@ cargo +nightly fuzz run --fuzz-dir runtime/fuzz decode_no_panic -- \ | `ffi_feed` | a config, then operations (see below) | the C ABI on its own rules: no panic crosses and no handle is poisoned, every status is one the function documents, every span lies inside the frame and the frame inside that `feed`'s input, `NEED_MORE` and `BUFFER_TOO_SMALL` report sizes a retry succeeds with, refusals spend no request id, and nothing allocates after `comline_connection_new` | | `ffi_vs_core` | `ffi_feed`'s | `ffi_feed`'s checks, and each call made again through `State`, the connection framings and `HandshakeFrame` directly, which must give the same status, bytes, request id, `*consumed` and event (spans at the same offsets) | | `ffi_comline2` | options, then frames | a `comline@2` connection under any side and parameters, fed any frames: no panic crosses and no handle is poisoned, every status is one the function documents, `COMLINE_EVENT_SEND` means `start` has a message, and a finished or refused handshake's readers answer | +| `sizes_vs_core` | a type graph, then values | `comline-core`'s byte range for the type in `packed@1`, `msgpack@1` and `json@1` holds every value's real encoded length (and `encoded_len` agrees), and core's least and most witnesses encode to exactly `min` and `max`; see below | **What `framing_decode` checks.** - The binary frames are canonical, so what decodes re-encodes to exactly the @@ -56,6 +57,54 @@ cargo +nightly fuzz run --fuzz-dir runtime/fuzz decode_no_panic -- \ JSON-RPC requests are only decoded, since encoding needs a `'static` method name. +**What `sizes_vs_core` checks.** The compiler's size rules +([ComlineProject/core#242](https://github.com/ComlineProject/core/pull/242), +the specification's `sizes`), held to the encoders that write the bytes. The +input is its own grammar, [`src/sizes.rs`](src/sizes.rs): a bounded type graph +of up to six declarations, then values. +- **The types** are what the compiler freezes (`TypeRef`s, with a `Lookup` + over the declarations), so core sizes them as it sizes a package's: + every primitive including the 128-bit integers and `char`; `char[max N]`, + `char[N]`, `T[]`, `T[max N]` and `T[N]` with bounds at the tiers where a + header grows (15/16, 31/32, 127/128, 255/256, 16383/16384, 65535/65536, + 2^21) and past what any header says; structs of up to 20 fields, some + `optional`; unit enums of up to 200 variants, with names up to 300 bytes; + aliases; and recursion under `@max_depth` 1 to 4, or none, through an + `optional` field or an array, as a schema with a value of every type has it. +- **The values** are built by `Gen`, in three styles: the simplest (which + exists exactly when core sizes the type), a random one, and one at the + bounds: full-length strings of the worst characters (four-byte UTF-8, + control characters JSON writes as `\u00XX`), the longest counts, the widest + numbers (`i128::MIN`, floats with the longest text), present options, the + last and the longest-named variant. Each is checked to be of the type, then + encoded with the real format through the conformance crate's `Wire`: its + length lies in `[min, max]`, and `encoded_len` says the same. +- **The witnesses** are core's least and most values of each type, the root's + and each declaration's. The least must encode to exactly `min` bytes and the + most to exactly `max` (unless the range is marked an upper bound), which is + what makes a range tight, not only sound. How much a witness may take to + build is bounded, but by what it holds: one of mostly characters may be + `2^23`, which reaches a string of 2^21 characters (a four-byte varint); one + of nodes, `2^18`, which reaches a MessagePack array of 65536 items (a + five-byte header). +- **The tiers the vectors can't store** (a value of megabytes) are the + deterministic tests of [`tests/sizes.rs`](tests/sizes.rs), which is also the + proptest twin of the target: random graphs over the same bytes, every + scalar over its range, a MessagePack array of 65536 items, strings and + arrays whose length is a three- and a four-byte varint, an enum of 200 + variants, recursion to its limit, and the longest `f32` and `f64` in JSON + (a scan finds none longer than core's maximum, and reaches it). Run it in + release: `cargo test --release --manifest-path runtime/fuzz/Cargo.toml --test sizes`. + `PROPTEST_CASES` raises its count. + +A disagreement is one of three things, and which decides what changes: the +sizer (core), a rule in the specification's catalog, or an encoder. A sizer +bug is fixed in core, a rule's in the specification, and an encoder's here, +each with the minimized input as a vector or a unit test. + +This crate depends on `comline-core` by git rev, with default features off +(the IR alone, no parser); the runtime library never does. + **What the C ABI targets run.** A `comline_config`, any value allowed (the supported framings, other names, any handshake, mode, ordering, limit and room), then operations: this side's `start`, `encode_request`, @@ -98,6 +147,10 @@ refuses a count past the bytes left even for such an element, and the vector exchange under every framing, mode, handshake and ordering, so the fuzzer starts past the handshake. +`sizes_vs_core` has none: its input is a type graph and values in its own +grammar, which no vector is written in, and the vectors' sizes are what +`tests/sizes.rs` and the conformance crate already run. + The corpora are generated, so they aren't committed (`.gitignore`), and neither are `artifacts/`. @@ -118,6 +171,10 @@ are `artifacts/`. unless one with that title is already open. It doesn't block anyone's pull request. +- **Both:** the `sizes` job runs `tests/sizes.rs`, the proptest twin of + `sizes_vs_core` and the tiers too large for a vector: on a pull request at + proptest's default count, nightly with `PROPTEST_CASES=20000`. + Every run writes [`rust.fuzz.json`](#the-report) as an artifact and to its summary. The nightly run, or a manual one from `main`, also commits it to the [`reports`](https://github.com/ComlineProject/runtime/tree/reports) branch, diff --git a/runtime/fuzz/fuzz_targets/sizes_vs_core.rs b/runtime/fuzz/fuzz_targets/sizes_vs_core.rs new file mode 100644 index 0000000..d1e1be5 --- /dev/null +++ b/runtime/fuzz/fuzz_targets/sizes_vs_core.rs @@ -0,0 +1,22 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! A random bounded type graph (a root type and up to six declarations: +//! structs, unit enums, aliases, recursion under `@max_depth`), and values of +//! it, from the bytes: `comline-core` sizes the type for `packed@1`, +//! `msgpack@1` and `json@1`, and each real encoder, through the conformance +//! crate's `Wire`, must write a length inside core's `[min, max]` (and +//! `encoded_len` must say it), while core's least and most witnesses must +//! encode to exactly `min` and `max`. See `comline_runtime_fuzz::sizes`. + +#![no_main] + +use comline_runtime_fuzz::sizes::check_input; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Err(why) = check_input(data) { + panic!("{why}"); + } +}); diff --git a/runtime/fuzz/src/lib.rs b/runtime/fuzz/src/lib.rs index d1f76b0..c837be4 100644 --- a/runtime/fuzz/src/lib.rs +++ b/runtime/fuzz/src/lib.rs @@ -34,6 +34,7 @@ //! own, in [`ffi`]. pub mod ffi; +pub mod sizes; use arbitrary::Unstructured; use comline_runtime::contract::{FORMAT_JSON, FORMAT_MSGPACK, FORMAT_PACKED}; diff --git a/runtime/fuzz/src/sizes.rs b/runtime/fuzz/src/sizes.rs new file mode 100644 index 0000000..bc34d23 --- /dev/null +++ b/runtime/fuzz/src/sizes.rs @@ -0,0 +1,1167 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The byte ranges `comline-core` computes for a type, held to the real +//! encoders: what `sizes_vs_core` (the fuzz target) and +//! [`tests/sizes.rs`](../tests/sizes.rs) (its proptest twin) both run. +//! +//! A [`Schema`] is a bounded type graph (a root [`Ty`] and the declarations +//! it names) that becomes what the compiler freezes: [`TypeRef`]s, with a +//! [`Lookup`] over the declarations, so core sizes it exactly as it sizes a +//! compiled package's types. [`read_schema`] reads one from the front of an +//! input, one byte per choice, like [`read_type`](crate::read_type) does for +//! a conformance [`Type`](comline_runtime_conformance::Type): +//! +//! - scalars, with the 128-bit integers, `char`, `string` and `unit`; +//! - `char[max N]` and `char[N]`, `T[]`, `T[max N]` and `T[N]`, with +//! bounds at the formats' tiers (a header grows at 15/16, 31/32, 127/128, +//! 255/256, 16383/16384, 65535/65536, 2^21) and past what a header can say; +//! - up to six declarations: structs (0 to 20 fields, some `optional`), unit +//! enums (0 to 200 variants, names up to 300 bytes) and `type` aliases; +//! - recursion, through an `optional` field or an array only, so every type +//! has a value, under `@max_depth` 1 to 4 or none; a required field names +//! an earlier declaration. +//! +//! A value of it comes from [`Gen`], in a [`Style`]: the simplest, a random +//! one, or one at the bounds (full-length strings of the worst characters +//! for a format, the longest counts, the widest numbers and variants). Each +//! is a [`Value`] in the specification's typed notation, which +//! [`encode_as`] writes with the real encoder. +//! +//! [`check_schema`] asserts, for `packed@1`, `msgpack@1` and `json@1`: +//! +//! - core sizes the type exactly when the generator can build a value of it; +//! - each value is of the type ([`Schema::conforms`]), encodes to a length in +//! `[min, max]`, and `encoded_len` says that length; +//! - core's witnesses are values of the type too, the least one encodes to +//! exactly `min` bytes and the most one to exactly `max`. +//! +//! That is soundness (no value escapes the range) and tightness (both ends +//! are reached), with no rule of the specification trusted on review. + +use std::borrow::Cow; + +use arbitrary::Unstructured; +use serde::Deserialize as _; +use comline_core::schema::ir::frozen::types::{PrimitiveType, TypeRef}; +use comline_core::schema::ir::size::{self, Decl, Lookup, Member, Range, Resolved}; +use comline_runtime::contract::{FORMAT_JSON, FORMAT_MSGPACK, FORMAT_PACKED}; +use comline_runtime_conformance::{encode_as, encoded_len_as, Value, Wire}; + +pub const FORMATS: [&str; 3] = [FORMAT_PACKED, FORMAT_MSGPACK, FORMAT_JSON]; + +/// Declarations in a schema, at most. +pub const MAX_DECLS: usize = 6; +/// `@max_depth`, at most. +pub const MAX_RECURSION: u64 = 4; +/// How much a check may build, in values and witnesses. Most inputs are +/// [`Scale::Small`]: building a witness (a `serde_json::Value` a node, which +/// costs hundreds of bytes) is what a run spends its time on. One in sixteen +/// is [`Scale::Big`], which reaches the tiers that need a lot of items or +/// characters: a MessagePack array of 65536 items (a five-byte header), a +/// string of 2^21 characters (a four-byte varint). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scale { + Small, + Big, +} + +impl Scale { + /// Past it, arrays and strings are cut short. + fn soft_items(self) -> u64 { + match self { + Scale::Small => 1 << 13, + Scale::Big => 1 << 19, + } + } + + fn soft_chars(self) -> u64 { + match self { + Scale::Small => 1 << 15, + Scale::Big => 1 << 22, + } + } + + /// Past it, the value is dropped. + fn hard_items(self) -> u64 { + match self { + Scale::Small => 1 << 17, + Scale::Big => 1 << 20, + } + } + + fn hard_chars(self) -> u64 { + match self { + Scale::Small => 1 << 18, + Scale::Big => 1 << 23, + } + } +} + +/// What a witness may take to build (a node or a character each), when the +/// type's biggest value is mostly nodes and the scale is big. +pub const WITNESS_BUDGET: usize = 1 << 18; +/// What it may take when the biggest value is made of characters, which cost +/// a byte or four: this reaches a string of 2^21 characters, a four-byte +/// length. +pub const WITNESS_BUDGET_CHARS: usize = 1 << 23; +/// What it may take at the small scale. +pub const WITNESS_BUDGET_SMALL: usize = 1 << 12; + +/// A length up to this is picked as it is; past it, one time in eight. +const HEAVY: u64 = 1 << 17; + +/// The sizes where a header grows, and the ones around them. +const TIERS: [u64; 24] = [ + 0, 1, 2, 3, 7, 8, 15, 16, 17, 31, 32, 33, 127, 128, 255, 256, 16383, 16384, 65535, 65536, + 65537, 2097151, 2097152, 2097153, +]; +/// A bound: the tiers, and bounds past what any header says (2^32 - 1). +const BOUNDS: [u64; 30] = [ + 0, 1, 2, 3, 7, 8, 15, 16, 17, 31, 32, 63, 64, 127, 128, 255, 256, 4095, 4096, 16383, 16384, + 65535, 65536, 65537, 2097151, 2097152, 1 << 30, u32::MAX as u64, 1 << 32, 5_000_000_000, +]; +/// The length of a fixed array of scalars, and of a fixed string. +const FIXED_BIG: [u64; 14] = [ + 1, 2, 3, 4, 8, 15, 16, 17, 32, 255, 256, 65535, 65536, 65537, +]; +const FIXED_SMALL: [u64; 9] = [1, 2, 3, 4, 8, 15, 16, 17, 33]; +const FIXED_CHARS: [u64; 14] = [0, 1, 2, 3, 8, 31, 32, 33, 255, 256, 4097, 16384, 65536, 70000]; +const FIELD_COUNTS: [usize; 14] = [0, 1, 1, 2, 2, 3, 4, 5, 6, 8, 15, 16, 17, 20]; +const VARIANT_COUNTS: [usize; 16] = [1, 1, 2, 3, 4, 5, 8, 15, 16, 17, 31, 127, 128, 129, 130, 200]; +/// Field names; the index follows, so they are distinct in a struct and few +/// in all (the conformance crate's interner leaks one entry per name). +const FIELD_NAMES: [&str; 10] = [ + "x", + "y", + "id", + "label", + "flag", + "maybe", + "rows", + "precise", + "name_of_thirty_or_so_bytes_long", + "a_rather_long_name_that_is_well_over_thirty_one_bytes_long_for_json_keys", +]; +/// How many `n` pad a variant's name. +const VARIANT_PADS: [usize; 8] = [0, 0, 1, 5, 25, 26, 40, 254]; + +/// A primitive type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scalar { + Bool, + U8, + U16, + U32, + U64, + U128, + I8, + I16, + I32, + I64, + I128, + F32, + F64, + String, + Char, + Unit, +} + +const SCALARS: [Scalar; 16] = [ + Scalar::Bool, + Scalar::U8, + Scalar::U16, + Scalar::U32, + Scalar::U64, + Scalar::U128, + Scalar::I8, + Scalar::I16, + Scalar::I32, + Scalar::I64, + Scalar::I128, + Scalar::F32, + Scalar::F64, + Scalar::String, + Scalar::Char, + Scalar::Unit, +]; + +/// A type, as a schema writes it. +#[derive(Debug, Clone, PartialEq)] +pub enum Ty { + Scalar(Scalar), + /// `char[max N]`. + CharMax(u64), + /// `char[N]`. + CharN(u64), + /// `T[]` or `T[max N]`. + Array(Box, Option), + /// `T[N]`. + Fixed(Box, u64), + /// A declaration, by its index in [`Schema::decls`]. + Named(usize), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Field { + pub name: String, + pub ty: Ty, + pub optional: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Body { + Struct(Vec), + /// Unit variants only. + Enum(Vec), + /// `type NAME = TARGET`. + Alias(Ty), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Declared { + pub name: String, + pub body: Body, + /// `@max_depth`, on a struct or an alias. + pub max_depth: Option, +} + +/// A bounded type graph. +#[derive(Debug, Clone, PartialEq)] +pub struct Schema { + pub decls: Vec, + pub root: Ty, +} + +impl Ty { + /// What the compiler freezes this type as. + pub fn to_ref(&self, schema: &Schema) -> TypeRef { + let prim = |p| TypeRef::Primitive(p); + let chars = |length, max| TypeRef::Array { + element: Box::new(prim(PrimitiveType::Char)), + length, + max, + }; + match self { + Ty::Scalar(s) => match s { + Scalar::Bool => prim(PrimitiveType::Bool), + Scalar::U8 => prim(PrimitiveType::U8), + Scalar::U16 => prim(PrimitiveType::U16), + Scalar::U32 => prim(PrimitiveType::U32), + Scalar::U64 => prim(PrimitiveType::U64), + Scalar::U128 => prim(PrimitiveType::U128), + Scalar::I8 => prim(PrimitiveType::S8), + Scalar::I16 => prim(PrimitiveType::S16), + Scalar::I32 => prim(PrimitiveType::S32), + Scalar::I64 => prim(PrimitiveType::S64), + Scalar::I128 => prim(PrimitiveType::S128), + Scalar::F32 => prim(PrimitiveType::F32), + Scalar::F64 => prim(PrimitiveType::F64), + Scalar::String => prim(PrimitiveType::String), + Scalar::Char => prim(PrimitiveType::Char), + Scalar::Unit => TypeRef::Unit, + }, + Ty::CharMax(n) => chars(None, Some(*n)), + Ty::CharN(n) => chars(Some(*n), None), + Ty::Array(of, max) => TypeRef::Array { + element: Box::new(of.to_ref(schema)), + length: None, + max: *max, + }, + Ty::Fixed(of, n) => TypeRef::Array { + element: Box::new(of.to_ref(schema)), + length: Some(*n), + max: None, + }, + Ty::Named(at) => TypeRef::Named(schema.decls[*at].name.clone()), + } + } +} + +/// Core resolves the names in a schema's types through it, as it does a +/// compiled package's. +impl Lookup for Schema { + type Scope = (); + + fn resolve(&self, _: (), name: &str) -> Option> { + let at = self.decls.iter().position(|d| d.name == name)?; + let d = &self.decls[at]; + let decl = match &d.body { + Body::Struct(fields) => Decl::Struct { + name: Cow::Borrowed(&d.name), + fields: fields + .iter() + .map(|f| Member { + name: Cow::Borrowed(&f.name), + ty: Cow::Owned(f.ty.to_ref(self)), + optional: f.optional, + }) + .collect(), + max_depth: d.max_depth, + }, + Body::Enum(variants) => Decl::Enum { + name: Cow::Borrowed(&d.name), + variants: variants.iter().map(|v| Cow::Borrowed(v.as_str())).collect(), + }, + Body::Alias(target) => Decl::Alias { + target: Cow::Owned(target.to_ref(self)), + max_depth: d.max_depth, + }, + }; + Some(Resolved { key: at + 1, scope: (), decl }) + } +} + +fn byte(u: &mut Unstructured) -> u8 { + u.arbitrary::().unwrap_or(0) +} + +fn pick(u: &mut Unstructured, pool: &[T]) -> T { + pool[byte(u) as usize % pool.len()] +} + +/// A bound (`char[max N]`, `T[max N]`): at a tier, or a few characters. +fn read_bound(u: &mut Unstructured) -> u64 { + match byte(u) % 8 { + 0 => byte(u) as u64 % 100, + _ => pick(u, &BOUNDS), + } +} + +/// A type; `me` is the declaration being written (`decls.len()` for the +/// root), whose earlier declarations a required field may name, and a +/// `guarded` one, behind an `optional` field or an array, any. +fn read_ty(u: &mut Unstructured, decls: usize, me: usize, guarded: bool, depth: u8) -> Ty { + let names: Vec = (0..decls).filter(|at| *at < me || guarded).collect(); + let tag = byte(u) % 16; + let scalar = |u: &mut Unstructured| Ty::Scalar(pick(u, &SCALARS)); + if depth == 0 && (7..=10).contains(&tag) { + return scalar(u); + } + match tag { + 0..=4 => scalar(u), + 5 => Ty::CharMax(read_bound(u)), + 6 => Ty::CharN(pick(u, &FIXED_CHARS)), + 7 | 8 => { + let of = read_ty(u, decls, me, true, depth - 1); + let max = match byte(u) % 3 { + 0 => None, + _ => Some(read_bound(u)), + }; + match (of, max) { + // An array of `char` is a string: that is how the IR has it. + (Ty::Scalar(Scalar::Char), None) => Ty::Scalar(Scalar::String), + (Ty::Scalar(Scalar::Char), Some(max)) => Ty::CharMax(max), + (of, max) => Ty::Array(Box::new(of), max), + } + } + 9 | 10 => { + let of = read_ty(u, decls, me, guarded, depth - 1); + let big = matches!(of, Ty::Scalar(_)); + let n = if big { pick(u, &FIXED_BIG) } else { pick(u, &FIXED_SMALL) }; + match of { + Ty::Scalar(Scalar::Char) => Ty::CharN(n), + of => Ty::Fixed(Box::new(of), n), + } + } + _ if names.is_empty() => scalar(u), + _ => Ty::Named(pick(u, &names)), + } +} + +/// The schema at the front of `u`. +pub fn read_schema(u: &mut Unstructured) -> Schema { + let n = byte(u) as usize % (MAX_DECLS + 1); + let mut decls = Vec::with_capacity(n); + for at in 0..n { + let max_depth = match byte(u) as u64 % (MAX_RECURSION + 1) { + 0 => None, + k => Some(k), + }; + let (name, body) = match byte(u) % 8 { + 0..=4 => { + let count = pick(u, &FIELD_COUNTS); + let fields = (0..count) + .map(|i| { + let optional = byte(u) % 3 == 0; + Field { + name: format!("{}{i}", pick(u, &FIELD_NAMES)), + ty: read_ty(u, n, at, optional, 2), + optional, + } + }) + .collect(); + (format!("S{at}"), Body::Struct(fields)) + } + 5 | 6 => { + // One time in sixteen, none: no value of it exists. + let count = if byte(u) % 16 == 0 { 0 } else { pick(u, &VARIANT_COUNTS) }; + let pad = pick(u, &VARIANT_PADS); + let variants = (0..count) + .map(|i| format!("V{i}{}", "n".repeat(if i % 5 == 0 { pad } else { 0 }))) + .collect(); + (format!("E{at}"), Body::Enum(variants)) + } + _ => (format!("A{at}"), Body::Alias(read_ty(u, n, at, false, 2))), + }; + decls.push(Declared { name, body, max_depth }); + } + let root = read_ty(u, n, n, false, 3); + let mut schema = Schema { decls, root }; + schema.carry_data_in_arrays(); + schema +} + +impl Schema { + /// Whether a value of `ty` takes no bytes in `packed@1`: `unit`, an + /// empty struct, a struct or alias of only such. + fn dataless(&self, ty: &Ty, seen: &mut Vec) -> bool { + match ty { + Ty::Scalar(s) => *s == Scalar::Unit, + Ty::CharMax(_) | Ty::CharN(_) | Ty::Array(..) => false, + Ty::Fixed(of, n) => *n == 0 || self.dataless(of, seen), + Ty::Named(at) => { + if seen.contains(at) { + return false; + } + seen.push(*at); + let out = match &self.decls[*at].body { + Body::Struct(fields) => { + fields.iter().all(|f| !f.optional && self.dataless(&f.ty, seen)) + } + Body::Enum(_) => false, + Body::Alias(target) => self.dataless(target, seen), + }; + seen.pop(); + out + } + } + } + + /// Core refuses an array of a type that carries no data + /// (`validation::array-of-dataless-type`, ComlineProject/core#141): such + /// an element becomes a `u8`. + fn carry_data_in_arrays(&mut self) { + fn walk(schema: &Schema, ty: &mut Ty) { + match ty { + Ty::Array(of, _) | Ty::Fixed(of, _) => { + if schema.dataless(of, &mut vec![]) { + **of = Ty::Scalar(Scalar::U8); + } + walk(schema, of); + } + _ => {} + } + } + let snapshot = self.clone(); + for d in &mut self.decls { + match &mut d.body { + Body::Struct(fields) => fields.iter_mut().for_each(|f| walk(&snapshot, &mut f.ty)), + Body::Alias(target) => walk(&snapshot, target), + Body::Enum(_) => {} + } + } + walk(&snapshot, &mut self.root); + } + + /// Whether `value` is of `ty`: its shape, its bounds and its nesting. + pub fn conforms(&self, ty: &Ty, value: &Value) -> Result<(), String> { + self.conforms_in(ty, value, &mut vec![]) + } + + fn conforms_in(&self, ty: &Ty, value: &Value, stack: &mut Vec) -> Result<(), String> { + let wrong = |what: &str| Err(format!("{what}: {}", shown(&format!("{value:?}")))); + match (ty, value) { + (Ty::Scalar(s), v) => { + let ok = matches!( + (s, v), + (Scalar::Bool, Value::Bool(_)) + | (Scalar::U8, Value::U8(_)) + | (Scalar::U16, Value::U16(_)) + | (Scalar::U32, Value::U32(_)) + | (Scalar::U64, Value::U64(_)) + | (Scalar::U128, Value::U128(_)) + | (Scalar::I8, Value::I8(_)) + | (Scalar::I16, Value::I16(_)) + | (Scalar::I32, Value::I32(_)) + | (Scalar::I64, Value::I64(_)) + | (Scalar::I128, Value::I128(_)) + | (Scalar::F32, Value::F32(_)) + | (Scalar::F64, Value::F64(_)) + | (Scalar::String, Value::String(_)) + | (Scalar::Char, Value::Char(_)) + | (Scalar::Unit, Value::Unit(())) + ); + if ok { Ok(()) } else { wrong(&format!("not a {s:?}")) } + } + (Ty::CharMax(max), Value::String(s)) => { + let chars = s.chars().count() as u64; + if chars <= *max { Ok(()) } else { Err(format!("{chars} characters, over char[max {max}]")) } + } + (Ty::CharN(n), Value::String(s)) => { + let chars = s.chars().count() as u64; + if chars == *n { Ok(()) } else { Err(format!("{chars} characters, not char[{n}]")) } + } + (Ty::Array(of, max), Value::Array(items)) => { + if max.is_some_and(|m| items.len() as u64 > m) { + return Err(format!("{} items, over the bound {max:?}", items.len())); + } + items.iter().try_for_each(|item| self.conforms_in(of, item, stack)) + } + (Ty::Fixed(of, n), Value::Tuple(items)) => { + if items.len() as u64 != *n { + return Err(format!("{} items, not T[{n}]", items.len())); + } + items.iter().try_for_each(|item| self.conforms_in(of, item, stack)) + } + (Ty::Named(at), value) => { + let d = &self.decls[*at]; + match (&d.body, value) { + (Body::Enum(variants), Value::UnitVariant { enum_name, index, variant }) => { + let ok = *enum_name == d.name + && variants.get(*index as usize).is_some_and(|v| v == variant); + if ok { Ok(()) } else { wrong(&format!("not a variant of {}", d.name)) } + } + (Body::Struct(fields), Value::Struct { name, fields: given }) => { + let level = stack.iter().filter(|k| **k == *at).count() as u64 + 1; + if d.max_depth.is_some_and(|limit| level > limit) { + return Err(format!("{} nested {level} deep, over its @max_depth", d.name)); + } + if *name != d.name || fields.len() != given.len() { + return wrong(&format!("not a {}", d.name)); + } + stack.push(*at); + let out = fields.iter().zip(given).try_for_each(|(f, (given_name, v))| { + if *given_name != f.name { + return Err(format!("field {given_name}, not {}", f.name)); + } + match (f.optional, v) { + (true, Value::Option(None)) => Ok(()), + (true, Value::Option(Some(v))) => self.conforms_in(&f.ty, v, stack), + (true, _) => Err(format!("{} isn't an option", f.name)), + (false, v) => self.conforms_in(&f.ty, v, stack), + } + }); + stack.pop(); + out + } + (Body::Alias(target), value) => { + let level = stack.iter().filter(|k| **k == *at).count() as u64 + 1; + if d.max_depth.is_some_and(|limit| level > limit) { + return Err(format!("{} nested {level} deep, over its @max_depth", d.name)); + } + stack.push(*at); + let out = self.conforms_in(target, value, stack); + stack.pop(); + out + } + _ => wrong(&format!("not a {}", d.name)), + } + } + _ => wrong(&format!("not a {ty:?}")), + } + } + + /// How many nodes and characters the largest value of `ty` has, as far + /// as core builds a witness of it; saturating, and cut off after + /// `steps` declarations. + fn weight(&self, ty: &Ty, stack: &mut Vec, steps: &mut u32) -> (u64, u64) { + let times = |(nodes, chars): (u64, u64), n: u64| (nodes.saturating_mul(n), chars.saturating_mul(n)); + match ty { + Ty::Scalar(_) => (1, 0), + Ty::CharMax(n) | Ty::CharN(n) => (1, (*n).min(u32::MAX as u64)), + // No largest value, so no witness: only the least one is built. + Ty::Array(_, None) => (1, 0), + Ty::Array(of, Some(n)) | Ty::Fixed(of, n) => { + let (nodes, chars) = times(self.weight(of, stack, steps), (*n).min(u32::MAX as u64)); + (nodes.saturating_add(1), chars) + } + Ty::Named(at) => { + let d = &self.decls[*at]; + if let Body::Enum(_) = d.body { + return (1, 0); + } + let level = stack.iter().filter(|k| **k == *at).count() as u64 + 1; + if *steps == 0 || d.max_depth.is_some_and(|limit| level > limit) || (d.max_depth.is_none() && level > 1) { + return (0, 0); + } + *steps -= 1; + stack.push(*at); + let out = match &d.body { + Body::Struct(fields) => fields.iter().fold((1u64, 0u64), |(nodes, chars), f| { + let (n, c) = self.weight(&f.ty, stack, steps); + (nodes.saturating_add(n).saturating_add(f.optional as u64), chars.saturating_add(c)) + }), + Body::Alias(target) => self.weight(target, stack, steps), + Body::Enum(_) => (1, 0), + }; + stack.pop(); + out + } + } + } + + /// What core may spend building a witness of `ty`: enough for its + /// largest value, up to [`WITNESS_BUDGET_CHARS`] when that is mostly + /// characters and [`WITNESS_BUDGET`] when it is nodes. + pub fn witness_budget(&self, ty: &Ty, scale: Scale) -> usize { + if scale == Scale::Small { + return WITNESS_BUDGET_SMALL; + } + let (nodes, chars) = self.weight(ty, &mut vec![], &mut 4096); + if nodes > WITNESS_BUDGET as u64 { + return WITNESS_BUDGET; + } + (nodes.saturating_add(chars) as usize).clamp(WITNESS_BUDGET, WITNESS_BUDGET_CHARS) + } +} + +/// How a value is chosen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Style { + /// The simplest: zeros, empty, absent. It exists exactly when core can + /// size the type. + Smallest, + /// Anywhere in the type, with the numbers, lengths and characters + /// where an encoding changes. + Random, + /// At the bounds: full-length strings and arrays of the widest numbers + /// and the worst characters for a format, present options, the longest + /// and the last variant. + Largest, +} + +/// A value too big to build here. +#[derive(Debug)] +pub struct Overrun; + +/// Builds values of a [`Schema`]'s types from the bytes of an input. +pub struct Gen<'s, 'u, 'd> { + schema: &'s Schema, + u: &'u mut Unstructured<'d>, + style: Style, + scale: Scale, + items: u64, + chars: u64, + stack: Vec, +} + +impl<'s, 'u, 'd> Gen<'s, 'u, 'd> { + pub fn new(schema: &'s Schema, u: &'u mut Unstructured<'d>, style: Style, scale: Scale) -> Self { + Gen { schema, u, style, scale, items: 0, chars: 0, stack: vec![] } + } + + fn byte(&mut self) -> u8 { + byte(self.u) + } + + fn range(&mut self, lo: u64, hi: u64) -> u64 { + if lo >= hi { + return lo; + } + self.u.int_in_range(lo..=hi).unwrap_or(lo) + } + + fn spend_item(&mut self) -> Result<(), Overrun> { + self.items += 1; + if self.items > self.scale.hard_items() { Err(Overrun) } else { Ok(()) } + } + + /// A count or length in `lo..=hi`. + fn length(&mut self, lo: u64, hi: u64) -> u64 { + let hi = hi.max(lo); + if lo == hi { + return lo; + } + let arm = match self.style { + Style::Smallest => 0, + Style::Largest => [1, 1, 1, 3][self.byte() as usize % 4], + Style::Random => [0, 1, 2, 3, 4, 4][self.byte() as usize % 6], + }; + match arm { + 0 => lo, + 1 if hi <= HEAVY || self.byte() % 8 == 0 => hi, + 2 => hi - 1, + 4 => { + let top = lo.saturating_add(16).min(hi); + self.range(lo, top) + } + // A tier, or the bound when none lies within. + _ => { + let heavy = self.byte() % 8 == 0; + let within: Vec = + TIERS.iter().copied().filter(|t| (lo..=hi).contains(t) && (*t <= HEAVY || heavy)).collect(); + if within.is_empty() { hi.min(HEAVY).max(lo) } else { within[self.byte() as usize % within.len()] } + } + } + } + + fn uint(&mut self, bits: u32) -> u128 { + let max = if bits == 128 { u128::MAX } else { (1u128 << bits) - 1 }; + if self.style == Style::Smallest { + return 0; + } + let kind = self.byte(); + let kind = if self.style == Style::Largest { [1, 5, 2][kind as usize % 3] } else { kind % 6 }; + let b = self.byte() as u32 % bits; + let r = match kind { + 0 => 0, + 1 => max, + 2 => 1u128 << b, + 3 => (1u128 << b) - 1, + 4 => (1u128 << b) + 1, + _ => self.u.arbitrary::().unwrap_or(0), + }; + r & max + } + + fn sint(&mut self, bits: u32) -> i128 { + let negative = self.style == Style::Largest || self.byte() % 2 == 1; + let mag = self.uint(bits); + let half = 1u128 << (bits - 1); + if negative { + let m = mag.min(half); + (m as i128).wrapping_neg() + } else { + (mag & (half - 1)) as i128 + } + } + + fn f64(&mut self) -> f64 { + const SPECIAL: [f64; 14] = [ + f64::MAX, + f64::MIN, + f64::MIN_POSITIVE, + -f64::MIN_POSITIVE, + 5e-324, + 1e15, + 1e16, + 1e17, + 1e-5, + 1e-6, + 1e21, + 0.1 + 0.2, + -1.2610470545525327e-05, + -2.2250738585072014e-308, + ]; + if self.style == Style::Smallest { + return 0.0; + } + let bits = self.u.arbitrary::().unwrap_or(0); + let random = f64::from_bits(bits); + let random = if random.is_finite() { random } else { -1.5 }; + let arm = if self.style == Style::Largest { [1, 1, 3][self.byte() as usize % 3] } else { self.byte() % 7 }; + match arm { + 0 => [0.0, -0.0][self.byte() as usize % 2], + 1 => random, + 2 => (bits as i32) as f64 * 10f64.powi(self.byte() as i32 % 40 - 20), + 3 => SPECIAL[self.byte() as usize % SPECIAL.len()], + 4 => [f64::NAN, f64::INFINITY, f64::NEG_INFINITY][self.byte() as usize % 3], + _ => bits as f64, + } + } + + fn f32(&mut self) -> f32 { + const SPECIAL: [f32; 12] = [ + f32::MAX, + f32::MIN, + f32::MIN_POSITIVE, + -f32::MIN_POSITIVE, + 1e-45, + 16777216.0, + 1e-5, + 1e-6, + 1e16, + 1e17, + -6233676316672.0, + 0.1 + 0.2, + ]; + if self.style == Style::Smallest { + return 0.0; + } + let bits = self.u.arbitrary::().unwrap_or(0); + let random = f32::from_bits(bits); + let random = if random.is_finite() { random } else { -1.5 }; + let arm = if self.style == Style::Largest { [1, 1, 3][self.byte() as usize % 3] } else { self.byte() % 7 }; + match arm { + 0 => [0.0, -0.0][self.byte() as usize % 2], + 1 => random, + 2 => (bits as i32) as f32 * 10f32.powi(self.byte() as i32 % 20 - 10), + 3 => SPECIAL[self.byte() as usize % SPECIAL.len()], + 4 => [f32::NAN, f32::INFINITY, f32::NEG_INFINITY][self.byte() as usize % 3], + _ => bits as f32, + } + } + + /// A character of every width an encoding tells apart. + fn char(&mut self) -> char { + if self.style == Style::Smallest { + return 'a'; + } + let class = if self.style == Style::Largest { [6, 7, 8, 9, 1][self.byte() as usize % 5] } else { self.byte() % 11 }; + let code = match class { + 0 => self.range(0x20, 0x7e), + 1 => self.range(0, 0x1f), + 2 => ['"', '\\', '\u{8}', '\t', '\n', '\u{c}', '\r'][self.byte() as usize % 7] as u64, + 3 => 0x7f, + 4 => self.range(0x80, 0x7ff), + 5 => { + let c = self.range(0x800, 0xffff - 0x800); + if c >= 0xd800 { c + 0x800 } else { c } + } + 6 => self.range(0x10000, 0x10ffff), + 7 => 1, + 8 => 0x10ffff, + 9 => 0x1f600, + _ => [0x800, 0xffff, 0x80, 0x7ff, 0x10000][self.byte() as usize % 5], + }; + char::from_u32(code as u32).unwrap_or('a') + } + + /// `lo` to `hi` characters, from a few of them repeated. + fn string(&mut self, lo: u64, hi: u64) -> Result { + let soft = self.scale.soft_chars().saturating_sub(self.chars); + let n = self.length(lo, hi.min(soft.max(lo))); + self.chars = self.chars.saturating_add(n); + if self.chars > self.scale.hard_chars() { + return Err(Overrun); + } + let alphabet: Vec = (0..1 + self.byte() % 3).map(|_| self.char()).collect(); + let mut s = String::with_capacity(n as usize); + for i in 0..n as usize { + s.push(alphabet[i % alphabet.len()]); + } + Ok(s) + } + + /// A value of `ty`; `None` where no value of it exists here (a type that + /// contains itself, past its `@max_depth`). + pub fn value(&mut self, ty: &Ty) -> Result, Overrun> { + self.spend_item()?; + Ok(Some(match ty { + Ty::Scalar(s) => match s { + Scalar::Bool => Value::Bool(self.style != Style::Smallest && self.byte() % 2 == 1), + Scalar::U8 => Value::U8(self.uint(8) as u8), + Scalar::U16 => Value::U16(self.uint(16) as u16), + Scalar::U32 => Value::U32(self.uint(32) as u32), + Scalar::U64 => Value::U64(self.uint(64) as u64), + Scalar::U128 => Value::U128(self.uint(128)), + Scalar::I8 => Value::I8(self.sint(8) as i8), + Scalar::I16 => Value::I16(self.sint(16) as i16), + Scalar::I32 => Value::I32(self.sint(32) as i32), + Scalar::I64 => Value::I64(self.sint(64) as i64), + Scalar::I128 => Value::I128(self.sint(128)), + Scalar::F32 => Value::F32(self.f32()), + Scalar::F64 => Value::F64(self.f64()), + Scalar::String => Value::String(self.string(0, u64::MAX)?), + Scalar::Char => Value::Char(self.char()), + Scalar::Unit => Value::Unit(()), + }, + Ty::CharMax(max) => Value::String(self.string(0, *max)?), + Ty::CharN(n) => { + if *n > self.scale.hard_chars() { + return Err(Overrun); + } + Value::String(self.string(*n, *n)?) + } + Ty::Array(of, max) => { + let soft = self.scale.soft_items().saturating_sub(self.items); + let hi = max.unwrap_or(u64::MAX).min(soft); + let n = self.length(0, hi); + let mut items = Vec::with_capacity(n.min(1 << 16) as usize); + for _ in 0..n { + match self.value(of)? { + Some(item) => items.push(item), + // None of the element exists here: none are written. + None => break, + } + } + Value::Array(items) + } + Ty::Fixed(of, n) => { + if *n > self.scale.hard_items() { + return Err(Overrun); + } + let mut items = Vec::with_capacity((*n).min(1 << 16) as usize); + for _ in 0..*n { + match self.value(of)? { + Some(item) => items.push(item), + None => return Ok(None), + } + } + Value::Tuple(items) + } + Ty::Named(at) => { + let schema = self.schema; + let d = &schema.decls[*at]; + let level = self.stack.iter().filter(|k| **k == *at).count() as u64 + 1; + // A declaration without a limit nests as far as the stack + // goes, then is cut short where it can be (an option, an array). + let cut = d.max_depth.is_some_and(|limit| level > limit) || self.stack.len() >= 24; + match &d.body { + Body::Enum(variants) => { + if variants.is_empty() { + return Ok(None); + } + let last = variants.len() as u64 - 1; + let at = match self.style { + Style::Smallest => 0, + Style::Largest => { + // The last variant, or the one with the longest name. + if self.byte() % 2 == 0 { + last + } else { + (0..variants.len()).max_by_key(|i| variants[*i].len()).unwrap_or(0) as u64 + } + } + Style::Random => self.range(0, last), + }; + Value::UnitVariant { + enum_name: d.name.clone(), + index: at as u32, + variant: variants[at as usize].clone(), + } + } + Body::Alias(target) => { + if cut { + return Ok(None); + } + self.stack.push(*at); + let out = self.value(target); + self.stack.pop(); + return out; + } + Body::Struct(fields) => { + if cut { + return Ok(None); + } + self.stack.push(*at); + let mut given = Vec::with_capacity(fields.len()); + for f in fields { + let value = if f.optional { + let present = match self.style { + Style::Smallest => false, + // Present, until the value is large enough. + Style::Largest => self.items < self.scale.soft_items() / 4, + Style::Random => self.byte() % 2 == 0, + }; + let inner = if present { self.value(&f.ty) } else { Ok(None) }; + match inner { + Ok(inner) => Value::Option(inner.map(Box::new)), + Err(e) => { + self.stack.pop(); + return Err(e); + } + } + } else { + match self.value(&f.ty) { + Ok(Some(v)) => v, + other => { + self.stack.pop(); + return other; + } + } + }; + given.push((f.name.clone(), value)); + } + self.stack.pop(); + Value::Struct { name: d.name.clone(), fields: given } + } + } + } + })) + } +} + +/// At most 600 characters of `s`, for a failure message. +fn shown(s: &str) -> String { + match s.char_indices().nth(600) { + Some((end, _)) => format!("{}... ({} bytes)", &s[..end], s.len()), + None => s.to_string(), + } +} + +/// What a check did, so a test can see it reached something. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Reach { + /// Types core sized, in a format each. + pub sized: u32, + /// Values encoded and held to a range. + pub values: u32, + /// Witnesses encoded and held to exactly `min` or `max`. + pub exact_min: u32, + pub exact_max: u32, + /// The longest value encoded, in bytes. + pub longest: u64, +} + +impl Reach { + pub fn add(&mut self, other: &Reach) { + self.sized += other.sized; + self.values += other.values; + self.exact_min += other.exact_min; + self.exact_max += other.exact_max; + self.longest = self.longest.max(other.longest); + } +} + +/// A disagreement between core's range and an encoder, or a broken +/// generator or witness, described. +#[derive(Debug)] +pub struct Disagreement(pub String); + +impl std::fmt::Display for Disagreement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Disagreement {} + +macro_rules! bail { + ($($arg:tt)*) => { + return Err(Disagreement(format!($($arg)*))) + }; +} + +/// Encodes `value` in `format` and holds its length to `range`. +fn hold( + format: &str, + range: &Range, + value: &Value, + what: &str, + reach: &mut Reach, +) -> Result { + let Some(bytes) = encode_as(format, &Wire(value)) else { + bail!("{format}: the runtime refused to encode {what}: {}", shown(&format!("{value:?}"))); + }; + let len = bytes.len() as u64; + let counted = encoded_len_as(format, &Wire(value)); + if counted != Some(bytes.len()) { + bail!("{format}: encoded_len of {what} is {counted:?}, it encodes to {len} bytes"); + } + if len < range.min || range.max.is_some_and(|max| len > max) { + bail!( + "{format}: {what} encodes to {len} bytes, outside core's range {}..={:?}: {}", + range.min, + range.max, + shown(&format!("{value:?}")) + ); + } + reach.values += 1; + reach.longest = reach.longest.max(len); + Ok(len) +} + +/// Core's range and witnesses for `ty`, held to the encoders in every +/// format, and `values` held to the range. `inhabited` is whether a value +/// of `ty` exists (the simplest was built), where that is known. +pub fn check_type( + schema: &Schema, + ty: &Ty, + inhabited: Option, + values: &[(&str, &Value)], + scale: Scale, + reach: &mut Reach, +) -> Result<(), Disagreement> { + let tyref = ty.to_ref(schema); + for format in FORMATS { + let rules = size::rules(format).expect("the official format has size rules"); + let range = size::range(&tyref, rules, schema, ()); + let Some(range) = range else { + if inhabited == Some(true) { + bail!("{format}: core can't size {ty:?}, a type with a value"); + } + continue; + }; + if inhabited == Some(false) { + bail!("{format}: core sizes {ty:?} as {range:?}, a type with no value"); + } + reach.sized += 1; + for (what, value) in values { + schema.conforms(ty, value).map_err(|why| Disagreement(format!("the generator made {what}, not a {ty:?}: {why}")))?; + hold(format, &range, value, what, reach)?; + } + let budget = schema.witness_budget(ty, scale); + let Some(w) = size::witnesses(&tyref, rules, schema, (), budget) else { + continue; + }; + let min = witness(&w.min).map_err(|e| Disagreement(format!("{format}: the min witness: {e}")))?; + schema.conforms(ty, &min).map_err(|why| Disagreement(format!("{format}: core's min witness isn't a {ty:?}: {why}")))?; + let len = hold(format, &range, &min, "the min witness", reach)?; + if len != range.min { + bail!( + "{format}: the min witness encodes to {len} bytes, core's min is {}: {}", + range.min, + shown(&format!("{min:?}")) + ); + } + reach.exact_min += 1; + if let Some(max) = &w.max { + let max = witness(max).map_err(|e| Disagreement(format!("{format}: the max witness: {e}")))?; + schema.conforms(ty, &max).map_err(|why| Disagreement(format!("{format}: core's max witness isn't a {ty:?}: {why}")))?; + let len = hold(format, &range, &max, "the max witness", reach)?; + if Some(len) != range.max || range.upper_bound { + bail!( + "{format}: the max witness encodes to {len} bytes, core's max is {:?}{}: {}", + range.max, + if range.upper_bound { " (an upper bound, which has no witness)" } else { "" }, + shown(&format!("{max:?}")) + ); + } + reach.exact_max += 1; + } + } + Ok(()) +} + +/// A witness as the conformance crate's [`Value`]. +fn witness(value: &serde_json::Value) -> Result { + Value::deserialize(value).map_err(|e| format!("{e}: {}", shown(&value.to_string()))) +} + +/// Everything `sizes_vs_core` asserts about `schema`: its root and each +/// declaration have core's range and witnesses held to the encoders, and +/// three values of the root (the simplest, a random one and one at the +/// bounds, from the rest of `u`) held to the range. +pub fn check_schema(schema: &Schema, u: &mut Unstructured, scale: Scale) -> Result { + let mut reach = Reach::default(); + let mut tys = vec![schema.root.clone()]; + tys.extend((0..schema.decls.len()).map(Ty::Named)); + for (at, ty) in tys.iter().enumerate() { + let mut values = vec![]; + let simplest = Gen::new(schema, u, Style::Smallest, scale).value(ty); + let inhabited = match &simplest { + Ok(v) => Some(v.is_some()), + // Too big to build here: unknown. + Err(_) => None, + }; + if let Ok(Some(v)) = simplest { + values.push(("the simplest value", v)); + } + // The root also gets values of its own; a declaration, its witnesses. + if at == 0 && inhabited == Some(true) { + for (what, style) in [("a random value", Style::Random), ("a value at the bounds", Style::Largest)] { + match Gen::new(schema, u, style, scale).value(ty) { + Ok(Some(v)) => values.push((what, v)), + // Too big to build here, or none (past @max_depth). + _ => {} + } + } + } + let shown_values: Vec<(&str, &Value)> = values.iter().map(|(w, v)| (*w, v)).collect(); + check_type(schema, ty, inhabited, &shown_values, scale, &mut reach) + .map_err(|e| Disagreement(format!("{e}\n the type: {ty:?}\n the schema: {schema:?}")))?; + } + Ok(reach) +} + +/// The whole check on one input: its scale from the first byte, a schema +/// from what follows, values from the rest. +pub fn check_input(data: &[u8]) -> Result { + let mut u = Unstructured::new(data); + let scale = if byte(&mut u) % 16 == 0 { Scale::Big } else { Scale::Small }; + let schema = read_schema(&mut u); + check_schema(&schema, &mut u, scale) +} diff --git a/runtime/fuzz/tests/sizes.rs b/runtime/fuzz/tests/sizes.rs new file mode 100644 index 0000000..84dfbe5 --- /dev/null +++ b/runtime/fuzz/tests/sizes.rs @@ -0,0 +1,338 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The twin of the `sizes_vs_core` fuzz target, on stable and by `cargo test` +//! (`PROPTEST_CASES` raises the count): `comline-core`'s byte ranges and +//! witnesses for a type, held to the real encoders in `packed@1`, `msgpack@1` +//! and `json@1`. The check is `comline_runtime_fuzz::sizes`'s, the same one +//! the target runs. +//! +//! - Random type graphs and values (what the fuzz target reads from its +//! input), and every scalar over its whole range. +//! - The tiers the specification's size vectors can't store, because their +//! values are too large: a MessagePack array of 65536 items (a five-byte +//! header), a string or array whose length takes a three- and a four-byte +//! varint (2^21 characters), and the longest JSON floats. +//! +//! Run in release: the large values are megabytes. + +use comline_core::schema::ir::size; +use comline_runtime::contract::{FORMAT_JSON, FORMAT_MSGPACK, FORMAT_PACKED}; +use comline_runtime_conformance::{encode_as, encoded_len_as, Value, Wire}; +use comline_runtime_fuzz::sizes::{ + check_input, check_type, Body, Declared, Field, Gen, Reach, Scalar, Scale, Schema, Style, Ty, + FORMATS, +}; +use proptest::prelude::*; + +fn alone(ty: Ty) -> Schema { + Schema { decls: vec![], root: ty } +} + +/// `values` held to core's range for `ty`, with its witnesses encoded to +/// exactly `min` and `max`. +fn hold(schema: &Schema, ty: &Ty, values: &[(&str, Value)]) -> Reach { + let values: Vec<(&str, &Value)> = values.iter().map(|(w, v)| (*w, v)).collect(); + let mut reach = Reach::default(); + check_type(schema, ty, Some(true), &values, Scale::Big, &mut reach).unwrap_or_else(|e| panic!("{e}")); + reach +} + +fn len(format: &str, value: &Value) -> usize { + let bytes = encode_as(format, &Wire(value)).expect("the runtime encodes it"); + assert_eq!(encoded_len_as(format, &Wire(value)), Some(bytes.len()), "{format}: encoded_len"); + bytes.len() +} + +fn range(schema: &Schema, ty: &Ty, format: &str) -> size::Range { + let rules = size::rules(format).unwrap(); + size::range(&ty.to_ref(schema), rules, schema, ()).expect("core sizes it") +} + +proptest! { + /// What the fuzz target does: a schema from the front of the bytes, + /// values from the rest. + #[test] + fn core_ranges_hold_for_random_schemas(data in prop::collection::vec(any::(), 0..4096)) { + check_input(&data).map_err(|e| TestCaseError::fail(e.to_string()))?; + } + + #[test] + fn integers_stay_in_range( + a in any::(), b in any::(), c in any::(), d in any::(), e in any::(), + f in any::(), g in any::(), h in any::(), i in any::(), j in any::(), + k in any::(), + ) { + for (ty, value) in [ + (Scalar::U8, Value::U8(a)), (Scalar::U16, Value::U16(b)), (Scalar::U32, Value::U32(c)), + (Scalar::U64, Value::U64(d)), (Scalar::U128, Value::U128(e)), + (Scalar::I8, Value::I8(f)), (Scalar::I16, Value::I16(g)), (Scalar::I32, Value::I32(h)), + (Scalar::I64, Value::I64(i)), (Scalar::I128, Value::I128(j)), + (Scalar::Bool, Value::Bool(k)), + ] { + let ty = Ty::Scalar(ty); + let mut reach = Reach::default(); + check_type(&alone(ty.clone()), &ty, Some(true), &[("a value", &value)], Scale::Big, &mut reach) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + } + } + + /// Every float the encoders write is within the range, JSON's longest + /// included. + #[test] + fn floats_stay_in_range(x in any::(), y in any::()) { + for (ty, value) in [(Scalar::F64, Value::F64(x)), (Scalar::F32, Value::F32(y))] { + let ty = Ty::Scalar(ty); + let mut reach = Reach::default(); + check_type(&alone(ty.clone()), &ty, Some(true), &[("a float", &value)], Scale::Big, &mut reach) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + } + } + + /// Strings of any characters, under a bound just enough, and exactly. + #[test] + fn strings_stay_in_range( + chars in prop::collection::vec(any::(), 0..200), + spare in 0u64..3, + c in any::(), + ) { + let s: String = chars.iter().collect(); + let n = chars.len() as u64; + for (ty, value) in [ + (Ty::Scalar(Scalar::String), Value::String(s.clone())), + (Ty::CharMax(n + spare), Value::String(s.clone())), + (Ty::CharN(n), Value::String(s.clone())), + (Ty::Scalar(Scalar::Char), Value::Char(c)), + ] { + let mut reach = Reach::default(); + check_type(&alone(ty.clone()), &ty, Some(true), &[("a string", &value)], Scale::Big, &mut reach) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + } + } +} + +/// A MessagePack array header is five bytes from 65536 items, three below +/// it, and one up to 15: the value, and core's witnesses, hit each. +#[test] +fn msgpack_array_headers_of_every_width() { + for (count, header) in [(15, 1), (16, 3), (65535, 3), (65536, 5), (65537, 5)] { + let ty = Ty::Array(Box::new(Ty::Scalar(Scalar::U8)), Some(count)); + let schema = alone(ty.clone()); + let value = Value::Array(vec![Value::U8(255); count as usize]); + let reach = hold(&schema, &ty, &[("a full array", value.clone())]); + // The witnesses are values the budget builds: this is a node each. + assert!(reach.exact_max >= FORMATS.len() as u32, "{count}: no max witness: {reach:?}"); + let bytes = encode_as(FORMAT_MSGPACK, &Wire(&value)).unwrap(); + let tag = match header { + 1 => 0x90 | count as u8, + 3 => 0xdc, + _ => 0xdd, + }; + assert_eq!(bytes[0], tag, "{count} items"); + assert_eq!(bytes.len(), header + count as usize * 2, "{count} items, `cc ff` each"); + // The same count, unbounded: no max, so only the range's floor. + let unbounded = Ty::Array(Box::new(Ty::Scalar(Scalar::U8)), None); + let r = range(&alone(unbounded.clone()), &unbounded, FORMAT_MSGPACK); + assert_eq!(r.max, None); + assert!(bytes.len() as u64 >= r.min); + } +} + +/// A string's length is a varint of one to four bytes (up to 127, 16383, +/// 2^21 - 1, 2^28 - 1) in `packed@1`, and a `str` header of one to five in +/// `msgpack@1`; the longest string a type allows is a witness core builds, +/// and it must be exact in every format. +#[test] +fn string_length_headers_of_every_width() { + // (characters, the varint's bytes for the longest string in `packed@1`) + for (chars, varint) in [ + (31u64, 1), + (32, 2), + (127, 2), + (4095, 2), + (4096, 3), + (16384, 3), + (524287, 3), + (524288, 4), + (1 << 21, 4), + ] { + let ty = Ty::CharMax(chars); + let schema = alone(ty.clone()); + let longest = Value::String("\u{1F600}".repeat(chars as usize)); + let reach = hold(&schema, &ty, &[("the longest string", longest.clone())]); + assert!(reach.exact_max >= FORMATS.len() as u32, "char[max {chars}]: no max witness: {reach:?}"); + let r = range(&schema, &ty, FORMAT_PACKED); + assert_eq!(r.max, Some(chars * 4 + varint)); + assert_eq!(len(FORMAT_PACKED, &longest) as u64, chars * 4 + varint, "char[max {chars}] in packed@1"); + // And one character at a time, `a` each: a different header. + let ascii = Value::String("a".repeat(chars as usize)); + hold(&schema, &ty, &[("an ASCII string", ascii)]); + } +} + +/// A string of exactly 2^21 bytes, where the varint takes a fourth byte, and +/// one byte fewer, where it doesn't. +#[test] +fn a_string_of_2_pow_21_bytes() { + let ty = Ty::Scalar(Scalar::String); + for (bytes, header) in [((1usize << 21) - 1, 3), (1 << 21, 4), ((1 << 21) + 1, 4)] { + let value = Value::String("a".repeat(bytes)); + assert_eq!(len(FORMAT_PACKED, &value), header + bytes); + // A `str32` header: one tag and a four-byte length. + assert_eq!(len(FORMAT_MSGPACK, &value), 5 + bytes); + assert_eq!(len(FORMAT_JSON, &value), 2 + bytes); + hold(&alone(ty.clone()), &ty, &[("a long string", value)]); + } +} + +/// A count of 3 and 4 bytes: arrays past 16383 and 2^21 - 1 items. +#[test] +fn array_count_varints_of_every_width() { + for (count, header) in [(16383u64, 2), (16384, 3), (2097151, 3), (2097152, 4)] { + let ty = Ty::Array(Box::new(Ty::Scalar(Scalar::U8)), Some(count)); + let schema = alone(ty.clone()); + // Core builds no witness past its budget; the value alone is held. + let value = Value::Array(vec![Value::U8(0); count as usize]); + assert_eq!(len(FORMAT_PACKED, &value) as u64, header + count); + let r = range(&schema, &ty, FORMAT_PACKED); + assert_eq!(r.max, Some(header + count), "u8[max {count}] in packed@1"); + let r = range(&schema, &ty, FORMAT_MSGPACK); + let tag = if count > 65535 { 5 } else { 3 }; + assert_eq!(r.max, Some(tag + count * 2), "u8[max {count}] in msgpack@1"); + hold(&schema, &ty, &[("a full array", value)]); + } +} + +/// The longest text `serde_json` writes for a float. Core's maximum for each +/// is one a witness reaches; a scan of the floats, spread over every +/// exponent, finds none longer, and reaches the maximum. +#[test] +fn the_longest_json_floats() { + for (ty, longest) in [(Scalar::F32, 16usize), (Scalar::F64, 24)] { + let ty = Ty::Scalar(ty); + let schema = alone(ty.clone()); + let r = range(&schema, &ty, FORMAT_JSON); + assert_eq!(r.max, Some(longest as u64), "{ty:?}"); + assert!(!r.upper_bound, "{ty:?}"); + let reach = hold(&schema, &ty, &[("zero", if ty == Ty::Scalar(Scalar::F32) { Value::F32(0.0) } else { Value::F64(0.0) })]); + assert_eq!(reach.exact_max, FORMATS.len() as u32, "{ty:?}: every format has a max witness"); + } + // Every f32 from a stride through the bit patterns (a million), both + // signs; a xorshift over f64 bits. + let mut longest = 0; + for bits in (0..=u32::MAX).step_by(4093) { + for bits in [bits, bits | 0x8000_0000] { + let x = f32::from_bits(bits); + if x.is_finite() { + longest = longest.max(len(FORMAT_JSON, &Value::F32(x))); + } + } + } + assert!(longest <= 16, "an f32 takes {longest} bytes in json@1"); + assert_eq!(longest, 16, "the scan reaches the longest f32"); + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut longest = 0; + for _ in 0..1_000_000 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let x = f64::from_bits(state); + if x.is_finite() { + longest = longest.max(len(FORMAT_JSON, &Value::F64(x))); + } + } + assert!(longest <= 24, "an f64 takes {longest} bytes in json@1"); + assert_eq!(longest, 24, "the scan reaches the longest f64"); + // NaN and infinity are written as `null`, which the range holds. + for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(len(FORMAT_JSON, &Value::F64(x)), 4); + assert_eq!(len(FORMAT_JSON, &Value::F32(x as f32)), 4); + } +} + +/// An enum of more than 128 variants: the index is a two-byte varint from the +/// 129th, and a name is as long as it is; every variant is held. +#[test] +fn an_enum_of_200_variants() { + let variants: Vec = + (0..200).map(|i| format!("V{i}{}", "n".repeat(if i % 5 == 0 { 254 } else { 0 }))).collect(); + let schema = Schema { + decls: vec![Declared { name: "E0".into(), body: Body::Enum(variants.clone()), max_depth: None }], + root: Ty::Named(0), + }; + let ty = Ty::Named(0); + let values: Vec<(&str, Value)> = variants + .iter() + .enumerate() + .map(|(i, v)| { + ("a variant", Value::UnitVariant { enum_name: "E0".into(), index: i as u32, variant: v.clone() }) + }) + .collect(); + hold(&schema, &ty, &values); + let at = |i: usize| len(FORMAT_PACKED, &values[i].1); + assert_eq!((at(127), at(128)), (1, 2)); +} + +/// A struct that contains itself under `@max_depth`: the deepest value, the +/// shallowest, and one past the limit that isn't a value. +#[test] +fn recursion_to_the_limit() { + let node = |next: Option| Value::Struct { + name: "S0".into(), + fields: vec![ + ("label0".into(), Value::String("\u{1F600}\u{1F600}\u{1F600}".into())), + ("next1".into(), Value::Option(next.map(Box::new))), + ], + }; + for limit in 1..=4u64 { + let schema = Schema { + decls: vec![Declared { + name: "S0".into(), + body: Body::Struct(vec![ + Field { name: "label0".into(), ty: Ty::CharMax(3), optional: false }, + Field { name: "next1".into(), ty: Ty::Named(0), optional: true }, + ]), + max_depth: Some(limit), + }], + root: Ty::Named(0), + }; + let ty = Ty::Named(0); + let mut deepest = node(None); + for _ in 1..limit { + deepest = node(Some(deepest)); + } + let values = [("the deepest", deepest.clone()), ("the shallowest", node(None))]; + let reach = hold(&schema, &ty, &values); + assert_eq!(reach.exact_max, FORMATS.len() as u32, "@max_depth {limit}"); + // A level past the limit is not of the type, and the check knows it. + let past = node(Some(deepest)); + assert!(schema.conforms(&ty, &past).is_err(), "@max_depth {limit}"); + } +} + +/// The fuzz target reaches the tiers: its generator, over a few inputs, +/// makes the arrays and strings the vectors can't store. +#[test] +fn the_generator_reaches_the_large_tiers() { + let reaches = |ty: Ty, at_least: usize, count: fn(&Value) -> usize| { + let schema = alone(ty.clone()); + (0u8..=255).any(|seed| { + let data = [seed; 64]; + let mut u = arbitrary::Unstructured::new(&data); + matches!(Gen::new(&schema, &mut u, Style::Largest, Scale::Big).value(&ty), Ok(Some(v)) if count(&v) >= at_least) + }) + }; + let items = |v: &Value| match v { + Value::Array(items) => items.len(), + _ => 0, + }; + let chars = |v: &Value| match v { + Value::String(s) => s.chars().count(), + _ => 0, + }; + assert!(reaches(Ty::Array(Box::new(Ty::Scalar(Scalar::U8)), Some(70000)), 65536, items)); + assert!(reaches(Ty::Array(Box::new(Ty::Scalar(Scalar::U8)), None), 65536, items)); + assert!(reaches(Ty::CharMax(1 << 21), 1 << 21, chars)); + assert!(reaches(Ty::Scalar(Scalar::String), 1 << 16, chars)); +}