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
59 changes: 59 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,64 @@ jobs:
echo "=== logs ==="
docker compose logs --tail=300

fuzz:
name: Fuzz (ASAN smoke)
runs-on: ubuntu-24.04
timeout-minutes: 20
needs: [unit-tests]
steps:
# The fuzz binaries link this driver, which links libodbc through
# odbc-sys, so the unixODBC dev libraries must be present to link them.
- name: Install host dependencies
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: unixodbc-dev
version: ubuntu-24.04

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# cargo-fuzz builds with libFuzzer + AddressSanitizer, which require a
# nightly toolchain. The fuzz crate is its own Cargo workspace, so the
# pinned stable root build never touches it.
- name: Install nightly toolchain
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # nightly
with:
toolchain: nightly

# fuzz/ declares its own [workspace], so its build artifacts land in
# fuzz/target, not the root target/. Without this the cache stores an
# empty directory and every run rebuilds nightly + ASAN from scratch.
- name: Setup Rust Cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: fuzz
workspaces: "fuzz -> target"

- name: Install cargo-fuzz
uses: taiki-e/install-action@1beb33eee6d086258184383af9a538940be190ed # v2.85.6
with:
tool: cargo-fuzz

# A short smoke run per target: long enough to catch a regression that
# reintroduces a shallow crash, short enough for per-PR CI. Finding a new
# defect is the job of a longer run, not of this gate.
#
# --target is pinned to the gnu triple explicitly: newer cargo-fuzz
# defaults to x86_64-unknown-linux-musl, whose statically linked libc is
# incompatible with AddressSanitizer ("sanitizer is incompatible with
# statically linked libc"). gnu uses a dynamic libc and ships with the
# nightly toolchain.
- name: Fuzz json_value
run: cargo +nightly fuzz run json_value --target x86_64-unknown-linux-gnu -- -max_total_time=30
- name: Fuzz type_name
run: cargo +nightly fuzz run type_name --target x86_64-unknown-linux-gnu -- -max_total_time=30
- name: Fuzz escape
run: cargo +nightly fuzz run escape --target x86_64-unknown-linux-gnu -- -max_total_time=30
- name: Fuzz connect_params
run: cargo +nightly fuzz run connect_params --target x86_64-unknown-linux-gnu -- -max_total_time=30

# Single required check for branch protection rules.
finished:
name: Finished Build and Test
Expand All @@ -406,6 +464,7 @@ jobs:
- unit-tests-windows
- release-artifacts
- integration-tests
- fuzz
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

A `TIME WITH TIME ZONE` value whose offset or hour field lies far outside a real
clock is now kept as text instead of being converted with wrapped arithmetic.
Both fields arrive as free-form text, so a number that no zone or clock could
hold still parses as an `i32`, and the conversion to minutes overflowed. Release
builds carry no overflow checks, so the driver reported a different time rather
than declining the value.

### Added

Fuzz targets for this driver's own parsers, in `fuzz/`: the JSON-to-value read
path, the Trino type-signature parsers, ODBC escape translation under the Trino
dialect, and the connection-string value parsing. They run as an
AddressSanitizer smoke test in CI. See `fuzz/README.md`.

## [0.1.0] — 2026-08-04

First release, so this section describes what the driver offers rather than
Expand Down
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ categories = ["database", "external-ffi-bindings", "api-bindings"]
[lib]
crate-type = ["cdylib", "rlib"]

[features]
# Exposes the `fuzz_api` module, which re-exports the private entry points the
# targets in `fuzz/` drive.
#
# Default-off because it is test scaffolding: the modules it widens are private
# on purpose, and a shipped driver has no reason to carry a second, unsupported
# way into its parsers. `fuzz/Cargo.toml` enables it; nothing else should.
fuzzing = []

[dependencies]
base64 = "0.23"
chrono = { version = "0.4", default-features = false }
Expand Down
11 changes: 11 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
target
corpus
artifacts
coverage
# `-jobs=N` writes one of these per worker into whatever directory the run
# started from.
fuzz-*.log
# Untracked, matching stackable-odbc-core's fuzz workspace. These targets are
# never published and CI resolves them fresh, so pinning here would only add a
# second lockfile for Renovate to carry.
Cargo.lock
50 changes: 50 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[package]
name = "stackable-odbc-trino-fuzz"
version = "0.0.1"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

# Own workspace: keeps `cargo fuzz` from resolving against the parent workspace
# (which pins a stable toolchain; libFuzzer needs nightly).
[workspace]

[[bin]]
name = "json_value"
path = "fuzz_targets/json_value.rs"
test = false
doc = false
bench = false

[[bin]]
name = "type_name"
path = "fuzz_targets/type_name.rs"
test = false
doc = false
bench = false

[[bin]]
name = "escape"
path = "fuzz_targets/escape.rs"
test = false
doc = false
bench = false

[[bin]]
name = "connect_params"
path = "fuzz_targets/connect_params.rs"
test = false
doc = false
bench = false

[dependencies]
arbitrary = { version = "1", features = ["derive"] }
libfuzzer-sys = "0.4"
serde_json = "1"
stackable-odbc-trino = { path = "..", features = ["fuzzing"] }
# Needed to name `TrinoTy` when building the declared type for a fuzzed value.
# Kept in step with the root `Cargo.toml` entry: a different revision here
# would resolve to a second copy of the crate and the types would not match.
trino-rust-client = { git = "https://github.com/stackabletech/trino-rust-client.git", branch = "stackable-main" }
91 changes: 91 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Fuzz targets

These targets cover this driver's own parsers: the code that turns text chosen
by a Trino coordinator, or by the application, into values the driver acts on.

None of it contains `unsafe`, so AddressSanitizer is not what earns these their
keep, and that is a real difference from the targets in
`stackable-odbc-core`, which exist for the pointer marshalling. What earns
these is coverage guidance plus a structured generator. Every target below
builds its input from a grammar rather than from raw bytes, because the inputs
that reach these parsers are shaped: a random byte string is not a balanced
`{fn CONVERT(x, SQL_INTEGER)}`, is not `13:14:15+02:00`, and is not
`Host=h;Port=8443;Roles=x:y`. A `proptest` over `".*"` explores the same space
and essentially never lands in it.

The property under test is that no input panics. Panics are caught at the FFI
boundary by core's `catch_unwind`, so the blast radius is a failed ODBC call
rather than a crashed application. That is the floor, not a reason to accept
one: a value a coordinator legitimately sent must fail safe, and where a
release build has no overflow checks the same defect returns a wrong answer
instead of an error.

- `json_value` covers `json_to_column_value` and the dozen temporal, interval
and decimal scanners under it. This is the half of the read path core does
not see: core fuzzes `write_column_value`, which turns the resulting
`ColumnValue` into the caller's buffer, and nothing covered the step that
produces it.
- `type_name` covers `type_name_precision`, `type_name_scale` and
`trino_type_name_to_sql_type`, which read Trino type signatures as text out
of `DESCRIBE INPUT` rows and `information_schema` queries.
- `escape` covers core's escape translator driven by this crate's dialect. Core
fuzzes the parser against its own dialects; only this repo has the Trino
dialect, so only this repo reaches `escape_dialect::split_args`.
- `connect_params` covers the per-key value parsing layered on core's
`ConnectParams::parse`: durations, booleans, proxy URLs, time zones, selected
roles and four `key:value`-inside-a-value sublanguages.

The targets reach these through `stackable_odbc_trino::fuzz_api`, behind the
default-off `fuzzing` feature. The modules are private on purpose and the
feature does not reach the shipped `cdylib`; `fuzz_api`'s doc comment lists what
is exposed and why.

## Running

[cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) needs nightly, because
libFuzzer does.

```bash
cargo install cargo-fuzz
cargo +nightly fuzz run json_value
```

If cargo-fuzz fails with "sanitizer is incompatible with statically linked
libc", it picked a musl target. Pin the gnu triple explicitly, which is what CI
does:

```bash
cargo +nightly fuzz run json_value --target x86_64-unknown-linux-gnu
```

`cargo fuzz run` runs until it finds a crash or you stop it. To bound a run,
pass a libFuzzer flag after `--`:

```bash
cargo +nightly fuzz run json_value -- -max_total_time=60 # stop after 60s
cargo +nightly fuzz run json_value -- -runs=1000000 # or a run count
```

A crash writes its input to `artifacts/<target>/`. To see it as the generator's
own types rather than as bytes:

```bash
cargo +nightly fuzz fmt json_value artifacts/json_value/crash-<hash>
```

## Reading a clean run

A target that finds nothing is only as good as the inputs its generator can
express. `type_name` ran fifteen million executions without reaching a slice
that a hand-read of the code said was reachable, and the reason was the
generator: it rendered `base(args)suffix`, so the opening parenthesis always
preceded the closing one and the shape under suspicion could not be built. When
a run comes back clean, check that the generator can actually produce the input
you had in mind before concluding the code is safe.

## Workspace

This is its own Cargo workspace, so `cargo build` in the repository root does
not touch it and no `pre-commit` hook compiles it. A change to
`type_conversion`, `escape_dialect` or the connection-string parsing can break
it while every root check still passes, so build it by hand after such a change.
116 changes: 116 additions & 0 deletions fuzz/fuzz_targets/connect_params.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#![no_main]

use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use stackable_odbc_trino::fuzz_api::trino_connect_params;

// Fuzzes connection-string handling: core splits the string into keys, then
// this driver reads the values it recognises.
//
// core already proptests `ConnectParams::parse` for panic-freedom, so the half
// under test here is the second one: the per-key value parsers, which are this
// crate's. They parse durations, booleans, proxy URLs, time zones, selected
// roles and four separate `key:value`-inside-a-value sublanguages.
//
// The input is user-supplied through a DSN rather than remote, so a panic here
// is less severe than one on the read path. It is also the cheapest of the four
// targets to run, because the surface is a pure string-to-Result function.
//
// The error is rendered to a String on the way out, so this covers the
// `Display` formatting too: several error arms interpolate the offending value.

/// One `key=value` pair.
#[derive(Arbitrary, Debug)]
enum Pair {
/// A key this driver knows, with a fuzzed value. This is the arm that
/// reaches the value parsers; a random key would only ever be ignored.
Known { key: u8, value: String },
/// A key the driver does not know, to cover the unrecognised-key handling.
Unknown { key: String, value: String },
/// A value wrapped in braces, which is how the connection-string grammar
/// carries a value containing `;` or `=`.
Braced { key: u8, value: String },
}

/// The driver's connection-string keys, from `backend::types::connect_params`.
/// Host and Port are omitted here and always prepended, so an input does not
/// have to rediscover them to get past the required-parameter checks.
const KEYS: &[&str] = &[
"protocol",
"tlsverify",
"sslverification",
"certificate",
"clientcertificate",
"querytimeout",
"logintimeout",
"catalog",
"schema",
"source",
"clienttags",
"accesstoken",
"token",
"sessionproperties",
"extracredentials",
"resourceestimates",
"path",
"clientinfo",
"tracetoken",
"proxy",
"proxyuser",
"proxypassword",
"extraheaders",
"clientcapabilities",
"timezone",
"roles",
"sessionuser",
"locale",
"disablecompression",
"maxattempts",
"encoding",
"externalauthentication",
"externalauthenticationtimeout",
];

#[derive(Arbitrary, Debug)]
struct Input {
/// Prepended so most inputs get past `MissingParam` and reach the value
/// parsing. Fuzzed rather than fixed, because the port parse is itself one
/// of the parsers under test.
host: String,
port: String,
user: String,
pairs: Vec<Pair>,
/// Appended verbatim, so the fuzzer can still explore the raw grammar:
/// stray semicolons, unbalanced braces, embedded nulls.
tail: String,
}

fn render(input: &Input) -> String {
let Input {
host,
port,
user,
pairs,
tail,
} = input;
let mut s = format!("Host={host};Port={port};UID={user};");
for pair in pairs {
match pair {
Pair::Known { key, value } => {
let key = KEYS[*key as usize % KEYS.len()];
s.push_str(&format!("{key}={value};"));
}
Pair::Unknown { key, value } => s.push_str(&format!("{key}={value};")),
Pair::Braced { key, value } => {
let key = KEYS[*key as usize % KEYS.len()];
s.push_str(&format!("{key}={{{value}}};"));
}
}
}
s.push_str(tail);
s
}

fuzz_target!(|input: Input| {
let _ = trino_connect_params(&render(&input));
});
Loading
Loading