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
74 changes: 74 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Fuzz

# Coverage-guided fuzzing of the two parsers of zone bytes (docs/design/broker.md), weekly on a pinned nightly.

on:
schedule:
- cron: '0 4 * * 0'
workflow_dispatch:

permissions:
contents: read

jobs:
fuzz:
name: Fuzz ${{ matrix.target }}
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- target: broker_request
dir: compartments/kryptikd
seeds: compartments/kryptikd/fuzz-corpus/broker-requests
max_len: 4096
- target: wire
dir: compositor/wlproxy
seeds: ''
max_len: 8192
env:
# A dated nightly, so a new finding is never a new compiler's.
RUSTUP_TOOLCHAIN: nightly-2026-09-28
FUZZ_TARGET: ${{ matrix.target }}
CRATE_DIR: ${{ matrix.dir }}
SEEDS: ${{ matrix.seeds }}
MAX_LEN: ${{ matrix.max_len }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0

- name: Nightly Rust and cargo-fuzz
run: |
rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal
cargo +stable install cargo-fuzz --version 0.13.2 --locked

# libfuzzer-sys comes from the fuzz crate's own lockfile, never from what ships.
- name: The fuzz crate's lockfile holds
run: cargo fetch --locked --manifest-path "$CRATE_DIR/fuzz/Cargo.toml"

- name: Seeds from the corpus in the tree
run: |
mkdir -p "$RUNNER_TEMP/corpus"
# The tree keeps one request per line; libFuzzer takes one input per file.
if [ -n "$SEEDS" ]; then
n=0
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
printf '%s\n' "$line" > "$RUNNER_TEMP/corpus/seed-$n"
done < "$SEEDS"
fi
echo "$(find "$RUNNER_TEMP/corpus" -type f | wc -l) seeds"

# Safe Rust throughout, so no sanitizer: panics, overflow checks and the harness's asserts are the oracle.
- name: Twenty minutes of fuzzing
working-directory: ${{ matrix.dir }}
run: cargo fuzz run --sanitizer none "$FUZZ_TARGET" "$RUNNER_TEMP/corpus" -- -max_total_time=1200 -max_len="$MAX_LEN" -timeout=10 -print_final_stats=1

- name: Inputs that broke the parser
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: fuzz-${{ matrix.target }}
path: ${{ matrix.dir }}/fuzz/artifacts/
retention-days: 30
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
# Rust and Python
/compartments/kryptikd/target/
compositor/target/
**/fuzz/target/
**/fuzz/corpus/
**/fuzz/artifacts/
**/fuzz/coverage/
__pycache__/

# Editor / OS noise
Expand Down
4 changes: 4 additions & 0 deletions compartments/kryptikd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ license = "GPL-2.0-or-later"
[dependencies]
libc = "0.2"

# Set only by cargo fuzz, which builds main.rs from fuzz/Cargo.toml.
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] }

[profile.release]
opt-level = 2
lto = true
Expand Down
90 changes: 90 additions & 0 deletions compartments/kryptikd/fuzz/Cargo.lock

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

33 changes: 33 additions & 0 deletions compartments/kryptikd/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# The broker's request parser under libFuzzer, run weekly by
# .github/workflows/fuzz.yml. A package and lockfile of its own, so
# libfuzzer-sys never reaches kryptikd's dependencies or Cargo.lock (ADR-010).
[package]
name = "kryptikd-fuzz"
version = "0.0.0"
edition = "2021"
publish = false

[package.metadata]
cargo-fuzz = true

[dependencies]
libc = "0.2"
libfuzzer-sys = "0.4"

# The daemon itself: under cfg(fuzzing) main.rs takes broker_request.rs in place of main.
[[bin]]
name = "broker_request"
path = "../src/main.rs"
test = false
doc = false
bench = false

# The release build's overflow checks, and debug assertions as well.
[profile.release]
debug-assertions = true
overflow-checks = true

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] }

[workspace]
27 changes: 27 additions & 0 deletions compartments/kryptikd/fuzz/broker_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! The broker's request parser under libFuzzer: any header line a zone can
//! send, and whatever it accepts held to the bounds its refusals enforce.

use libfuzzer_sys::fuzz_target;

use crate::broker::{check_transfer_name, parse_request, Request, CLIPBOARD_MAX, MIME_TYPES};
use crate::update::{POINTER_MAX, PUT_MAX};

fuzz_target!(|data: &[u8]| {
// The header as serve_connection takes it: up to the first newline, decoded lossily.
let Some(nl) = data.iter().position(|b| *b == b'\n') else { return };
let header = String::from_utf8_lossy(&data[..nl]);
match parse_request(&header) {
Err(_) | Ok(Request::Version | Request::ClipboardGet | Request::UpdatePoll) => {}
Ok(Request::ClipboardSet { mime, len }) => assert!(MIME_TYPES.contains(&mime.as_str()) && len <= CLIPBOARD_MAX),
Ok(Request::Transfer { dest, name }) => {
assert!((1..=12).contains(&dest.len()));
assert!(dest.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'));
assert!(check_transfer_name(&name).is_ok());
}
Ok(Request::TimeOffset(c)) => assert!(c.offset.is_finite() && c.offset.abs() <= 1e10 && (1..=16).contains(&c.sources)),
Ok(Request::UpdateLatest { plen, slen }) => assert!((1..=POINTER_MAX).contains(&plen) && (1..=POINTER_MAX).contains(&slen)),
Ok(Request::UpdatePut { name, len, .. }) => assert!(check_transfer_name(&name).is_ok() && (1..=PUT_MAX).contains(&len)),
Ok(Request::NotAZoneVerb(v)) => assert_eq!(v, "clipboard-move"),
Ok(Request::Unknown(v)) => assert!(!v.is_empty() && !v.contains(char::is_whitespace)),
}
});
7 changes: 7 additions & 0 deletions compartments/kryptikd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
//! process that creates zones or moves data between them. Anything not built
//! is refused with an error, never a silent no-op.

// cargo fuzz builds this file with cfg(fuzzing), and libFuzzer brings its own main.
#![cfg_attr(fuzzing, no_main, allow(dead_code))]

mod broker;
mod caps;
mod cgroup;
Expand All @@ -24,6 +27,10 @@ mod update;
mod volume;
mod wifi;
mod zone;
// The parser is private to the daemon, so its fuzz target compiles inside it (fuzz/Cargo.toml).
#[cfg(fuzzing)]
#[path = "../fuzz/broker_request.rs"]
mod fuzz;

use std::path::{Path, PathBuf};
use std::process::ExitCode;
Expand Down
89 changes: 89 additions & 0 deletions compositor/wlproxy/fuzz/Cargo.lock

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

29 changes: 29 additions & 0 deletions compositor/wlproxy/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# kryptik-wlproxy's wire parser under libFuzzer, run weekly by
# .github/workflows/fuzz.yml. A package, workspace and lockfile of its own, so
# libfuzzer-sys never reaches the compositor's dependencies or Cargo.lock
# (ADR-010).
[package]
name = "wlproxy-fuzz"
version = "0.0.0"
edition = "2021"
publish = false

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"

[[bin]]
name = "wire"
path = "wire.rs"
test = false
doc = false
bench = false

# The release build's overflow checks, and debug assertions as well.
[profile.release]
debug-assertions = true
overflow-checks = true

[workspace]
Loading
Loading