Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ jobs:
- uses: actions/checkout@v7
- name: Check copyright headers
run: bash scripts/check_copyright_headers.sh
# Hard gate: every public fn taking untrusted input (&str/&[u8]/Value/&Path)
# must be classified in fuzz/fuzz_coverage.toml as fuzzed or not-untrusted.
# Pure source+manifest analysis on stable Python — no Rust build, no nightly.
- name: Check fuzz coverage registry
run: python3 scripts/check_fuzz_coverage.py

# Build, test, and conformance share a single release-profile target/ cache.
# This avoids maintaining separate debug and release caches for the same
Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Fuzz
Comment thread
crowecawcaw marked this conversation as resolved.

# Smoke-fuzz every fuzz target on pull requests and on merges to main. The
# run is short and time-boxed per target so it gates without slowing the merge
# queue. Any panic, abort, overflow, or char-boundary slice in a fuzzed entry
# point fails the job. Coverage plateaus within about a minute per target once
# seeded, so a longer run buys little here; deeper campaigns are left to manual
# local runs (see fuzz/README.md).
#
# The fuzz crate (fuzz/) is deliberately outside the root workspace and builds
# only with a nightly toolchain under AddressSanitizer, so it lives in its own
# workflow rather than the stable CI matrix.

on:
push:
branches: [main]
pull_request:
branches: [main, release, "patch_*"]
workflow_dispatch:

concurrency:
group: fuzz-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUST_BACKTRACE: 1
# Per-target smoke budget, in seconds. Coverage plateaus within a few seconds
# once seeded, so a short run catches regressions reachable from the corpus;
# deeper campaigns are run manually (see fuzz/README.md).
FUZZ_SECONDS: 10

jobs:
fuzz:
name: Fuzz
# libFuzzer + cargo-fuzz's sanitizer build is best supported on Linux.
runs-on: ubuntu-latest
steps:
Comment thread
crowecawcaw marked this conversation as resolved.
- uses: actions/checkout@v6

# cargo-fuzz requires a nightly toolchain (sanitizer -Z flags). Pin the
# nightly so a bad nightly (e.g. an ASan codegen ICE) can't randomly break
# the fuzz job; bump this date deliberately.
- name: Install Rust nightly
run: |
rustup toolchain install nightly-2026-05-15 --profile minimal --component rust-src
rustup override set nightly-2026-05-15

- name: Compute rustc hash
id: rustc
run: echo "hash=$(rustc +nightly-2026-05-15 --version --verbose | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"

- name: Restore cargo + fuzz cache
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-fuzz
fuzz/target
key: fuzz-${{ steps.rustc.outputs.hash }}-${{ hashFiles('**/Cargo.lock', 'fuzz/Cargo.toml') }}
restore-keys: |
fuzz-${{ steps.rustc.outputs.hash }}-
fuzz-

- name: Install cargo-fuzz
run: which cargo-fuzz || cargo install cargo-fuzz --locked --version ^0.13

# Build + run every target. The target list is derived from
# `cargo fuzz list` inside the script, so a newly-added target is picked
# up automatically — nothing to update here. A crash in any target fails
# the job.
- name: Run fuzz targets
run: scripts/run_fuzz.sh

# If any target produced a crash artifact, surface it so the failure is
# actionable from the run page instead of just a red X.
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: fuzz-artifacts
path: fuzz/artifacts
if-no-files-found: ignore
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ cargo clippy --all-features --all-targets --workspace -- -D warnings # Lint
cargo fmt --all # Apply formatting
cargo doc --no-deps --workspace # Build docs
scripts/coverage.sh # Code coverage (see COVERAGE_REPORT.md)
scripts/run_fuzz.sh # Smoke-fuzz all cargo-fuzz targets (see fuzz/README.md)
python3 scripts/check_fuzz_coverage.py # Untrusted-input fuzz coverage gate (runs in CI)
```

MSRV: **1.94.1** (enforced in CI).
Expand Down Expand Up @@ -124,6 +126,8 @@ The `specs/` directory is the primary resource for understanding each crate's de

Every crate's spec directory must include a `public-api.md` that fully describes the crate's public API — all public types, functions, traits, and constants with their signatures. When adding or changing public API surface, update `public-api.md` in the same commit.

When adding a public function that takes untrusted input (`&str`, `&[u8]`, `serde_json::Value`, `&Path`) to `openjd-expr`, `openjd-model`, or `openjd-snapshots`, CI's fuzz-coverage gate (`scripts/check_fuzz_coverage.py`) will fail until the function is classified in `fuzz/fuzz_coverage.toml` — either mapped to a fuzz target that exercises it or marked not-untrusted with a reason. See `fuzz/README.md`.

The structure:

```
Expand Down Expand Up @@ -239,10 +243,11 @@ PRs run these checks (all must pass):
| **Conformance** | Full OpenJD conformance suite (1,038 tests) on all three platforms |
| **MSRV** | `cargo check --workspace` with Rust 1.94.1 |
| **Documentation** | `cargo doc --no-deps --workspace` with `-D warnings` |
| **Compliance** | Copyright header check |
| **Compliance** | Copyright header check + fuzz-coverage gate (`scripts/check_fuzz_coverage.py`: every public fn taking untrusted input must be classified in `fuzz/fuzz_coverage.toml`) |
| **Cross-User (Linux)** | Docker-based cross-user tests: localuser and LDAP variants |
| **Cross-User (Windows)** | Windows cross-user and permissions tests with a temporary test user |
| **openjd-for-js** | Builds the wasm32 crate with `wasm-bindgen` and runs the vitest suite |
| **Fuzz** | Separate workflow (`.github/workflows/fuzz.yml`): smoke-fuzzes every cargo-fuzz target (nightly + ASan, overflow-checks on) — see `fuzz/README.md` |

If a dependency update bumps `wasm-bindgen`, also bump the matching
`wasm-bindgen-cli` version pinned in the `openjd-for-js` job in
Expand Down
9 changes: 8 additions & 1 deletion crates/openjd-expr/src/functions/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,14 @@ fn extract_unc_root(path: &str) -> Option<&str> {

fn path_starts_with(path: &str, base: &str, fmt: PathFormat) -> bool {
if fmt == PathFormat::Windows {
path.len() >= base.len() && path[..base.len()].eq_ignore_ascii_case(base)
// Case-insensitive prefix compare. Slicing `path[..base.len()]` would
// panic when `base.len()` lands inside a multibyte char in `path`
// (e.g. path "日" is 3 bytes, base "ab" is 2). Compare the raw byte
// prefix instead — `eq_ignore_ascii_case` on bytes is equivalent for
// the ASCII-only case-folding Windows path comparison uses, and byte
// slicing at `base.len()` is always valid.
let (pb, bb) = (path.as_bytes(), base.as_bytes());
pb.len() >= bb.len() && pb[..bb.len()].eq_ignore_ascii_case(bb)
} else {
path.starts_with(base)
}
Expand Down
19 changes: 19 additions & 0 deletions crates/openjd-expr/tests/integration/test_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ fn is_relative_to_false() {
);
}

// Regression: the Windows-format prefix compare in `path_starts_with` sliced
// the path by the base's byte length, which panicked when that offset landed
// inside a multibyte char (e.g. path "日" is 3 bytes, base "ab" is 2). See the
// expr quality report's exploratory finding X8; found by the expr_evaluate
// fuzz target once it exercised the Windows path format. Must evaluate to a
// bool, not panic.
#[test]
fn is_relative_to_multibyte_no_char_boundary_panic() {
assert_eq!(
eval_windows("P.is_relative_to('ab')", &windows_st("P", "日")).to_display_string(),
"false"
);
// A multibyte base against a shorter multibyte path exercises the same slice.
assert_eq!(
eval_windows("P.is_relative_to('日本')", &windows_st("P", "日")).to_display_string(),
"false"
);
}

// === TestRelativeTo ===
#[test]
fn relative_to() {
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

# libFuzzer writes discovered inputs as 40-hex-char SHA1-named files. When you
# run a target with `fuzz/seeds/<t>` as the corpus dir, it drops those into the
# seed dir. They are not curated seeds and must not be committed — ignore them
# so an accidental `git add` can't sweep them in. Curated seeds use readable
# names (expr_0, mb_1, tpl_2, …), which this pattern never matches.
seeds/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]
Loading
Loading