Skip to content

Commit 22219fb

Browse files
cargo-unit: per-unit panic-freedom check via relocation scan (#309)
Adds an opt-in `--deny-panics` policy check to cargo-unit that fails a build when a workspace function can reach a panic. Refs #307. ## Approach Detection is relocation-based. rustc lowers a panic call to a relocation whose target is an undefined panic sink (`core::panicking::*`, plus the `unwrap_failed` / `expect_failed` cold helpers that `unwrap`/`expect` route through, confirmed against the relocations rustc emits at opt-level 0 and 3). The new `scan-panics` subcommand reads symbols and relocations with the `object` crate and attributes each panic call to its containing function, with no disassembler and no demangler, so one path covers ELF and Mach-O. To cover generics, the scan reads the relocatable objects of every workspace unit, not just library rlibs. A generic carries no relocation in its defining rlib because it is codegened where it is monomorphized; the bin or test object that instantiates it does carry the relocation, and the symbol keeps the defining crate's mangled token. Each unit therefore gets a `panic-objects` derivation (a recompile with `--emit obj`, mirroring the clippy units), and findings are scoped to the whole workspace crate set so a library generic monomorphized inside another unit is attributed back to its crate. This was the deliberate design after ruling out the naive alternative: a trivial clean std binary already carries ~36 `panicking` symbols from std's runtime, so a whole-artifact symbol grep is always-red for std code. Relocation attribution scoped to the workspace crate set is the signal that actually means "this workspace's code can panic." ## What it does - `--deny-panics` renders `policyChecks.panicFreedom`: one scan derivation per workspace unit, joined under one aggregate, so a touched unit only re-scans itself. - Each scan runs `nix-cargo-unit scan-panics --crate-name ...` over the unit's `panic-objects` derivation, scoped to the workspace crate set. - The scanner is the `cargoUnit` package, threaded through a new `cargoUnit` template argument and asserted non-null, so enabling the policy without wiring the scanner fails loudly instead of silently passing. ## Validation - 45 Rust tests pass (hermetic scanner tests synthesize objects via `object::write`, no rustc dependency), clippy clean, fmt clean, generated Nix parses. - End-to-end on real crates: `unwrap`/`expect`/indexing are flagged with their exact entrypoints; a generic `pub fn first<T>(xs:&[T])->T{xs[3]}` invisible in its own rlib is caught via a consumer's objects and attributed to its defining crate; a clean crate and a crate with its own `panicking` module both pass. ## Known limitations (documented in code) - Best-effort, not a soundness proof. The residual gap is a public generic that no workspace bin/test ever instantiates (never codegened anywhere here, and also unreachable from the workspace's own entrypoints). The sound successor is call-graph reachability over the linked, monomorphized binary (what `findpanics` does). - The panic-sink catalog is curated; a panic routed through some other std/alloc cold path is missed until its symbol is added. - The `ix.cargoUnit.buildWorkspace` `policy` toggle that passes `cargoUnit` and sets `--deny-panics` lives in the `ix` repo and is the remaining integration step before this runs in a real workspace.
1 parent 8012487 commit 22219fb

9 files changed

Lines changed: 946 additions & 47 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ loro = "1.12"
5656
ndarray = "0.16"
5757
nix-web-monitor-parser = { path = "packages/nix-web-monitor/parser" }
5858
numpy = "0.28"
59+
object = { version = "0.37", default-features = false, features = [
60+
"read",
61+
"write",
62+
"std",
63+
] }
5964
parking_lot = "0.12"
6065
pty-process = { version = "0.4", features = ["async"] }
6166
pyo3 = { version = "0.28", features = ["abi3-py311"] }

lib/cargo-unit.nix

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ let
256256
toolchainId
257257
]
258258
++ lib.optional args.contentAddressed "--content-addressed"
259-
++ lib.optional args.policy.denyUnusedCrateDependencies "--deny-unused-crate-dependencies";
259+
++ lib.optional args.policy.denyUnusedCrateDependencies "--deny-unused-crate-dependencies"
260+
++ lib.optional args.policy.denyPanics "--deny-panics";
260261
in
261262
pkgs.runCommand "cargo-units.nix"
262263
{
@@ -372,6 +373,9 @@ let
372373
rustToolchain
373374
;
374375
inherit workspaceRoot;
376+
# Scanner for the opt-in panic-freedom policy. The rendered check
377+
# asserts this is non-null when `policy.denyPanics` is set.
378+
cargoUnit = nixCargoUnit;
375379
extraNativeBuildInputs = args.nativeBuildInputs ++ rust.nativeBuildInputsForPolicy args.policy;
376380
# `clippy-driver` ships in the clippy package; `rustToolchain` only
377381
# guarantees rustc + cargo. Adding the resolved clippy package keeps

lib/rust.nix

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ let
2323

2424
defaultPolicy = {
2525
denyUnusedCrateDependencies = true;
26+
# Opt-in: scans each unit's objects for functions that can reach a panic.
27+
# Off by default because it is a best-effort gate, not a soundness proof.
28+
denyPanics = false;
2629
cargoAudit = {
2730
enable = false;
2831
db = defaultRustsecAdvisoryDb;
@@ -63,6 +66,7 @@ let
6366
{
6467
denyUnusedCrateDependencies =
6568
rawPolicy.denyUnusedCrateDependencies or defaultPolicy.denyUnusedCrateDependencies;
69+
denyPanics = rawPolicy.denyPanics or defaultPolicy.denyPanics;
6670
cargoAudit = {
6771
enable = cargoAudit.enable or defaultPolicy.cargoAudit.enable;
6872
db = cargoAudit.db or defaultPolicy.cargoAudit.db;

packages/nix-cargo-unit/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ workspace = true
1313
askama = { workspace = true, default-features = false, features = ["derive", "std"] }
1414
clap = { workspace = true, features = ["derive"] }
1515
color-eyre.workspace = true
16+
object.workspace = true
1617
serde = { workspace = true, features = ["derive"] }
1718
serde_json.workspace = true
1819
sha2.workspace = true

packages/nix-cargo-unit/src/main.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod hash;
22
mod model;
3+
mod panic_scan;
34
mod render;
45
mod shell;
56

@@ -28,6 +29,24 @@ enum Command {
2829

2930
/// Render generated Nix from Cargo unit-graph JSON on stdin.
3031
Render(RenderArgs),
32+
33+
/// Scan compiled rlib artifacts for functions that can reach a panic.
34+
ScanPanics(ScanPanicsArgs),
35+
}
36+
37+
#[derive(Debug, clap::Args)]
38+
struct ScanPanicsArgs {
39+
/// Workspace crate (Cargo target name) whose functions findings are scoped
40+
/// to. Repeat for the full workspace set so a library generic monomorphized
41+
/// in another unit's object is still attributed. Omit to report every
42+
/// panic-reaching function.
43+
#[arg(long = "crate-name", value_name = "NAME")]
44+
crate_names: Vec<String>,
45+
46+
/// Rlib or object artifacts, or directories to scan. Directories are
47+
/// searched for `*.rlib` and `*.o` recursively.
48+
#[arg(required = true, value_name = "PATH")]
49+
paths: Vec<PathBuf>,
3150
}
3251

3352
#[derive(Debug, clap::Args)]
@@ -62,6 +81,11 @@ struct RenderArgs {
6281
/// Collect and fail builds on dependencies unused across all local package units.
6382
#[arg(long)]
6483
deny_unused_crate_dependencies: bool,
84+
85+
/// Emit a per-unit panic-freedom policy check that scans each local unit's
86+
/// compiled artifact for reachable panic machinery and fails if any is found.
87+
#[arg(long)]
88+
deny_panics: bool,
6589
}
6690

6791
fn merge(args: MergeArgs) -> color_eyre::Result<()> {
@@ -103,6 +127,7 @@ fn render(args: RenderArgs) -> color_eyre::Result<()> {
103127
content_addressed: args.content_addressed,
104128
toolchain_id: args.toolchain_id,
105129
deny_unused_crate_dependencies: args.deny_unused_crate_dependencies,
130+
deny_panics: args.deny_panics,
106131
},
107132
)
108133
.wrap_err("rendering Cargo unit graph as Nix")?;
@@ -111,11 +136,52 @@ fn render(args: RenderArgs) -> color_eyre::Result<()> {
111136
Ok(())
112137
}
113138

139+
fn scan_panics(args: ScanPanicsArgs) -> color_eyre::Result<()> {
140+
let ScanPanicsArgs { crate_names, paths } = args;
141+
let artifacts = panic_scan::collect_artifacts(&paths)?;
142+
// Fail closed: a panic gate that finds nothing to inspect must error, not
143+
// report success, or a wrong path or empty object set would pass open.
144+
if artifacts.is_empty() {
145+
color_eyre::eyre::bail!(
146+
"cargo-unit panic-freedom: no .rlib or .o artifacts found under {}",
147+
paths
148+
.iter()
149+
.map(|path| path.display().to_string())
150+
.collect::<Vec<_>>()
151+
.join(", ")
152+
);
153+
}
154+
let crate_tokens: Vec<String> = crate_names
155+
.iter()
156+
.map(|name| panic_scan::crate_token(name))
157+
.collect();
158+
let findings = panic_scan::scan_paths(&artifacts, &crate_tokens)?;
159+
160+
if findings.is_empty() {
161+
return Ok(());
162+
}
163+
164+
let scope = if crate_names.is_empty() {
165+
String::new()
166+
} else {
167+
format!(" in {}", crate_names.join(", "))
168+
};
169+
eprintln!(
170+
"error: cargo-unit panic-freedom: {} function(s){scope} can reach panic machinery",
171+
findings.len()
172+
);
173+
for finding in &findings {
174+
eprintln!(" {} -> {}", finding.function, finding.panic_entrypoint);
175+
}
176+
std::process::exit(1);
177+
}
178+
114179
fn main() -> color_eyre::Result<()> {
115180
color_eyre::install()?;
116181

117182
match Cli::parse().command {
118183
Command::Merge(args) => merge(args),
119184
Command::Render(args) => render(args),
185+
Command::ScanPanics(args) => scan_panics(args),
120186
}
121187
}

0 commit comments

Comments
 (0)