mirror of
https://github.com/bdbrown4/peltier.io.git
synced 2026-11-07 10:58:28 +00:00
gates: close the audit findings — enforce the gates the docs claimed
An adversarial audit found the enforcement story ahead of the code: gates that were documented as hard gates were advisory, manual, or absent, and several human-facing claims outran the machine record. This makes the claims true. High findings - Differential fuzz was documentation, not enforcement: verdict hardcoded fuzz_iters=0 and diff-test hardcoded the gate to Skipped, while the case studies claimed 10k-iteration fuzz on the same wins. Fuzz is now a real pipeline gate (strict FUZZ-RESULT contract, executed count recorded), and it is inherently baseline-vs-candidate, so it runs on the accept path and reports Skipped in the standalone `just gates` flow. New hard rule: a machine `accepted` verdict is impossible unless the fuzz gate actually passed — otherwise it caps at needs-human-review. - The agent's system prompt claimed FP/concurrency/unsafe patches were "routed to human review automatically". No such mechanism existed. Added a conservative lexical risk classifier (crates/verdict/src/risk.rs) that forces needs-human-review on any would-be accept carrying those signals; fp-tolerance equivalence always signals. - The local verdict path ran agent-influenced code unsandboxed. It now runs under scripts/no-net.sh (network namespace) and fails closed. Upstream test suites are hash-verified against corpora/<t>/TESTSUITE.sha256 when present (scripts/pin-testsuite.sh generates them). Also fixed, found while fixing the above - The pipeline script's `set -e` was inert (brace group as the left operand of `&&`), so a failing candidate build did not abort the run and verdict could bench a stale binary. Now a scoped subshell whose status is branched on at top level. - A wrapper that exits before exec'ing (no-net.sh refusing, bad wrapper path) stranded RUNNING.lock and left read_verdict polling forever. Failure accounting now lives outside the wrapper. - harnessd's patch allowlist ignored git rename/copy headers and --no-prefix diffs; the rename-escape case is now a committed regression test. - The workspace did not compile on Windows (ungated Unix APIs). cfg-gated; 71 portable tests now run on both platforms. - pin-check regenerated the corpus manifest instead of verifying it; it is now verify-only, with a deliberate `just pin-corpus` to re-pin. - Max RSS is captured per run on Unix; PMU counters are opt-in diagnostics (compare --perf-stat), never part of the accept decision. Docs now match the code, including what is NOT yet enforced: no target ships a TESTSUITE.sha256 pin, no target declares [build].tsan, and matmul is not wired to the fp-tolerance gate — each is stated as mechanized-but-inert rather than done. Corrected the stale "33 ledger rows" (34), the playbook's class-3 cJSON win (rejected), adversarial-review.md's sanitizer-separation claim (five accepted rows carry sanitizers_clean=false), the unsupported "40,000 requests" figure, and the blanket "historical rows record fuzz_iters=0" (8 of 10; phase0-tokei-002/-003 record 4,332). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+279
-62
@@ -1,15 +1,25 @@
|
||||
//! `just verdict`: the whole attempt pipeline in one command.
|
||||
//! Gates on the candidate → interleaved A/B bench vs the baseline →
|
||||
//! verdict by the CI-lower-bound rule → append-only ledger row.
|
||||
//! Any gate failure is rejected-gate and the bench is skipped; the
|
||||
//! --needs-human-review flag caps the verdict for FP/concurrency/
|
||||
//! sanitizer-flagged attempts (SPEC §8) — it never upgrades one.
|
||||
//! Any gate failure is rejected-gate and the bench is skipped. A verdict
|
||||
//! is only ever capped, never upgraded: the lexical risk classifier
|
||||
//! (src/risk.rs) and fp-tolerance equivalence route any would-be accept
|
||||
//! to needs-human-review, and the --needs-human-review flag forces the
|
||||
//! same cap by hand (SPEC §8).
|
||||
//!
|
||||
//! This is also the only flow that can run differential fuzz, because it is
|
||||
//! the only one holding both sides of the comparison: it rebuilds a pristine
|
||||
//! baseline and passes it to the gates alongside the candidate. Hard rule —
|
||||
//! a machine `accepted` requires that fuzz gate to have actually Passed; a
|
||||
//! skipped or failed one caps the verdict at needs-human-review.
|
||||
|
||||
mod risk;
|
||||
|
||||
use anyhow::Result;
|
||||
use bench_runner::{config::AcceptConfig, decide, exec, fingerprint::EnvFingerprint, stats};
|
||||
use clap::Parser;
|
||||
use diff_test::{
|
||||
all_passed, orchestrate::run_core_gates, target::TargetSpec, GateLayer, GateOutcome,
|
||||
orchestrate::run_core_gates, target::TargetSpec, GateInputs, GateLayer, GateOutcome,
|
||||
};
|
||||
use ledger::{Attempt, BenchEvidence, GateResults, Ledger, Verdict};
|
||||
use std::path::PathBuf;
|
||||
@@ -48,6 +58,10 @@ struct Cli {
|
||||
/// Force the needs-human-review verdict tier (FP/concurrency/UB-adjacent).
|
||||
#[arg(long)]
|
||||
needs_human_review: bool,
|
||||
/// How this run was isolated, recorded verbatim in the env
|
||||
/// fingerprint (e.g. "no-net.sh"); absent = "unwrapped-host".
|
||||
#[arg(long)]
|
||||
isolation_note: Option<String>,
|
||||
#[arg(long, default_value = "results/ledger.sqlite")]
|
||||
db: PathBuf,
|
||||
#[arg(long, default_value = "config/accept.toml")]
|
||||
@@ -107,6 +121,78 @@ fn rebuild_pristine_baseline(
|
||||
Ok(baseline)
|
||||
}
|
||||
|
||||
/// A run is flagged on nonzero exit OR any of these stderr markers.
|
||||
/// halt_on_error makes sanitizer hits exit nonzero; the markers catch
|
||||
/// builds whose runtime still exits 0 (and never a bare "ERROR:" from
|
||||
/// the target's own logging, the old false-flag risk).
|
||||
const SANITIZER_MARKERS: &[&str] = &[
|
||||
"ERROR: AddressSanitizer",
|
||||
"ERROR: LeakSanitizer",
|
||||
"ERROR: ThreadSanitizer",
|
||||
"WARNING: ThreadSanitizer",
|
||||
"runtime error:",
|
||||
];
|
||||
|
||||
/// One instrumented build+run lane: templates, isolation subdir, and the
|
||||
/// halt-on-error runtime options for its sanitizer family.
|
||||
struct SanLane<'a> {
|
||||
label: &'a str,
|
||||
out_subdir: &'a str,
|
||||
build_tpl: &'a str,
|
||||
bin_tpl: &'a str,
|
||||
envs: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
/// Build one instrumented lane and run the pinned bench workload under
|
||||
/// it. Returns Some(clean), or None when the instrumented binary cannot
|
||||
/// be built — an infrastructure failure, not a code defect: the caller
|
||||
/// caps the accept at needs-human-review instead of crashing a verdict
|
||||
/// that already carries a measured bench.
|
||||
fn run_san_lane(
|
||||
root: &std::path::Path,
|
||||
target: &str,
|
||||
spec: &TargetSpec,
|
||||
lane: &SanLane,
|
||||
) -> Result<Option<bool>> {
|
||||
let label = lane.label;
|
||||
let out_dir = root
|
||||
.join(format!("targets/{target}/{}", lane.out_subdir))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
println!("{label}: instrumented build of the patched tree…");
|
||||
let status = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(diff_test::target::subst_out(lane.build_tpl, &out_dir))
|
||||
.current_dir(root)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
println!("{label}: BUILD FAILED — cannot verify; capping accept at needs-human-review");
|
||||
return Ok(None);
|
||||
}
|
||||
let bin = diff_test::target::subst_out(lane.bin_tpl, &out_dir);
|
||||
let mut run = std::process::Command::new("sh");
|
||||
run.arg("-c")
|
||||
.arg(spec.bench.command.replace("{binary}", &bin))
|
||||
.current_dir(root);
|
||||
for (k, v) in lane.envs {
|
||||
run.env(k, v);
|
||||
}
|
||||
let out = run.output()?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let clean = out.status.success() && !SANITIZER_MARKERS.iter().any(|m| stderr.contains(m));
|
||||
if clean {
|
||||
println!("{label}: clean");
|
||||
} else {
|
||||
let lines: Vec<&str> = stderr.lines().filter(|l| !l.is_empty()).collect();
|
||||
let start = lines.len().saturating_sub(8);
|
||||
println!("{label}: FLAGGED (rc={:?})", out.status.code());
|
||||
for l in &lines[start..] {
|
||||
println!(" {l}");
|
||||
}
|
||||
}
|
||||
Ok(Some(clean))
|
||||
}
|
||||
|
||||
/// SPEC §8: an accept is final only if ASan+LSan are clean on the patched
|
||||
/// tree over the pinned workload. Stop-the-line fix after phase2-comrak-010:
|
||||
/// the pipeline auto-accepted an LSan-flagged teardown patch (sanitizers
|
||||
@@ -126,49 +212,67 @@ fn sanitizer_check(
|
||||
println!("sanitizers: target declares no sanitizer build — cannot verify an accept");
|
||||
return Ok(None);
|
||||
};
|
||||
let out_dir = root
|
||||
.join(format!("targets/{target}/asan"))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
println!("sanitizers: ASan build of the patched tree…");
|
||||
let status = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(diff_test::target::subst_out(build_tpl, &out_dir))
|
||||
.current_dir(root)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
// Infrastructure failure (missing runtime, toolchain), not a code
|
||||
// defect: don't crash the verdict and lose a measured bench —
|
||||
// treat "cannot build the sanitizer binary" like "no sanitizer
|
||||
// declared" so the accept caps at needs-human-review (None), with
|
||||
// the failure logged for the operator to fix and re-run.
|
||||
println!("sanitizers: BUILD FAILED — cannot verify; capping accept at needs-human-review");
|
||||
run_san_lane(
|
||||
root,
|
||||
target,
|
||||
spec,
|
||||
&SanLane {
|
||||
label: "sanitizers",
|
||||
out_subdir: "asan",
|
||||
build_tpl,
|
||||
bin_tpl,
|
||||
envs: &[
|
||||
("ASAN_OPTIONS", "halt_on_error=1:abort_on_error=1"),
|
||||
("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1"),
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// TSan lane (SPEC §8), run on the accept path when the target declares
|
||||
/// a TSan build — call only when spec.build.tsan is Some. Returns None
|
||||
/// when the lane cannot run (missing tsan_binary, build failure); the
|
||||
/// caller caps the accept, mirroring the ASan posture.
|
||||
fn tsan_check(root: &std::path::Path, target: &str, spec: &TargetSpec) -> Result<Option<bool>> {
|
||||
let (Some(build_tpl), Some(bin_tpl)) = (&spec.build.tsan, &spec.build.tsan_binary) else {
|
||||
println!("tsan: build.tsan declared without build.tsan_binary — cannot verify");
|
||||
return Ok(None);
|
||||
};
|
||||
run_san_lane(
|
||||
root,
|
||||
target,
|
||||
spec,
|
||||
&SanLane {
|
||||
label: "tsan",
|
||||
out_subdir: "tsan",
|
||||
build_tpl,
|
||||
bin_tpl,
|
||||
envs: &[("TSAN_OPTIONS", "halt_on_error=1")],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// One word for the state of the DifferentialFuzz gate, for the cap message.
|
||||
fn fuzz_state(outcome: Option<&GateOutcome>) -> &'static str {
|
||||
match outcome {
|
||||
Some(GateOutcome::Passed) => "passed",
|
||||
Some(GateOutcome::Failed { .. }) => "failed",
|
||||
Some(GateOutcome::Skipped { .. }) => "skipped",
|
||||
None => "not run",
|
||||
}
|
||||
let asan_bin = diff_test::target::subst_out(bin_tpl, &out_dir);
|
||||
let cmd = spec.bench.command.replace("{binary}", &asan_bin);
|
||||
let out = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
// detect_leaks is a no-op for the C/C++ UBSan combo but harmless.
|
||||
.env("ASAN_OPTIONS", "detect_leaks=1")
|
||||
.env("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1")
|
||||
.current_dir(root)
|
||||
.output()?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let clean =
|
||||
out.status.success() && !stderr.contains("ERROR:") && !stderr.contains("runtime error:");
|
||||
if clean {
|
||||
println!("sanitizers: clean");
|
||||
}
|
||||
|
||||
/// Median of per-run max-RSS samples (KiB); an even count averages the
|
||||
/// two middle values.
|
||||
fn median_u64(xs: &[u64]) -> u64 {
|
||||
let mut v = xs.to_vec();
|
||||
v.sort_unstable();
|
||||
let n = v.len();
|
||||
if n % 2 == 1 {
|
||||
v[n / 2]
|
||||
} else {
|
||||
let lines: Vec<&str> = stderr.lines().filter(|l| !l.is_empty()).collect();
|
||||
let start = lines.len().saturating_sub(8);
|
||||
println!("sanitizers: FLAGGED (rc={:?})", out.status.code());
|
||||
for l in &lines[start..] {
|
||||
println!(" {l}");
|
||||
}
|
||||
(v[n / 2 - 1] + v[n / 2]) / 2
|
||||
}
|
||||
Ok(Some(clean))
|
||||
}
|
||||
|
||||
fn now_utc() -> String {
|
||||
@@ -197,23 +301,58 @@ fn main() -> Result<()> {
|
||||
})?
|
||||
};
|
||||
|
||||
// Gates run on the candidate binary.
|
||||
let gates = run_core_gates(&root, &spec, &cli.candidate_bin)?;
|
||||
for (layer, outcome) in &gates {
|
||||
let patch = match &cli.patch_file {
|
||||
Some(p) => std::fs::read_to_string(p)?,
|
||||
None => "(no source patch)".into(),
|
||||
};
|
||||
|
||||
// Gates run on the candidate binary — except differential fuzz, which
|
||||
// differs it against the pristine baseline built just above. This is the
|
||||
// only flow that has both sides, and therefore the only flow that can
|
||||
// run the fuzz gate at all (hence the accept rule enforced below).
|
||||
let report = run_core_gates(
|
||||
&root,
|
||||
&spec,
|
||||
&GateInputs {
|
||||
candidate_binary: &cli.candidate_bin,
|
||||
baseline_binary: Some(baseline_bin.as_str()),
|
||||
fuzz_iters: cfg.fuzz_iters,
|
||||
},
|
||||
)?;
|
||||
for (layer, outcome) in &report.gates {
|
||||
match outcome {
|
||||
GateOutcome::Passed => println!("{layer:?}: PASS"),
|
||||
GateOutcome::Failed { detail } => println!("{layer:?}: FAIL — {detail}"),
|
||||
GateOutcome::Skipped { reason } => println!("{layer:?}: skipped — {reason}"),
|
||||
}
|
||||
}
|
||||
let passed = |layer| matches!(report.outcome(layer), Some(GateOutcome::Passed));
|
||||
let mut gate_results = GateResults {
|
||||
upstream_tests: matches!(gates[0].1, GateOutcome::Passed),
|
||||
golden_replay: matches!(gates[1].1, GateOutcome::Passed),
|
||||
fuzz_iters: 0,
|
||||
fuzz_divergence: false,
|
||||
upstream_tests: passed(GateLayer::UpstreamTests),
|
||||
golden_replay: passed(GateLayer::GoldenReplay),
|
||||
fuzz_iters: report.fuzz_iters,
|
||||
fuzz_divergence: report.fuzz_divergence,
|
||||
sanitizers_clean: false,
|
||||
tsan_clean: None,
|
||||
risk_signals: risk::classify(&patch, report.equivalence_mode == "fp-tolerance"),
|
||||
equivalence_mode: Some(report.equivalence_mode.clone()),
|
||||
};
|
||||
debug_assert!(matches!(gates[0].0, GateLayer::UpstreamTests));
|
||||
if !gate_results.risk_signals.is_empty() {
|
||||
println!(
|
||||
"risk: lexical signals [{}] — a would-be accept routes to needs-human-review (SPEC §8)",
|
||||
gate_results.risk_signals.join(", ")
|
||||
);
|
||||
}
|
||||
// Announced up front, like the risk signals, so the rule is in the
|
||||
// transcript even when another cap fires first below.
|
||||
let fuzz_passed = passed(GateLayer::DifferentialFuzz);
|
||||
if !fuzz_passed {
|
||||
println!(
|
||||
"fuzz: differential-fuzz gate {} — a would-be accept routes to needs-human-review; \
|
||||
no machine accept without a real differential-fuzz run (SPEC §8)",
|
||||
fuzz_state(report.outcome(GateLayer::DifferentialFuzz))
|
||||
);
|
||||
}
|
||||
|
||||
let pin = &cfg.pin_prefix;
|
||||
let wrap = |bin: &str| {
|
||||
@@ -225,7 +364,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let (verdict, bench) = if !all_passed(&gates) {
|
||||
let (verdict, bench) = if !report.all_passed() {
|
||||
println!("verdict: rejected-gate (bench skipped)");
|
||||
(Verdict::RejectedGate, None)
|
||||
} else {
|
||||
@@ -285,8 +424,62 @@ fn main() -> Result<()> {
|
||||
v = Verdict::NeedsHumanReview;
|
||||
}
|
||||
}
|
||||
if spec.build.tsan.is_some() {
|
||||
match tsan_check(&root, &cli.target, &spec)? {
|
||||
Some(clean) => {
|
||||
gate_results.tsan_clean = Some(clean);
|
||||
if !clean {
|
||||
println!(
|
||||
"verdict: TSan flag caps the accept at needs-human-review (SPEC §8)"
|
||||
);
|
||||
v = Verdict::NeedsHumanReview;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("verdict: TSan lane declared but not verifiable; capping accept at needs-human-review (SPEC §8)");
|
||||
v = Verdict::NeedsHumanReview;
|
||||
}
|
||||
}
|
||||
}
|
||||
if v == Verdict::Accepted && !gate_results.risk_signals.is_empty() {
|
||||
println!(
|
||||
"verdict: risk signals [{}] route the accept to needs-human-review (SPEC §8)",
|
||||
gate_results.risk_signals.join(", ")
|
||||
);
|
||||
v = Verdict::NeedsHumanReview;
|
||||
}
|
||||
// Hard rule (audit remediation): differential fuzz is the gate
|
||||
// that catches behavioral divergence the fixed corpus misses, so
|
||||
// an accept minted without it is an unverified claim. The audit
|
||||
// found accepted rows carrying fuzz_iters=0 — fuzz had run
|
||||
// out-of-band, or not at all. A skipped or failed fuzz gate can
|
||||
// no longer produce a machine accept, only needs-human-review.
|
||||
if v == Verdict::Accepted && !fuzz_passed {
|
||||
println!(
|
||||
"verdict: differential fuzz {} — no accept without a real differential-fuzz \
|
||||
run against the pristine baseline; capping at needs-human-review. The target \
|
||||
must declare [gates].fuzz in targets/{}/target.toml, and the run must report \
|
||||
FUZZ-RESULT with zero divergences (SPEC §8)",
|
||||
fuzz_state(report.outcome(GateLayer::DifferentialFuzz)),
|
||||
cli.target
|
||||
);
|
||||
v = Verdict::NeedsHumanReview;
|
||||
}
|
||||
}
|
||||
let fp = EnvFingerprint::collect(pin, "system-default");
|
||||
let mut env_fingerprint = serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"workload": spec.bench.workload,
|
||||
"target_commit": spec.source.commit,
|
||||
"isolation": cli.isolation_note.as_deref().unwrap_or("unwrapped-host"),
|
||||
"gates_detail": "diff-fuzz gated in-pipeline against the pristine baseline (an accept requires it to have passed); ASan+UBSan enforced on the accept path, TSan when configured",
|
||||
});
|
||||
if !samples.baseline_max_rss_kib.is_empty() && !samples.candidate_max_rss_kib.is_empty() {
|
||||
env_fingerprint["max_rss_kib"] = serde_json::json!({
|
||||
"baseline_median": median_u64(&samples.baseline_max_rss_kib),
|
||||
"candidate_median": median_u64(&samples.candidate_max_rss_kib),
|
||||
});
|
||||
}
|
||||
(
|
||||
v,
|
||||
Some(BenchEvidence {
|
||||
@@ -296,20 +489,11 @@ fn main() -> Result<()> {
|
||||
candidate_ci: (clo, chi),
|
||||
speedup_median: ratio.median,
|
||||
speedup_ci: (ratio.lo, ratio.hi),
|
||||
env_fingerprint: serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"workload": spec.bench.workload,
|
||||
"target_commit": spec.source.commit,
|
||||
"gates_detail": "diff-fuzz per-attempt manual; ASan+LSan enforced on the accept path",
|
||||
}),
|
||||
env_fingerprint,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let patch = match &cli.patch_file {
|
||||
Some(p) => std::fs::read_to_string(p)?,
|
||||
None => "(no source patch)".into(),
|
||||
};
|
||||
// Derive the phase from the run_id namespace (phaseN-*) so the column
|
||||
// matches the run_id prefix; the historical hardcoded `1` left every
|
||||
// verdict-written row tagged phase 1 regardless of namespace (found in
|
||||
@@ -344,3 +528,36 @@ fn main() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fuzz_state, median_u64};
|
||||
use diff_test::GateOutcome;
|
||||
|
||||
#[test]
|
||||
fn median_u64_odd_and_even() {
|
||||
assert_eq!(median_u64(&[5]), 5);
|
||||
assert_eq!(median_u64(&[9, 1, 5]), 5);
|
||||
assert_eq!(median_u64(&[4, 2, 8, 6]), 5);
|
||||
}
|
||||
|
||||
/// Only `Passed` clears the accept bar; every other state (including a
|
||||
/// gate that never ran) is a cap to needs-human-review.
|
||||
#[test]
|
||||
fn fuzz_state_names_every_outcome() {
|
||||
assert_eq!(fuzz_state(Some(&GateOutcome::Passed)), "passed");
|
||||
assert_eq!(
|
||||
fuzz_state(Some(&GateOutcome::Failed {
|
||||
detail: "divergences=3".into()
|
||||
})),
|
||||
"failed"
|
||||
);
|
||||
assert_eq!(
|
||||
fuzz_state(Some(&GateOutcome::Skipped {
|
||||
reason: "no baseline".into()
|
||||
})),
|
||||
"skipped"
|
||||
);
|
||||
assert_eq!(fuzz_state(None), "not run");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Lexical NHR risk classifier (SPEC §8). Scans only the changed lines
|
||||
//! of a unified diff for concurrency / unsafe / floating-point tokens
|
||||
//! and routes any would-be accept that hits one to needs-human-review.
|
||||
//! Deliberately lexical and conservative: over-triggering costs a human
|
||||
//! a look, under-triggering ships an unreviewed risk — so plain token
|
||||
//! presence decides, no semantic analysis.
|
||||
|
||||
const CONCURRENCY: &[&str] = &[
|
||||
"pthread_",
|
||||
"std::sync",
|
||||
"std::thread",
|
||||
"Atomic",
|
||||
"atomic_",
|
||||
"__atomic_",
|
||||
"__sync_",
|
||||
"Mutex",
|
||||
"RwLock",
|
||||
"Condvar",
|
||||
"mpsc",
|
||||
"rayon::",
|
||||
"memory_order",
|
||||
"volatile",
|
||||
];
|
||||
|
||||
const UNSAFE: &[&str] = &[
|
||||
"unsafe ",
|
||||
"transmute",
|
||||
"from_raw",
|
||||
"MaybeUninit",
|
||||
"UnsafeCell",
|
||||
];
|
||||
|
||||
/// The spaced tokens carry their own trailing boundary; `f32`/`f64` get
|
||||
/// hand boundary checks below.
|
||||
const FP_SPACED: &[&str] = &["float ", "double "];
|
||||
const FP_TYPES: &[&str] = &["f32", "f64"];
|
||||
|
||||
fn is_ident_letter(b: u8) -> bool {
|
||||
b.is_ascii_alphabetic() || b == b'_'
|
||||
}
|
||||
|
||||
/// True when `line` contains `token` not glued to an identifier letter on
|
||||
/// either side. Digits are allowed neighbors so literal suffixes like
|
||||
/// `1.0f32` still trigger; letter neighbors (identifiers, most hex
|
||||
/// strings, e.g. `buf32`, `deadf64b`) do not.
|
||||
fn has_fp_token(line: &str, token: &str) -> bool {
|
||||
let bytes = line.as_bytes();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = line[start..].find(token) {
|
||||
let i = start + pos;
|
||||
let before_ok = i == 0 || !is_ident_letter(bytes[i - 1]);
|
||||
let after_ok = i + token.len() >= bytes.len() || !is_ident_letter(bytes[i + token.len()]);
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_changed_line(line: &str) -> bool {
|
||||
(line.starts_with('+') && !line.starts_with("+++"))
|
||||
|| (line.starts_with('-') && !line.starts_with("---"))
|
||||
}
|
||||
|
||||
/// Classify a unified diff into risk-signal names. `fp_tolerance` is true
|
||||
/// when the target's equivalence mode is fp-tolerance, itself a §8 signal.
|
||||
/// Returned names are unique; empty means no signal.
|
||||
pub fn classify(diff: &str, fp_tolerance: bool) -> Vec<String> {
|
||||
let (mut concurrency, mut unsafe_sig, mut fp) = (false, false, false);
|
||||
for line in diff.lines().filter(|l| is_changed_line(l)) {
|
||||
concurrency = concurrency || CONCURRENCY.iter().any(|t| line.contains(t));
|
||||
unsafe_sig = unsafe_sig || UNSAFE.iter().any(|t| line.contains(t));
|
||||
fp = fp
|
||||
|| FP_SPACED.iter().any(|t| line.contains(t))
|
||||
|| FP_TYPES.iter().any(|t| has_fp_token(line, t));
|
||||
}
|
||||
let mut signals = Vec::new();
|
||||
if concurrency {
|
||||
signals.push("concurrency".to_string());
|
||||
}
|
||||
if unsafe_sig {
|
||||
signals.push("unsafe".to_string());
|
||||
}
|
||||
if fp {
|
||||
signals.push("floating-point".to_string());
|
||||
}
|
||||
if fp_tolerance {
|
||||
signals.push("fp-tolerance-equivalence".to_string());
|
||||
}
|
||||
signals
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn concurrency_tokens_trigger() {
|
||||
let diff = "--- a/src/lib.rs\n+++ b/src/lib.rs\n+ let m = Mutex::new(0);\n";
|
||||
assert_eq!(classify(diff, false), vec!["concurrency"]);
|
||||
let diff = "- pthread_create(&t, NULL, worker, NULL);\n";
|
||||
assert_eq!(classify(diff, false), vec!["concurrency"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_tokens_trigger() {
|
||||
let diff = "+ unsafe { ptr.read() }\n";
|
||||
assert_eq!(classify(diff, false), vec!["unsafe"]);
|
||||
let diff = "+ let v: Vec<u8> = Vec::from_raw_parts(p, n, n);\n";
|
||||
assert_eq!(classify(diff, false), vec!["unsafe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_point_tokens_trigger_with_boundaries() {
|
||||
assert_eq!(
|
||||
classify("+ let x: f64 = 1.0;\n", false),
|
||||
vec!["floating-point"]
|
||||
);
|
||||
// Literal suffixes have a digit neighbor and must still trigger.
|
||||
assert_eq!(
|
||||
classify("+ sum += 1.0f32;\n", false),
|
||||
vec!["floating-point"]
|
||||
);
|
||||
assert_eq!(
|
||||
classify("+ double acc = 0.0;\n", false),
|
||||
vec!["floating-point"]
|
||||
);
|
||||
// Glued to identifier letters: not an FP token.
|
||||
assert!(classify("+ let buf32 = [0u8; 4];\n", false).is_empty());
|
||||
assert!(classify("+ hash = \"deadf64beef\";\n", false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp_tolerance_flag_is_its_own_signal() {
|
||||
assert_eq!(classify("", true), vec!["fp-tolerance-equivalence"]);
|
||||
assert_eq!(
|
||||
classify("+ let x: f32 = 0.5;\n", true),
|
||||
vec!["floating-point", "fp-tolerance-equivalence"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_diff_yields_no_signals() {
|
||||
let diff = "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,3 +1,3 @@\n\
|
||||
- let n = items.len();\n+ let n = count;\n let done = true;\n";
|
||||
assert!(classify(diff, false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_changed_lines_are_scanned() {
|
||||
// Tokens on context lines and in file headers must not trigger.
|
||||
let diff = "--- a/src/mutex_wrapper.rs\n+++ b/src/mutex_wrapper.rs\n\
|
||||
@@ -10,4 +10,4 @@\n let guard = lock.mutex();\n\
|
||||
+ let n = count;\n";
|
||||
assert!(classify(diff, false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_are_deduped() {
|
||||
let diff = "+ let a = Mutex::new(0);\n+ let b = RwLock::new(0);\n\
|
||||
+ unsafe { transmute::<u32, f32>(x) };\n";
|
||||
assert_eq!(
|
||||
classify(diff, false),
|
||||
vec!["concurrency", "unsafe", "floating-point"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user