This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 9ae00e73b Differential Fuzzing (3/3): Check `DuckDbDialect` against 
DuckDB's own parser (#2564)
9ae00e73b is described below

commit 9ae00e73b54ebbca2341c3de22312a8011e98757
Author: Luca Cappelletti <[email protected]>
AuthorDate: Wed Sep 23 21:31:50 2026 +0000

    Differential Fuzzing (3/3): Check `DuckDbDialect` against DuckDB's own 
parser (#2564)
---
 .clusterfuzzlite/build.sh                |   2 +
 docs/fuzzing.md                          |   2 +
 fuzz/Cargo.toml                          |   9 ++
 fuzz/fuzz_targets/fuzz_duckdb_accepts.rs | 210 +++++++++++++++++++++++++++++++
 4 files changed, 223 insertions(+)

diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh
index 764144a64..e5df5a361 100755
--- a/.clusterfuzzlite/build.sh
+++ b/.clusterfuzzlite/build.sh
@@ -20,6 +20,8 @@ set -eu
 
 cd "$SRC/datafusion-sqlparser-rs"
 # the base image's RUSTUP_TOOLCHAIN (nightly) overrides the rust-toolchain 
pin, so we name none
+# CXXFLAGS in the image carries -stdlib=libc++ while the cc crate defaults to 
stdc++, so override.
+export CXXSTDLIB=c++
 cargo fuzz build -O --fuzz-dir fuzz
 
 targets=$(cargo fuzz list --fuzz-dir fuzz)
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
index 7c56c0c77..f8f2aa456 100644
--- a/docs/fuzzing.md
+++ b/docs/fuzzing.md
@@ -35,6 +35,8 @@ rendered statement no longer parses.
 
 `fuzz_postgres_accepts` parses the input with PostgreSQL's own grammar through 
`pg_query` and fails when PostgreSQL accepts SQL that `PostgreSqlDialect` 
rejects.
 
+`fuzz_duckdb_accepts` parses the input with DuckDB's own parser and fails when 
DuckDB accepts a `SELECT` that `DuckDbDialect` rejects.
+
 ClusterFuzzLite runs continuous fuzzing. Every pull request fuzzes for 10 
minutes in
 `code-change` mode, a daily batch job grows the shared corpus stored on the
 `clusterfuzzlite` branch, and a daily prune compacts it.
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index 42c91742b..21e766295 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -28,6 +28,8 @@ cargo-fuzz = true
 libfuzzer-sys = "0.4"
 libsqlite3-sys = { version = "0.38", features = ["bundled"] }
 pg_query = "6.2"
+libduckdb-sys = { version = "1.10505.0", features = ["bundled", "json"] }
+serde_json = "1"
 sqlparser = { path = ".." }
 
 # Prevent this from interfering with workspaces
@@ -61,3 +63,10 @@ path = "fuzz_targets/fuzz_postgres_accepts.rs"
 test = false
 doc = false
 bench = false
+
+[[bin]]
+name = "fuzz_duckdb_accepts"
+path = "fuzz_targets/fuzz_duckdb_accepts.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/fuzz_targets/fuzz_duckdb_accepts.rs 
b/fuzz/fuzz_targets/fuzz_duckdb_accepts.rs
new file mode 100644
index 000000000..0a7e6470a
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_duckdb_accepts.rs
@@ -0,0 +1,210 @@
+// 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.
+
+#![no_main]
+
+use libduckdb_sys as ffi;
+use libfuzzer_sys::fuzz_target;
+use serde_json::Value;
+use sqlparser::dialect::DuckDbDialect;
+use sqlparser::parser::{Parser, ParserError};
+use std::collections::HashSet;
+use std::ffi::{CStr, CString};
+use std::mem;
+use std::ptr;
+
+struct Ctx {
+    _db: ffi::duckdb_database,
+    _conn: ffi::duckdb_connection,
+    // Prepared `SELECT json_serialize_sql($1)` reused for every input
+    stmt: ffi::duckdb_prepared_statement,
+    catalog: HashSet<String>,
+}
+
+fn load_catalog(conn: ffi::duckdb_connection) -> HashSet<String> {
+    // SAFETY: duckdb_result is plain C data that the query call overwrites 
before any read.
+    let mut result = unsafe { mem::zeroed::<ffi::duckdb_result>() };
+    // SAFETY: conn is valid, the query is NUL-terminated, and result is a 
valid out value.
+    let rc = unsafe {
+        ffi::duckdb_query(
+            conn,
+            c"SELECT DISTINCT function_name FROM duckdb_functions()".as_ptr(),
+            &mut result,
+        )
+    };
+    assert_eq!(rc, ffi::duckdb_state_DuckDBSuccess, "cannot load catalog");
+    // SAFETY: result holds a successful query result.
+    let rows = unsafe { ffi::duckdb_row_count(&mut result) };
+    let mut catalog = HashSet::new();
+    for i in 0..rows {
+        // SAFETY: result is valid and col 0 row i is within bounds.
+        let ptr = unsafe { ffi::duckdb_value_varchar(&mut result, 0, i) };
+        if !ptr.is_null() {
+            // SAFETY: ptr is non-null and NUL-terminated per 
duckdb_value_varchar contract.
+            let name = unsafe { CStr::from_ptr(ptr) }
+                .to_string_lossy()
+                .into_owned();
+            // SAFETY: ptr is a DuckDB allocation and must be freed with 
duckdb_free.
+            unsafe { ffi::duckdb_free(ptr.cast()) };
+            catalog.insert(name);
+        }
+    }
+    // SAFETY: result must always be destroyed.
+    unsafe { ffi::duckdb_destroy_result(&mut result) };
+    catalog
+}
+
+fn init_ctx() -> Ctx {
+    let mut db: ffi::duckdb_database = ptr::null_mut();
+    // SAFETY: null path opens an in-memory database, and db is a valid out 
pointer.
+    let rc = unsafe { ffi::duckdb_open(ptr::null(), &mut db) };
+    assert_eq!(rc, ffi::duckdb_state_DuckDBSuccess, "cannot open DuckDB");
+    let mut conn: ffi::duckdb_connection = ptr::null_mut();
+    // SAFETY: db is a valid open database handle, and conn is a valid out 
pointer.
+    let rc = unsafe { ffi::duckdb_connect(db, &mut conn) };
+    assert_eq!(
+        rc,
+        ffi::duckdb_state_DuckDBSuccess,
+        "cannot connect to DuckDB"
+    );
+    // SAFETY: duckdb_result is plain C data that the query call overwrites 
before any read.
+    let mut load_result = unsafe { mem::zeroed::<ffi::duckdb_result>() };
+    // SAFETY: conn is valid, the query is NUL-terminated, and load_result is 
a valid out value.
+    let rc = unsafe { ffi::duckdb_query(conn, c"LOAD json".as_ptr(), &mut 
load_result) };
+    assert_eq!(
+        rc,
+        ffi::duckdb_state_DuckDBSuccess,
+        "cannot load json extension"
+    );
+    // SAFETY: result must always be destroyed.
+    unsafe { ffi::duckdb_destroy_result(&mut load_result) };
+    let catalog = load_catalog(conn);
+    let mut stmt: ffi::duckdb_prepared_statement = ptr::null_mut();
+    // SAFETY: conn is valid, the query is NUL-terminated, and stmt is a valid 
out pointer.
+    let rc = unsafe {
+        ffi::duckdb_prepare(
+            conn,
+            c"SELECT json_serialize_sql($1::VARCHAR)".as_ptr(),
+            &mut stmt,
+        )
+    };
+    assert_eq!(
+        rc,
+        ffi::duckdb_state_DuckDBSuccess,
+        "cannot prepare json_serialize_sql"
+    );
+    Ctx {
+        _db: db,
+        _conn: conn,
+        stmt,
+        catalog,
+    }
+}
+
+// json_serialize_sql is parse-only with no transaction or schema state 
accumulated across calls.
+thread_local! {
+    static CTX: Ctx = init_ctx();
+}
+
+fn collect_fn_names<'a>(val: &'a Value, out: &mut Vec<&'a str>) {
+    match val {
+        Value::Object(map) => {
+            if let Some(Value::String(name)) = map.get("function_name") {
+                out.push(name.as_str());
+            }
+            for v in map.values() {
+                collect_fn_names(v, out);
+            }
+        }
+        Value::Array(arr) => {
+            for v in arr {
+                collect_fn_names(v, out);
+            }
+        }
+        _ => {}
+    }
+}
+
+// DuckDB parses unknown operator strings as calls to functions that do not 
exist, so they never bind.
+fn is_bogus_operator(name: &str, catalog: &HashSet<String>) -> bool {
+    if catalog.contains(name) {
+        return false;
+    }
+    let base = name.strip_suffix("__postfix").unwrap_or(name);
+    !base.is_empty() && base.chars().all(|c| !c.is_alphanumeric() && c != '_')
+}
+
+fuzz_target!(|sql: &str| {
+    if sql.contains('\0') {
+        return;
+    }
+    CTX.with(|ctx| {
+        // Bound to a name so the pointer outlives the FFI call
+        let c_sql = CString::new(sql).unwrap();
+        // SAFETY: stmt is valid, param index 1 matches $1, and c_sql outlives 
this call.
+        if unsafe { ffi::duckdb_bind_varchar(ctx.stmt, 1, c_sql.as_ptr()) }
+            != ffi::duckdb_state_DuckDBSuccess
+        {
+            return;
+        }
+        // SAFETY: duckdb_result is plain C data that the query call 
overwrites before any read.
+        let mut result = unsafe { mem::zeroed::<ffi::duckdb_result>() };
+        // SAFETY: stmt is valid with param 1 bound, and result is a valid out 
value.
+        if unsafe { ffi::duckdb_execute_prepared(ctx.stmt, &mut result) }
+            != ffi::duckdb_state_DuckDBSuccess
+        {
+            // SAFETY: result must always be destroyed.
+            unsafe { ffi::duckdb_destroy_result(&mut result) };
+            return;
+        }
+        // SAFETY: result is valid, col 0 row 0 exists, and the returned 
pointer is freed below.
+        let json_ptr = unsafe { ffi::duckdb_value_varchar(&mut result, 0, 0) };
+        // SAFETY: result must always be destroyed.
+        unsafe { ffi::duckdb_destroy_result(&mut result) };
+        if json_ptr.is_null() {
+            return;
+        }
+        // SAFETY: json_ptr is non-null and NUL-terminated per 
duckdb_value_varchar contract.
+        let ast_result =
+            serde_json::from_slice::<Value>(unsafe { CStr::from_ptr(json_ptr) 
}.to_bytes());
+        // SAFETY: json_ptr is a DuckDB allocation and must be freed with 
duckdb_free.
+        unsafe { ffi::duckdb_free(json_ptr.cast()) };
+        let ast = match ast_result {
+            Ok(v) => v,
+            Err(_) => return,
+        };
+        // json_serialize_sql sets "error":true for parse failures and 
non-SELECT statements.
+        if ast.get("error").and_then(Value::as_bool).unwrap_or(false) {
+            return;
+        }
+        let mut fn_names: Vec<&str> = Vec::new();
+        collect_fn_names(&ast, &mut fn_names);
+        if fn_names.iter().any(|n| is_bogus_operator(n, &ctx.catalog)) {
+            return;
+        }
+        match Parser::parse_sql(&DuckDbDialect {}, sql) {
+            Ok(_) | Err(ParserError::RecursionLimitExceeded) => {}
+            Err(err) => {
+                // SAFETY: duckdb_library_version returns a static 
NUL-terminated string.
+                let version = unsafe { 
CStr::from_ptr(ffi::duckdb_library_version()) };
+                panic!(
+                    "DuckDB {version:?} accepts SQL the DuckDB dialect 
rejects\n  sql: {sql:?}\n  error: {err}"
+                )
+            }
+        }
+    });
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to