Skip to content
Draft
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 eng/dict/crates.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ json_canon
json-canon
json_patch
json-patch
libfuzzer_sys
libfuzzer-sys
litemap
openssl
opentelemetry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,13 @@ fn binary_encoding_options() -> TestOptions {
}

/// A document covering every JSON value shape the binary encoder emits: literal
/// and wide integers, an unsigned value beyond `i64::MAX`, a double, booleans,
/// `null`, unicode/empty strings, nested arrays and objects, and a vector of
/// objects.
/// and wide integers, a large unsigned value, a double, booleans, `null`,
/// unicode/empty strings, nested arrays and objects, and a vector of objects.
///
/// Note: `huge` stays at or below `2^53` because the live Cosmos service
/// normalizes JSON numbers to IEEE-754 doubles. A value beyond `2^53` (e.g.
/// `u64::MAX`) is echoed back as a `Double` and can no longer be deserialized
/// into a `u64` field, so it does not round-trip against the real service.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
struct BinaryItem {
id: String,
Expand Down Expand Up @@ -95,11 +99,11 @@ fn sample_item(id: &str, partition_key: &str) -> BinaryItem {
text: "hello binary".to_owned(),
unicode: "café ☃ 𝄞 quotes:\" backslash:\\".to_owned(),
empty: String::new(),
small_int: 7, // literal-int form (0..32)
big_int: 9_000_000_000, // Int64 form
negative: -1_234_567, // Int64 form
huge: u64::MAX, // UInt64 form (beyond i64::MAX)
ratio: 123.456_789, // Double form
small_int: 7, // literal-int form (0..32)
big_int: 9_000_000_000, // Int64 form
negative: -1_234_567, // Int64 form
huge: 9_007_199_254_740_992, // UInt64 form, exactly f64-representable (2^53)
ratio: 123.456_789, // Double form
active: true,
inactive: false,
maybe: None, // null
Expand Down
601 changes: 601 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/docs/BINARY_ENCODING_RFC.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
59 changes: 59 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# cargo-fuzz crate for the Cosmos binary JSON codec.
#
# A SEPARATE crate with its own `[workspace]` (empty table at the bottom) so it
# is not pulled into the parent stable workspace: cargo-fuzz builds these
# targets on nightly with libFuzzer.
#
# Usage:
# rustup toolchain install nightly
# cargo install cargo-fuzz
# cargo +nightly fuzz run decode
# See README.md for target descriptions and corpus seeding.

[package]
name = "azure_data_cosmos_driver-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
license = "MIT"

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"
serde_json = "1"

[dependencies.azure_data_cosmos_driver]
path = ".."

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

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

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

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

# Isolate this crate from the parent workspace (nightly + libFuzzer only).
[workspace]
174 changes: 174 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Binary JSON codec fuzzing (`cargo-fuzz`)

Coverage-guided, **byte-level** fuzzing for the Cosmos binary JSON codec
(`azure_data_cosmos_driver::binary_json`). Where the live
round-trip fuzzer (`azure_data_cosmos/tests/binary_roundtrip_fuzzer.rs`)
generates random JSON *values* and only ever feeds the decoder **encoder-produced**
(well-formed) bytes, these targets feed **arbitrary and mutated bytes** straight
into the decoder — so they exercise the *format*/protocol itself: truncated
buffers, bad length prefixes, unknown or misused markers, reference/depth bombs,
non-UTF-8 string payloads, and trailing bytes.

This is a **separate crate** with its own empty `[workspace]` in `Cargo.toml`, so
it stays isolated from the stable repo workspace: cargo-fuzz builds it on nightly
with libFuzzer.

## Prerequisites

```bash
rustup toolchain install nightly
cargo install cargo-fuzz
```

## Targets

| Target | Entry point | What it checks |
| --- | --- | --- |
| `decode` | `binary_json::decode` | `Value` decode never panics/hangs/over-allocates on any bytes. |
| `from_slice` | `binary_json::from_slice::<Value>` | Native serde streaming decode honors the same no-crash contract. |
| `transcode_to_text` | `binary_json::transcode_to_text` | Driver-side binary→text response transcode never panics on a malformed body. |
| `decode_reencode_roundtrip` | `decode` + `encode` | **Differential**: any buffer the decoder accepts must satisfy `decode(encode(decode(x))) == decode(x)` — catches reader/writer disagreements. |

All four assert the **robustness oracle**: for *any* input the codec terminates
and returns `Ok`/`Err` — never panics, hangs, or allocates beyond the buffer.
The last one adds a **semantic** oracle on decoder-accepted inputs.

## Running

From this `fuzz/` directory (or the driver crate root):

```bash
# Explore one target (Ctrl-C to stop):
cargo +nightly fuzz run decode

# Time-boxed CI-style smoke run (60s), 4 workers:
cargo +nightly fuzz run decode -- -max_total_time=60 -workers=4

# Reproduce a crash from a saved artifact:
cargo +nightly fuzz run decode fuzz/artifacts/decode/crash-<hash>

# Minimize a crashing input:
cargo +nightly fuzz tmin decode fuzz/artifacts/decode/crash-<hash>
```

## Thorough manual run on a Linux VM

Weekly CI only replays the committed corpus once (`-runs=0`, no mutation). To
perform coverage-guided mutation and deeper fuzzing, run it by hand on any Linux
box (or WSL2), without a wall-clock cap:

```bash
# 1. Toolchain (one-time)
rustup toolchain install nightly --component rust-src
cargo install cargo-fuzz --locked

# 2. Get the code and seed the corpus from the golden vectors (recommended —
# lets libFuzzer mutate outward from real wire frames).
cd sdk/cosmos/azure_data_cosmos_driver
mkdir -p fuzz/corpus/decode
jq -r '.[] | "\(.name) \(.binary)"' testdata/binary_json_vectors.json |
while read -r name hex; do
echo "$hex" | tr -d ' ' | xxd -r -p > "fuzz/corpus/decode/$name"
done

# 3a. Run one target for a fixed budget (e.g. 1 hour), 8 parallel workers:
cargo +nightly fuzz run decode -- -max_total_time=3600 -workers=8 -jobs=8 -print_final_stats=1

# 3b. Or run it open-ended until you Ctrl-C (a true soak):
cargo +nightly fuzz run decode -- -workers=8 -jobs=8

# 4. Repeat for the other targets (they share the same corpus format):
cargo +nightly fuzz run from_slice -- -max_total_time=3600 -workers=8
cargo +nightly fuzz run transcode_to_text -- -max_total_time=3600 -workers=8
cargo +nightly fuzz run decode_reencode_roundtrip -- -max_total_time=3600 -workers=8

# 5. Or drive all four with the CI helper (installs deps, seeds corpus, runs each):
pwsh ../eng/scripts/Run-BinaryJsonFuzz.ps1 -MaxTotalTimeSeconds 3600 -Workers 8
```

**If a crash is found**, libFuzzer writes the triggering input to
`fuzz/artifacts/<target>/crash-<hash>`. Reproduce and minimize it:

```bash
cargo +nightly fuzz run decode fuzz/artifacts/decode/crash-<hash> # reproduce
cargo +nightly fuzz tmin decode fuzz/artifacts/decode/crash-<hash> # minimize
```

Then add the minimized input as a golden vector / unit test in
`src/binary_json/` and fix the codec. The **corpus in `fuzz/corpus/<target>/`
persists across runs** — keep it (or copy it between machines) to accelerate
subsequent sessions.

Sizing guidance: `job time ≈ 1 min (compile) + N_targets × per-target budget`.
On an 8-vCPU VM, `-workers=8` roughly 2× the throughput seen in CI (~3.4K
exec/s/worker in the first run), so a 1-hour/target soak explores tens of
millions of inputs per target.

## Seeding the corpus from the golden vectors

Seeding libFuzzer with **valid** frames lets it mutate outward from real wire
shapes and reach the interesting error paths far faster than blind byte flips.
The [golden vectors](https://github.com/Azure/azure-sdk-for-rust/blob/main/sdk/cosmos/azure_data_cosmos_driver/testdata/binary_json_vectors.json) already contain every
marker family as space-separated hex. Materialize them into the `decode` corpus:

PowerShell:

```powershell
$dir = "fuzz/corpus/decode"; New-Item -ItemType Directory -Force $dir | Out-Null
(Get-Content ../testdata/binary_json_vectors.json | ConvertFrom-Json) | ForEach-Object {
$bytes = $_.binary -split '\s+' | ForEach-Object { [Convert]::ToByte($_, 16) }
[IO.File]::WriteAllBytes("$dir/$($_.name)", [byte[]]$bytes)
}
```

bash + jq + xxd:

```bash
mkdir -p fuzz/corpus/decode
jq -r '.[] | "\(.name) \(.binary)"' ../testdata/binary_json_vectors.json |
while read -r name hex; do
echo "$hex" | tr -d ' ' | xxd -r -p > "fuzz/corpus/decode/$name"
done
```

The same corpus works for `from_slice`, `transcode_to_text`, and
`decode_reencode_roundtrip` (all consume raw binary buffers); copy or point
`--corpus` at `fuzz/corpus/decode`.

## Notes

- `corpus/`, `artifacts/`, and `target/` are git-ignored (regenerated locally / in CI).
- These targets are **offline** (no live account), so they are cheap enough to
run in CI as a nightly job or a time-boxed smoke check on PRs touching
`binary_json`.
- A reproducible crash should be reduced with `cargo fuzz tmin`, added as a
golden vector / unit test in `src/binary_json/`, and fixed there.

## Windows

`cargo-fuzz` builds on **libFuzzer** (`-fsanitize=fuzzer`), which the Windows
MSVC target does not support — `cargo fuzz run` fails to link on Windows. Use
**WSL2** or a **Linux** box. On Windows, the always-on decoder robustness
coverage lives in `src/binary_json/fuzz_tests.rs` (random / truncated / corrupted
buffers into `decode`) and runs on stable via `cargo test -p
azure_data_cosmos_driver --lib fuzz`.

## CI

Fuzzing runs as a **non-blocking leg of the existing `sdk/cosmos/ci.yml`** — a
Build-stage `MatrixConfigs` entry ([`sdk/cosmos/fuzz-matrix.json`](https://github.com/Azure/azure-sdk-for-rust/blob/main/sdk/cosmos/fuzz-matrix.json))
that adds one **Linux + nightly** job (cargo-fuzz/libFuzzer is Linux-only), gated
to the **weekly / scheduled** build only (not per-PR). It carries
`ContinueOnError: "true"`, so a discovered crash reports "succeeded with issues"
instead of blocking merge. The job's test-setup hook
([`Invoke-CosmosTestSetup.ps1`](https://github.com/Azure/azure-sdk-for-rust/blob/main/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1),
gated on `AZURE_COSMOS_FUZZ=1`) calls
[`Run-BinaryJsonFuzz.ps1`](https://github.com/Azure/azure-sdk-for-rust/blob/main/sdk/cosmos/eng/scripts/Run-BinaryJsonFuzz.ps1)
**with `-ValidateOnly`**, which installs cargo-fuzz, seeds each corpus from the
golden vectors, and **replays the committed vectors once** (libFuzzer `-runs=0`,
no mutation, no time budget) to prove they still decode without panicking.

Coverage-guided mutation soaks (`-max_total_time`) are **manual / local only** —
CI never runs an unattended time-boxed soak (see the manual-run section above).
Crash inputs are published as the `fuzz-crashes` build artifact so a failure can
be reproduced and minimized (`cargo fuzz tmin`).
24 changes: 24 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/fuzz/fuzz_targets/decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

//! Byte-level protocol fuzz target for the binary-JSON **decoder**.
//!
//! libFuzzer feeds arbitrary (and, once seeded, mutated-from-valid) byte
//! buffers straight into [`decode`]. This is the format fuzzer the live
//! round-trip test can't be: it explores mis-encoded frames — truncated
//! buffers, bad length prefixes, unknown/misused markers, reference and
//! depth bombs, non-UTF-8 string payloads, trailing bytes — that the encoder
//! never produces.
//!
//! Oracle: for **any** input the decoder must terminate and return either
//! `Ok(Value)` or `Err(BinaryError)` — never panic, hang, or allocate beyond
//! what the buffer can back. A crash or hang here is a decoder-hardening bug.

#![no_main]

use azure_data_cosmos_driver::binary_json::decode;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let _ = decode(data);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

//! Differential fuzz target: decode → encode → decode idempotence.
//!
//! Unlike the plain `decode` no-crash target, this asserts a **semantic**
//! invariant on every buffer the decoder *accepts*: re-encoding the decoded
//! value and decoding it again must reproduce the exact same value. It catches
//! the class of bug the live round-trip fuzzer cannot — a buffer the decoder
//! accepts but the encoder would round-trip to a *different* value (marker or
//! number-form disagreements between the reader and writer). libFuzzer's
//! mutation reaches decoder-accepted-but-unusual frames that hand-written
//! golden vectors don't enumerate.
//!
//! Oracle: `decode(data) = Ok(v)` ⇒ `decode(encode(v)) = Ok(v)`.

#![no_main]

use azure_data_cosmos_driver::binary_json::{decode, encode};
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(value) = decode(data) {
let reencoded = encode(&value);
let redecoded =
decode(&reencoded).expect("re-encoding a decoded value must itself decode");
assert_eq!(
value, redecoded,
"decode∘encode∘decode is not idempotent for a decoder-accepted buffer"
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

//! Byte-level protocol fuzz target for the native serde **deserializer**.
//!
//! [`from_slice`] is the zero-`Value` streaming decode path used by the SDK's
//! typed reads; it drives a different code path from [`decode`] (it streams
//! tokens into a serde visitor instead of materializing a
//! [`serde_json::Value`]). Fuzzing it independently ensures the streaming
//! deserializer honors the same no-crash contract on malformed input.
//!
//! Oracle: for any input, deserialization must terminate with `Ok`/`Err` —
//! never panic, hang, or over-allocate.

#![no_main]

use azure_data_cosmos_driver::binary_json::from_slice;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let _ = from_slice::<serde_json::Value>(data);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

//! Byte-level protocol fuzz target for the driver-side response transcode.
//!
//! [`transcode_to_text`] is what the driver runs on a binary response body when
//! a text-only host asked for text back: it decodes the binary buffer and
//! re-serializes it as UTF-8 text JSON (or passes text/empty input through
//! unchanged). It sits on the FFI/text-host response path, so a panic here on a
//! malformed service body would take down the host.
//!
//! Oracle: for any input, transcoding must terminate with `Ok`/`Err` — never
//! panic, hang, or over-allocate.

#![no_main]

use azure_data_cosmos_driver::binary_json::transcode_to_text;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let _ = transcode_to_text(data);
});
Loading
Loading