Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ jobs:
- uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: Test
run: cargo test --locked --all-features
- name: Test fuzz helpers
run: cargo test --locked --manifest-path fuzz/Cargo.toml --test expression

coverage:
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion docs/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ rendered statement no longer parses.

`fuzz_duckdb_accepts` parses the input with DuckDB's own parser and fails when DuckDB accepts a `SELECT` that `DuckDbDialect` rejects.

The three oracle targets compile their engine from source, so each builds only with its feature, `sqlite`, `postgres` or `duckdb`, as in `cargo +nightly fuzz run --features sqlite fuzz_sqlite_accepts fuzz_seeds`. `cargo +nightly fuzz build --all-features` builds every target.
`fuzz_postgres_expr` and `fuzz_sqlite_expr` generate operator chains from a small token grammar. They fail when the engine reads sqlparser's printed statement differently from the original, which catches misreads and printing bugs, and then when it reads the fully parenthesized print differently from the plain one, which catches grouping bugs. Custom operators print as `OPERATOR(name)` so lexing differences show up too.

The oracle targets compile their engine from source, so each builds only with its feature, `sqlite`, `postgres` or `duckdb`, as in `cargo +nightly fuzz run --features sqlite fuzz_sqlite_accepts fuzz_seeds`. `cargo +nightly fuzz build --all-features` builds every target.

`fuzz_stage_cost` times tokenizing, parsing and printing the input with every dialect and fails when parsing takes more than 50 times as long as tokenizing, or printing more than 50 times as long as parsing, once the slower stage passes 10 ms. It reports superlinear paths whose cost stays far below the fuzzer's timeout.

Expand Down
51 changes: 48 additions & 3 deletions fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 21 additions & 4 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ publish = false
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"
libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
libsqlite3-sys = { version = "0.39", features = ["bundled"], optional = true }
pg_query = { version = "6.2", optional = true }
libduckdb-sys = { version = "1.10505.0", features = ["bundled", "json"], optional = true }
serde_json = { version = "1", optional = true }
sqlparser = { path = ".." }
thiserror = { version = "2", optional = true }
sqlparser = { path = "..", features = ["visitor"] }

# The oracle targets build their engines from source, so each compiles only when its feature is on.
[features]
sqlite = ["dep:libsqlite3-sys"]
postgres = ["dep:pg_query"]
sqlite = ["dep:libsqlite3-sys", "dep:thiserror"]
postgres = ["dep:pg_query", "dep:serde_json"]
duckdb = ["dep:libduckdb-sys", "dep:serde_json"]

# Prevent this from interfering with workspaces
Expand Down Expand Up @@ -80,6 +81,22 @@ test = false
doc = false
bench = false

[[bin]]
name = "fuzz_sqlite_expr"
path = "fuzz_targets/fuzz_sqlite_expr.rs"
required-features = ["sqlite"]
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_postgres_expr"
path = "fuzz_targets/fuzz_postgres_expr.rs"
required-features = ["postgres"]
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_stage_cost"
path = "fuzz_targets/fuzz_stage_cost.rs"
Expand Down
226 changes: 226 additions & 0 deletions fuzz/fuzz_targets/expression/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

//! Unparenthesized operator chains for the expression shape targets.

use libfuzzer_sys::arbitrary::{self, Arbitrary};
use sqlparser::ast::{visit_expressions_mut, Expr, Statement};
use std::fmt::Write;
use std::ops::ControlFlow;

const MAX_DEPTH: usize = 6;

/// The tokens one dialect's chains are built from.
pub struct Grammar {
pub atoms: &'static [&'static str],
pub prefix: &'static [&'static str],
pub infix: &'static [&'static str],
pub postfix: &'static [&'static str],
pub functions: &'static [&'static str],
pub types: &'static [&'static str],
/// Opening and closing text of an array constructor.
pub array: (&'static str, &'static str),
/// Statements a chain is placed in, with `{}` where the chain goes.
pub contexts: &'static [&'static str],
}

/// A chain placed in one of the grammar's statement contexts.
#[derive(Arbitrary, Debug)]
pub struct Case {
context: u8,
chain: Chain,
}

impl Case {
/// Writes the statement as SQL text in the tokens of `grammar`.
pub fn render(&self, grammar: &Grammar) -> String {
pick(grammar.contexts, self.context).replacen("{}", &self.chain.render(grammar), 1)
}
}

/// An expression written as operands joined by operators, with no parentheses unless the input asks for a group.
#[derive(Arbitrary, Debug)]
pub struct Chain {
first: Operand,
rest: Vec<(Infix, Operand)>,
}

#[derive(Arbitrary, Debug)]
struct Infix {
op: u8,
tight: bool,
}

#[derive(Arbitrary, Debug)]
struct Operand {
prefix: Vec<u8>,
primary: Primary,
postfix: Vec<u8>,
tight: bool,
}

#[derive(Arbitrary, Debug)]
enum Primary {
Atom(u8),
Group(Box<Chain>),
List(Box<Chain>, Vec<Chain>),
Call(u8, Vec<Chain>),
Cast(Box<Chain>, u8),
Case {
operand: Option<Box<Chain>>,
when: Box<(Chain, Chain)>,
more: Vec<(Chain, Chain)>,
otherwise: Option<Box<Chain>>,
},
Scalar(Box<Chain>),
Array(Vec<Chain>),
}

fn pick(table: &'static [&'static str], index: u8) -> &'static str {
table[usize::from(index) % table.len()]
}

impl Chain {
/// Writes the chain as SQL text in the tokens of `grammar`.
pub fn render(&self, grammar: &Grammar) -> String {
let mut out = String::new();
self.write(grammar, 0, &mut out);
out
}

fn write(&self, grammar: &Grammar, depth: usize, out: &mut String) {
self.first.write(grammar, depth, out);
for (infix, operand) in &self.rest {
let gap = if infix.tight { "" } else { " " };
let _ = write!(out, "{gap}{}{gap}", pick(grammar.infix, infix.op));
operand.write(grammar, depth, out);
}
}
}

impl Operand {
fn write(&self, grammar: &Grammar, depth: usize, out: &mut String) {
let gap = if self.tight { "" } else { " " };
for &op in &self.prefix {
let _ = write!(out, "{}{gap}", pick(grammar.prefix, op));
}
self.primary.write(grammar, depth, out);
for &op in &self.postfix {
let _ = write!(out, "{gap}{}", pick(grammar.postfix, op));
}
}
}

fn write_list<'a>(
chains: impl IntoIterator<Item = &'a Chain>,
grammar: &Grammar,
depth: usize,
out: &mut String,
) {
for (i, chain) in chains.into_iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
chain.write(grammar, depth, out);
}
}

impl Primary {
fn write(&self, grammar: &Grammar, depth: usize, out: &mut String) {
let inner = depth + 1;
if inner > MAX_DEPTH && !matches!(self, Primary::Atom(_)) {
out.push_str(grammar.atoms[0]);
return;
}
match self {
Primary::Atom(i) => out.push_str(pick(grammar.atoms, *i)),
Primary::Group(chain) => {
out.push('(');
chain.write(grammar, inner, out);
out.push(')');
}
Primary::List(first, rest) => {
out.push('(');
write_list(std::iter::once(&**first).chain(rest), grammar, inner, out);
out.push(')');
}
Primary::Call(name, args) => {
let _ = write!(out, "{}(", pick(grammar.functions, *name));
write_list(args, grammar, inner, out);
out.push(')');
}
Primary::Cast(chain, ty) => {
out.push_str("CAST(");
chain.write(grammar, inner, out);
let _ = write!(out, " AS {})", pick(grammar.types, *ty));
}
Primary::Case {
operand,
when,
more,
otherwise,
} => {
out.push_str("CASE");
if let Some(operand) = operand {
out.push(' ');
operand.write(grammar, inner, out);
}
for (condition, result) in std::iter::once(&**when).chain(more) {
out.push_str(" WHEN ");
condition.write(grammar, inner, out);
out.push_str(" THEN ");
result.write(grammar, inner, out);
}
if let Some(otherwise) = otherwise {
out.push_str(" ELSE ");
otherwise.write(grammar, inner, out);
}
out.push_str(" END");
}
Primary::Scalar(chain) => {
out.push_str("(SELECT ");
chain.write(grammar, inner, out);
out.push(')');
}
Primary::Array(elements) => {
out.push_str(grammar.array.0);
write_list(elements, grammar, inner, out);
out.push_str(grammar.array.1);
}
}
}
}

/// Wraps every compound expression of `statement` in parentheses, so its text spells out the tree sqlparser built.
pub fn parenthesize(statement: &mut Statement) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It rewrites the statement tree, so let's cover it with tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered

let _ = visit_expressions_mut(statement, |expr| {
// These already print inside their own parentheses or are single tokens.
if !matches!(
expr,
Expr::Value(_)
| Expr::Identifier(_)
| Expr::CompoundIdentifier(_)
| Expr::Nested(_)
| Expr::Subquery(_)
| Expr::Tuple(_)
) {
let inner = std::mem::replace(expr, Expr::value(sqlparser::ast::Value::Null));
*expr = Expr::Nested(Box::new(inner));
}
ControlFlow::<()>::Continue(())
});
}
Loading
Loading