diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml new file mode 100644 index 0000000..4c0748f --- /dev/null +++ b/.github/workflows/fuzz.yaml @@ -0,0 +1,52 @@ +name: "fuzz" + +# ENG-4691: cargo-fuzz quick-pass. Feeds arbitrary bytes to +# Graph::from_bytes (the load_archive target) for 60s per PR so malformed +# archives can't panic, OOM, or segfault. The deep, continuous variant runs +# on OSS-Fuzz (see oss-fuzz/). Mirrors ci.yaml's conventions; the sole +# deviation is the toolchain: cargo-fuzz needs a nightly sanitizer toolchain, +# so this is the one job in the repo that is not stable-only. + +on: + pull_request: + push: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + workflow_dispatch: + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + load-archive: + name: fuzz load_archive + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + # ASan is incompatible with a statically linked libc, so the fuzz + # build must target the dynamically-linked gnu triple. Pin it + # explicitly (both here and on the cargo-fuzz commands below) — + # on GitHub's runners cargo-fuzz otherwise defaults to the musl + # target, which fails with "sanitizer is incompatible with + # statically linked libc". + targets: x86_64-unknown-linux-gnu + - uses: taiki-e/install-action@v2 + with: + tool: cargo-fuzz + - uses: Swatinem/rust-cache@v2 + with: + workspaces: fuzz + shared-key: fuzz + # Compile BOTH targets so a break in route_inputs is caught even + # though only load_archive is run in the quick-pass. + - run: cargo fuzz build --target x86_64-unknown-linux-gnu + # 60s libFuzzer run seeded from fuzz/corpus/load_archive/. + - run: cargo fuzz run load_archive --target x86_64-unknown-linux-gnu -- -max_total_time=60 diff --git a/CHANGELOG.md b/CHANGELOG.md index 697bae7..b93f505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Continuous fuzzing (ENG-4691): a self-contained `fuzz/` cargo-fuzz + package (its own workspace root, excluded from the root build) with two + libFuzzer targets — `load_archive` (arbitrary bytes → `Graph::from_bytes`) + and `route_inputs` (fuzzed `from`/`to`/blocked composition against a fixed + graph) — plus a committed valid seed. A `fuzz` CI workflow runs a 60s + `load_archive` quick-pass per PR on nightly, and `oss-fuzz/` stages the + Google OSS-Fuzz project files (Dockerfile, build.sh, project.yaml) with a + submission runbook for CNCF-grade continuous fuzzing. - Automated release pipeline (ENG-4692): `release-plz` opens a `chore: release vX.Y.Z` PR from conventional commits and, on merge, publishes to crates.io and creates the GitHub release/tag; diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..43fe27e --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,10 @@ +/target/ +/artifacts/ +/coverage/ +# Fuzz crate lockfile — mirrors the repo's no-lockfile stance for library and +# sub-package crates (see root .gitignore); OSS-Fuzz rebuilds from scratch. +/Cargo.lock +# libFuzzer-discovered corpus entries are regenerable noise; commit only the +# named seed(s) under corpus//seed_*. +/corpus/*/* +!/corpus/*/seed_* diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..a8fa6b4 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "rustyroute-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +# Empty table => fuzz/ is its OWN workspace root, so it is never absorbed +# into a parent [workspace] even if one is later added to the root +# Cargo.toml. The root crate is single-crate today (no [workspace]), so the +# fuzz package is already excluded; this future-proofs that guarantee. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +# No `derive` feature: `RouteInput` implements `Arbitrary` by hand (to bound +# the blocked-set length), so the derive macro would be dead weight in the +# fuzz and OSS-Fuzz builds. +arbitrary = "1" + +[dependencies.rustyroute] +path = ".." +# Default features (=> data-50km) expose rustyroute::data::BYTES_50KM for the +# route_inputs fixed graph. + +[[bin]] +name = "load_archive" +path = "fuzz_targets/load_archive.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "route_inputs" +path = "fuzz_targets/route_inputs.rs" +test = false +doc = false +bench = false diff --git a/fuzz/corpus/load_archive/seed_100km.rkyv b/fuzz/corpus/load_archive/seed_100km.rkyv new file mode 100644 index 0000000..03ebb54 Binary files /dev/null and b/fuzz/corpus/load_archive/seed_100km.rkyv differ diff --git a/fuzz/fuzz_targets/load_archive.rs b/fuzz/fuzz_targets/load_archive.rs new file mode 100644 index 0000000..d3e50ed --- /dev/null +++ b/fuzz/fuzz_targets/load_archive.rs @@ -0,0 +1,36 @@ +#![no_main] +//! ENG-4691: fuzz `rustyroute::Graph::from_bytes` on arbitrary bytes. +//! +//! Contract: feeding any byte slice to `from_bytes` must only ever return a +//! typed `LoadError` (or `Ok`) — never panic, OOM, or segfault. `from_bytes` +//! already guards its header length before slicing (`validate_header`) and +//! runs rkyv's *checked* `access`, so this target proves that contract holds +//! across the whole input space and guards against regressions. + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // `Graph::from_bytes` takes `&'static [u8]`. Promote a copy of the + // transient fuzz buffer to `'static`, then reclaim it after use so RSS + // stays flat across libFuzzer's many in-process iterations. A plain + // `Box::leak` would accumulate one copy per iteration and — with the + // large committed seed driving `-max_len` up — climb toward + // `-rss_limit_mb` and trip a false-positive OOM crash. + let ptr = Box::into_raw(data.to_vec().into_boxed_slice()); + + // Inner scope confines the `&'static [u8]` borrow (and the `Graph` that + // holds it) so both are definitely dead before the free below — no shared + // reference derived from `ptr` is live across `Box::from_raw`. + { + // SAFETY: `ptr` is a freshly created boxed slice we have not freed, so + // dereferencing it to a shared slice is valid. + let leaked: &'static [u8] = unsafe { &*ptr }; + let _ = rustyroute::Graph::from_bytes(leaked); + } + + // SAFETY: a `Graph` holds only `GraphBacking::Static(&'static [u8])` — a + // borrow, not an owner — and both it and `leaked` went out of scope above, + // so no reference into `ptr` survives. Reconstructing the `Box` to free it + // is sound. + drop(unsafe { Box::from_raw(ptr) }); +}); diff --git a/fuzz/fuzz_targets/route_inputs.rs b/fuzz/fuzz_targets/route_inputs.rs new file mode 100644 index 0000000..f292f6f --- /dev/null +++ b/fuzz/fuzz_targets/route_inputs.rs @@ -0,0 +1,60 @@ +#![no_main] +//! ENG-4691: fuzz `rustyroute::Graph::route` argument composition against a +//! fixed graph. +//! +//! Contract: for a valid graph, any `(from, to, blocked)` composition must +//! only ever return `Ok(Route)` or a typed `RouteError` — never panic. Coords +//! are validated inside `Graph::route`, and blocked ids that do +//! not exist simply never filter anything, so arbitrary inputs are safe by +//! construction; this target proves it empirically. + +use arbitrary::{Arbitrary, Unstructured}; +use libfuzzer_sys::fuzz_target; +use std::collections::HashSet; +use std::sync::OnceLock; + +/// Structured fuzz input. The f64 endpoints span NaN/±inf and the full range, +/// exercising coord validation; libFuzzer's coverage feedback learns in-range +/// coordinates over time to reach the Dijkstra path. Both endpoints are fuzzed +/// independently (self-route and cross-node paths). +#[derive(Debug)] +struct RouteInput { + from: (f64, f64), + to: (f64, f64), + blocked: Vec, +} + +/// Hand-written `Arbitrary` (rather than derive) so the `blocked` set length is +/// bounded. `route` treats unknown edge ids as no-op filters, so blocked-set +/// *size* has no bearing on routing correctness — but a derived unbounded +/// `Vec` would grow with the fuzzer's input size (especially under +/// OSS-Fuzz), producing large `Vec`/`HashSet` allocations that manifest as +/// slow units or OOMs unrelated to `Graph::route`. The count is read from a +/// single `u8`, capping the set at 255 elements. +impl<'a> Arbitrary<'a> for RouteInput { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let from = <(f64, f64)>::arbitrary(u)?; + let to = <(f64, f64)>::arbitrary(u)?; + let count = u8::arbitrary(u)? as usize; // ≤ 255 — bounds memory use + let mut blocked = Vec::with_capacity(count); + for _ in 0..count { + blocked.push(u32::arbitrary(u)?); + } + Ok(RouteInput { from, to, blocked }) + } +} + +/// The fixed graph, built once from the baked 50 km archive (available via the +/// path dep's default `data-50km` feature). +fn graph() -> &'static rustyroute::Graph { + static G: OnceLock = OnceLock::new(); + G.get_or_init(|| { + rustyroute::Graph::from_bytes(rustyroute::data::BYTES_50KM) + .expect("baked 50km archive is valid") + }) +} + +fuzz_target!(|input: RouteInput| { + let blocked: HashSet = input.blocked.into_iter().collect(); + let _ = graph().route(input.from, input.to, &blocked); +}); diff --git a/oss-fuzz/README.md b/oss-fuzz/README.md new file mode 100644 index 0000000..4a2be48 --- /dev/null +++ b/oss-fuzz/README.md @@ -0,0 +1,55 @@ +# OSS-Fuzz submission for rustyroute (ENG-4691) + +This directory stages the files that OSS-Fuzz needs for continuous fuzzing of +`rustyroute`. They live here so they are versioned alongside the fuzz targets +they build, but the **actual submission is a pull request to the external +[`google/oss-fuzz`](https://github.com/google/oss-fuzz) repository** — it +cannot be merged from this repo. + +``` +oss-fuzz/projects/rustyroute/ + project.yaml # engine/sanitizer config + maintainer contacts + Dockerfile # clones this repo into the OSS-Fuzz base-builder-rust image + build.sh # cargo fuzz build -O; copies target binaries + seed to $OUT +``` + +## Prerequisites + +- `github.com/spotship/rustyroute` must be **public** (OSS-Fuzz only fuzzes + public projects). The Dockerfile clones over HTTPS. +- OSS-Fuzz requires **two maintainer email addresses** associated with the + project. Satisfied: `project.yaml` `auto_ccs` lists `jimbo@spot-ship.com` + and `jimbo@freedman.io`. + +## Submission steps + +Steps 1–2 are already done; the submission itself (steps 3–6) is a deliberate +human follow-up and has **not** been started. + +1. ~~Confirm the co-maintainer email and add it to `auto_ccs`.~~ Done — both + maintainer addresses are in `project.yaml`. +2. ~~Verify the repo is public.~~ Done — `spotship/rustyroute` is public. +3. Fork `google/oss-fuzz`. Copy this `projects/rustyroute/` directory to + `projects/rustyroute/` in the fork (drop the `oss-fuzz/` prefix — in + google/oss-fuzz the path is `projects/rustyroute/`). +4. Validate locally against the OSS-Fuzz tooling: + ```sh + python infra/helper.py build_image rustyroute + python infra/helper.py build_fuzzers rustyroute + python infra/helper.py check_build rustyroute + ``` +5. Open the pull request to `google/oss-fuzz`. Approval typically takes + **1–3 weeks**; an OSS-Fuzz maintainer must merge it. +6. Once the PR is open, paste its link into ClickUp ticket **ENG-4691** as the + tracking link. + +## Notes + +- The CI quick-pass (`.github/workflows/fuzz.yaml`) runs `load_archive` for 60s + per PR. OSS-Fuzz runs the deep, continuous variant of both targets. +- `build.sh` builds both `load_archive` and `route_inputs`; keep the target + list in sync with `fuzz/Cargo.toml`. +- The committed seed is delivered to OSS-Fuzz as + `$OUT/load_archive_seed_corpus.zip` — OSS-Fuzz only ingests seed corpora + from `_seed_corpus.zip`, not from loose files copied into `$OUT`. + `base-builder-rust` provides `zip`. diff --git a/oss-fuzz/projects/rustyroute/Dockerfile b/oss-fuzz/projects/rustyroute/Dockerfile new file mode 100644 index 0000000..c21ca72 --- /dev/null +++ b/oss-fuzz/projects/rustyroute/Dockerfile @@ -0,0 +1,7 @@ +FROM gcr.io/oss-fuzz-base/base-builder-rust +# Absolute $SRC paths: build.sh does `cd "$SRC/rustyroute"`, so spell the same +# location here instead of leaning on the base image's default working dir +# being $SRC. +RUN git clone --depth 1 https://github.com/spotship/rustyroute $SRC/rustyroute +WORKDIR $SRC/rustyroute +COPY build.sh $SRC/ diff --git a/oss-fuzz/projects/rustyroute/build.sh b/oss-fuzz/projects/rustyroute/build.sh new file mode 100755 index 0000000..c10b4e7 --- /dev/null +++ b/oss-fuzz/projects/rustyroute/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash -eu +# ENG-4691: OSS-Fuzz build script for rustyroute. Runs inside the +# gcr.io/oss-fuzz-base/base-builder-rust image (nightly + cargo-fuzz + clang +# preinstalled). Builds every fuzz target and copies the binaries — plus the +# committed seed corpus — into $OUT for the OSS-Fuzz runners. + +cd "$SRC/rustyroute" + +# Pin the target triple rather than relying on cargo-fuzz's default. That +# default is the *host* triple, which is not reliably gnu — it resolved to musl +# on GitHub's runners, and ASan (project.yaml `sanitizers: address`) is +# incompatible with a statically linked libc. See the same pin in +# .github/workflows/fuzz.yaml. Deriving the output dir from the same variable +# keeps the build and the copy below from ever disagreeing. +FUZZ_TARGET_TRIPLE="x86_64-unknown-linux-gnu" + +# cargo-fuzz auto-locates the fuzz/ package from the crate root. +cargo fuzz build -O --target "$FUZZ_TARGET_TRIPLE" + +FUZZ_TARGET_OUTPUT_DIR="fuzz/target/$FUZZ_TARGET_TRIPLE/release" +for target in load_archive route_inputs; do + cp "$FUZZ_TARGET_OUTPUT_DIR/$target" "$OUT/" +done + +# Ship the committed seed corpus so OSS-Fuzz starts with coverage. OSS-Fuzz +# only ingests seeds from $OUT/_seed_corpus.zip (loose files in $OUT +# are ignored), so package the load_archive seed(s) into that zip. Only +# load_archive has a committed seed; route_inputs relies on coverage-guided +# discovery. +if compgen -G "fuzz/corpus/load_archive/*" > /dev/null; then + zip -j "$OUT/load_archive_seed_corpus.zip" fuzz/corpus/load_archive/* +fi diff --git a/oss-fuzz/projects/rustyroute/project.yaml b/oss-fuzz/projects/rustyroute/project.yaml new file mode 100644 index 0000000..634e57f --- /dev/null +++ b/oss-fuzz/projects/rustyroute/project.yaml @@ -0,0 +1,11 @@ +homepage: "https://github.com/spotship/rustyroute" +main_repo: "https://github.com/spotship/rustyroute" +language: rust +primary_contact: "jimbo@spot-ship.com" +auto_ccs: + - "jimbo@spot-ship.com" + - "jimbo@freedman.io" +fuzzing_engines: + - libfuzzer +sanitizers: + - address diff --git a/tests/fuzz_workflow.rs b/tests/fuzz_workflow.rs new file mode 100644 index 0000000..24bcc6f --- /dev/null +++ b/tests/fuzz_workflow.rs @@ -0,0 +1,232 @@ +//! ENG-4691: lock in the structural invariants of the fuzz setup. +//! +//! Like `tests/ci_workflow.rs`, these are string-level assertions (no YAML +//! parser) plus one spawned `cargo metadata` check. What can only be verified +//! on GitHub Actions itself (the actual libFuzzer run finding no crash) is out +//! of scope here — these guard against silent local drift: +//! +//! - the workflow exists with the nightly + cargo-fuzz shape it needs, +//! - the fuzz crate is its own workspace root with both targets declared, +//! - the committed seed is a real, valid archive, +//! - and — the load-bearing acceptance invariant — the root build never +//! picks up the fuzz package as a member. +//! +//! Convention notes: string assertions mirror `tests/ci_workflow.rs`; the +//! spawned-cargo pattern (using the `CARGO`/`CARGO_MANIFEST_DIR` env vars +//! Cargo sets for integration tests) mirrors +//! `tests/downstream_consumer_smoke.rs`. No new dev-dependencies. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +/// Crate root, regardless of where `cargo test` is invoked from. +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(rel: &str) -> String { + let p = root().join(rel); + fs::read_to_string(&p).unwrap_or_else(|e| panic!("failed to read {}: {e}", p.display())) +} + +// ---- fuzz.yaml workflow invariants ---- + +#[test] +fn fuzz_workflow_exists_and_is_named_fuzz() { + let w = read(".github/workflows/fuzz.yaml"); + assert!(w.contains("name: \"fuzz\""), "workflow must be named fuzz"); +} + +#[test] +fn fuzz_workflow_uses_nightly_and_installs_cargo_fuzz() { + let w = read(".github/workflows/fuzz.yaml"); + assert!( + w.contains("dtolnay/rust-toolchain@nightly"), + "cargo-fuzz needs a nightly sanitizer toolchain" + ); + assert!(w.contains("tool: cargo-fuzz"), "must install cargo-fuzz"); +} + +#[test] +fn fuzz_workflow_builds_both_and_runs_load_archive() { + let w = read(".github/workflows/fuzz.yaml"); + // Builds all targets (catches a route_inputs compile break). + assert!(w.contains("cargo fuzz build"), "must build all targets"); + // Runs the load_archive quick-pass with a time bound. (A `--target` + // flag may sit between the target name and `--`, so assert the pieces + // rather than one contiguous substring.) + assert!( + w.contains("cargo fuzz run load_archive") && w.contains("-max_total_time="), + "must run load_archive with a max_total_time bound" + ); + // ASan requires the dynamically-linked gnu triple (musl's static libc + // breaks the sanitizer on GitHub runners). + assert!( + w.contains("--target x86_64-unknown-linux-gnu"), + "fuzz build/run must pin the gnu target for ASan compatibility" + ); +} + +#[test] +fn fuzz_workflow_triggers_and_least_privilege() { + let w = read(".github/workflows/fuzz.yaml"); + assert!(w.contains("pull_request:"), "runs on pull_request"); + assert!(w.contains("workflow_dispatch:"), "supports manual dispatch"); + assert!( + w.contains("cancel-in-progress: true"), + "concurrency cancels superseded runs" + ); + assert!( + w.contains("permissions:") && w.contains("contents: read"), + "least-privilege permissions" + ); +} + +// ---- fuzz crate structural invariants ---- + +#[test] +fn fuzz_crate_is_its_own_workspace_root() { + let c = read("fuzz/Cargo.toml"); + assert!( + c.contains("[workspace]"), + "fuzz must declare its own [workspace] so it stays excluded from any \ + future root workspace" + ); + assert!( + c.contains("cargo-fuzz = true"), + "cargo-fuzz metadata marker" + ); +} + +#[test] +fn fuzz_targets_declared() { + let c = read("fuzz/Cargo.toml"); + assert!(c.contains("name = \"load_archive\""), "load_archive target"); + assert!(c.contains("name = \"route_inputs\""), "route_inputs target"); +} + +#[test] +fn seed_corpus_present_and_valid_header() { + let p = root().join("fuzz/corpus/load_archive/seed_100km.rkyv"); + let bytes = fs::read(&p).unwrap_or_else(|e| panic!("failed to read seed {}: {e}", p.display())); + assert!(bytes.len() >= 8, "seed too short to carry a header"); + assert_eq!(&bytes[0..4], b"RRG1", "seed magic must be RRG1"); + assert_eq!( + u32::from_le_bytes(bytes[4..8].try_into().expect("4-byte slice")), + 1, + "seed schema version must be 1" + ); +} + +// ---- ACCEPTANCE: root build must NOT pick up the fuzz package ---- + +#[test] +fn root_metadata_excludes_fuzz_package() { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let out = Command::new(&cargo) + .args(["metadata", "--no-deps", "--format-version", "1"]) + .current_dir(root()) + .output() + .expect("spawn cargo metadata"); + assert!( + out.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let json = String::from_utf8_lossy(&out.stdout); + assert!( + !json.contains("rustyroute-fuzz"), + "root `cargo metadata` must NOT list the fuzz package as a workspace member" + ); +} + +// ---- OSS-Fuzz staging files (deliverables for the external submission) ---- +// +// The PR to google/oss-fuzz is a human follow-up (public repo + approval +// cycle), so these lock the *repo-side* deliverables: the project files must +// stay structurally valid and in sync with the fuzz crate, so the eventual +// submission does not fail an OSS-Fuzz `check_build`. + +#[test] +fn oss_fuzz_project_yaml_has_required_keys() { + let y = read("oss-fuzz/projects/rustyroute/project.yaml"); + for key in [ + "language: rust", + "primary_contact:", + "main_repo:", + "fuzzing_engines:", + "sanitizers:", + ] { + assert!(y.contains(key), "project.yaml must contain `{key}`"); + } + assert!(y.contains("libfuzzer"), "libfuzzer engine required"); + assert!(y.contains("address"), "address sanitizer required"); +} + +#[test] +fn oss_fuzz_dockerfile_uses_rust_base_builder() { + let d = read("oss-fuzz/projects/rustyroute/Dockerfile"); + assert!( + d.contains("FROM gcr.io/oss-fuzz-base/base-builder-rust"), + "Dockerfile must build on the OSS-Fuzz Rust base image" + ); + assert!( + d.contains("COPY build.sh"), + "Dockerfile must stage build.sh" + ); +} + +#[test] +fn oss_fuzz_build_sh_builds_all_targets_and_seeds() { + let b = read("oss-fuzz/projects/rustyroute/build.sh"); + assert!( + b.starts_with("#!/bin/bash"), + "build.sh needs a bash shebang" + ); + assert!( + b.contains("cargo fuzz build"), + "must build the fuzz targets" + ); + // Every declared fuzz target must be handled by build.sh, and vice versa — + // guards against renaming a target in Cargo.toml but not the build script. + let cargo = read("fuzz/Cargo.toml"); + for target in ["load_archive", "route_inputs"] { + assert!( + cargo.contains(&format!("name = \"{target}\"")), + "target {target} should be declared in fuzz/Cargo.toml" + ); + assert!( + b.contains(target), + "build.sh must handle the {target} target" + ); + } + // The seed must be delivered via OSS-Fuzz's _seed_corpus.zip + // convention; loose files copied into $OUT are ignored by OSS-Fuzz. + assert!( + b.contains("load_archive_seed_corpus.zip"), + "seed must be packaged as load_archive_seed_corpus.zip" + ); +} + +#[test] +fn oss_fuzz_build_sh_is_committed_executable() { + // OSS-Fuzz invokes build.sh directly, so it must carry the executable bit. + // Check git's tracked mode (100755) — platform-independent, unlike the + // local filesystem bit which PermissionsExt cannot read on Windows. + let out = Command::new("git") + .args(["ls-files", "-s", "oss-fuzz/projects/rustyroute/build.sh"]) + .current_dir(root()) + .output(); + let out = match out { + Ok(o) if o.status.success() && !o.stdout.is_empty() => o, + // Not a git checkout (e.g. a packaged crate tarball) — nothing to + // assert against; skip rather than fail. + _ => return, + }; + let line = String::from_utf8_lossy(&out.stdout); + assert!( + line.starts_with("100755"), + "build.sh must be committed executable (git mode 100755), got: {line}" + ); +}