From e78ac29fdfb0521de9c58b811dd8eec0bcf5e612 Mon Sep 17 00:00:00 2001 From: Ivo Matijasevic Date: Wed, 12 Aug 2026 23:39:27 +0200 Subject: [PATCH 1/5] export-json: don't report failed, skipped, or zero-match runs as complete Close several ways a run that did not finish cleanly could still serialize as a clean, completed export: - A run reusing an --export-json path that died before the final write (compile error, OOM, Ctrl-C, or a harness-level error propagating out) left the *previous* run's complete-looking file at the target. verify_project now writes a run_state:"incomplete" marker to the target before verification starts, so a stale file can never be read as this run's result; the marker is promoted to "complete" only when the run finished. - A --harness filter that matched nothing wrote a completed / 0-failed document (the "no harnesses matched" error is only raised after the export). A zero-match run is now marked run_state:"no_harnesses_selected", and a harness_selection block records the requested filters, the matched count, and any unmatched filters. - Under --fail-fast, harnesses skipped after the first failure kept their harness_metadata entry but had no error_details/property_details entry, so a consumer correlating the arrays could read absence as success. Every selected harness now gets an explicit entry, and the terminal run_state is "complete" only when every selected harness produced a result, "partial" otherwise. - Per-harness details were joined to results on pretty_name, which two harnesses in different crates of a workspace can share, misattributing a result. The join now uses the unique mangled_name. Follow-up to #4472. --- kani-driver/src/frontend/schema_utils.rs | 60 ++++++++++++++++++++-- kani-driver/src/main.rs | 63 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/kani-driver/src/frontend/schema_utils.rs b/kani-driver/src/frontend/schema_utils.rs index 2984f640b00..88bbfb1fd9e 100644 --- a/kani-driver/src/frontend/schema_utils.rs +++ b/kani-driver/src/frontend/schema_utils.rs @@ -177,8 +177,9 @@ impl PropertyCounts { }) } - /// The same shape, for a harness whose properties were never measured. - fn unmeasured_json() -> Value { + /// The same shape, for a harness whose properties were never measured, with a caller-supplied + /// explanation of why (e.g. a CBMC failure vs. never having run at all). + fn unmeasured_json_with_reason(reason: &str) -> Value { json!({ "total_properties": null, "passed": null, @@ -190,9 +191,16 @@ impl PropertyCounts { "unsatisfiable": null, "covered": null, "uncovered": null, - "error": "Could not extract property details due to verification failure" + "error": reason }) } + + /// The same shape, for a harness whose properties were never measured. + fn unmeasured_json() -> Value { + Self::unmeasured_json_with_reason( + "Could not extract property details due to verification failure", + ) + } } /// Creates structured JSON metadata for the project @@ -326,7 +334,12 @@ pub fn process_harness_results( ) -> Result<()> { // The main verification results are handled by the harness runner for h in harnesses { - let harness_result = results.iter().find(|r| r.harness.pretty_name == h.pretty_name); + // Joined on `mangled_name`, the unique identifier `harness_metadata` already carries, + // rather than `pretty_name`: two harnesses in different crates of the same workspace can + // share a `pretty_name`, and joining on that would attribute a result -- including a + // failure -- to the wrong harness. `harness_id` in the emitted JSON is unchanged; only + // the join predicate used to find the matching result moves to the unique key. + let harness_result = results.iter().find(|r| r.harness.mangled_name == h.mangled_name); // Add error details for this harness. This accumulates one entry per harness, keyed by // `harness_id`, the same way the `cbmc` array does: a single top-level object would let a @@ -377,6 +390,40 @@ pub fn process_harness_results( } }), ); + } else { + // This harness was selected (it has a `harness_metadata` entry) but has no entry in + // `results`. That is not always "never ran": under `--fail-fast`, + // `check_all_harnesses` collects harness futures into a single `Result>`, and + // as soon as one harness fails, the whole collection short-circuits on that `Err` -- + // discarding the `Ok` results of any other harness that had already completed + // (including a pass) but lost the race to be collected before the failure. So a + // harness landing in this branch may have genuinely been skipped, or may have run + // and even passed, with its result simply not retained. Without this branch the + // harness would be silently absent from both `error_details` and `property_details`, + // which a consumer correlating those arrays against `harness_metadata` (or checking + // "every detail entry is a Success") could easily misread as "nothing wrong with it". + // "skipped"/"not_run" would overclaim the former case for certain, so this reports + // the honest, disjunctive truth instead. + handler.add_harness_detail( + "error_details", + json!({ + "harness_id": h.pretty_name, + "has_errors": true, + "error_type": "not_reported", + "exit_status": "unknown" + }), + ); + + handler.add_harness_detail( + "property_details", + json!({ + "harness_id": h.pretty_name, + "property_details": PropertyCounts::unmeasured_json_with_reason( + "No result was reported for this harness (e.g. skipped after \ + --fail-fast, or a completed result not retained)." + ) + }), + ); } } @@ -468,7 +515,10 @@ pub fn process_cbmc_results( ) -> Result<()> { let cbmc_info_opt = session.get_cbmc_info().ok(); for h in harnesses { - let harness_result = results.iter().find(|r| r.harness.pretty_name == h.pretty_name); + // See the matching comment in `process_harness_results`: join on the unique + // `mangled_name` rather than `pretty_name`, which two harnesses in different crates of a + // workspace can share. + let harness_result = results.iter().find(|r| r.harness.mangled_name == h.mangled_name); handler.add_harness_detail("cbmc", json!({ // basic name for harnesses "harness_id": h.pretty_name, diff --git a/kani-driver/src/main.rs b/kani-driver/src/main.rs index 22aad350eb4..1754b5602a8 100644 --- a/kani-driver/src/main.rs +++ b/kani-driver/src/main.rs @@ -147,6 +147,23 @@ fn verify_project(project: Project, session: KaniSession) -> Result<()> { // overhead for every other run, including a `cbmc --version` probe in `process_cbmc_results`. let mut handler = session.args.export_json.as_ref().map(|path| JsonHandler::new(Some(path.clone()))); + + // Invalidate any stale export at the target path immediately, before verification even + // starts. Without this, a run that dies before reaching the final `export()` below (a + // compile error, OOM, Ctrl-C, or a harness-level `Err` that propagates out of this function + // before that export runs) would leave a *previous* run's clean, complete-looking file at + // the target path -- and a consumer that trusts the file would read it as this run's + // result. Writing this marker first means the target can never be read as a stale clean pass + // again: it is either this incomplete marker, a genuinely complete export from this run, or + // absent. `write_sarif`/`print_final_summary` below run *after* the final `export()`, using + // the same `results` the export was built from, so a failure there leaves behind a complete + // export whose verification data is already accurate -- it is not a case this marker needs + // to guard against. + if let Some(handler) = handler.as_mut() { + handler.add_item("run_state", json!("incomplete")); + handler.export()?; + } + let harnesses = session.determine_targets(project.get_all_harnesses())?; debug!(n = harnesses.len(), ?harnesses, "verify_project"); @@ -168,6 +185,41 @@ fn verify_project(project: Project, session: KaniSession) -> Result<()> { for h in &harnesses { handler.add_harness_detail("harness_metadata", create_harness_metadata_json(h)); } + + // Record what was requested and what was actually selected, so a filter typo that + // matches nothing is visible in the export itself rather than only in a log line the + // export's consumer never sees. + let requested_filters = &session.args.harnesses; + let unmatched_filters: Vec<&String> = if session.args.exact { + // `determine_targets` above already returns an error when an `--exact` filter + // matches nothing, so reaching this point means every exact filter matched. + vec![] + } else { + // Mirrors `find_proof_harnesses`'s non-exact matching closely enough to be a useful + // diagnostic (exact and unqualified-name matches are also substring matches of the + // full pretty name), without needing to re-run its full matching logic here. + requested_filters + .iter() + .filter(|filter| !harnesses.iter().any(|h| h.pretty_name.contains(filter.as_str()))) + .collect() + }; + handler.add_item( + "harness_selection", + json!({ + "requested_filters": requested_filters, + "matched_count": harnesses.len(), + "unmatched_filters": unmatched_filters, + }), + ); + + if harnesses.is_empty() { + // A filter that matches zero harnesses must never export as a clean, completed run + // with `successful:0, failed:0` -- that is a vacuous pass, not evidence of anything. + // This overrides the "incomplete" marker written above; the final block below only + // promotes a run to "complete" when at least one harness was actually selected, so + // this state survives to the exported file. + handler.add_item("run_state", json!("no_harnesses_selected")); + } } // Verification @@ -201,6 +253,17 @@ fn verify_project(project: Project, session: KaniSession) -> Result<()> { if let Some(handler) = handler.as_mut() { handler.add_item("coverage", json!({"enabled": session.args.coverage})); + // The terminal `run_state` must be authoritative about whether every selected harness + // actually ran: a zero-match run keeps the `no_harnesses_selected` state set above, and + // -- critically -- a non-empty selection is only "complete" when `results` accounts for + // every selected harness. Under `--fail-fast`, harnesses skipped after the first failure + // never produce a `HarnessResult`, so `results.len() < harnesses.len()`; reporting + // "complete" in that case would say a run that was intentionally aborted early finished + // normally. That case is reported as "partial" instead. + if !harnesses.is_empty() { + let run_state = if results.len() == harnesses.len() { "complete" } else { "partial" }; + handler.add_item("run_state", json!(run_state)); + } handler.export()?; } From e17eff28432374c0aeb9fcce3b4c6020ca545d1c Mon Sep 17 00:00:00 2001 From: Ivo Matijasevic Date: Wed, 12 Aug 2026 23:39:27 +0200 Subject: [PATCH 2/5] export-json: cbmc_stats parsing is non-destructive, unit-safe, and overflow-safe The scraped CBMC statistics could be silently wrong: - A later status message that matched a recognized label but failed to parse overwrote an already-recorded valid value with null (indistinguishable from "not measured"). Parsing now only assigns on success and never clobbers a valid value. - Exact-suffix matching turned any harmless CBMC wording change into a silent null. Counts now parse the leading numeric token, tolerating trailing text. - record_seconds extracted the leading number regardless of unit, so a duration reported as "5ms" would be recorded as 5 seconds. It now accepts a value only when the unit is exactly "s"; any other unit is a parse failure, never a mis-scaled number. - The integer count fields were u32; a large enough run overflowed to null. Widened to u64. Follow-up to #4472. --- kani-driver/src/call_cbmc.rs | 135 +++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 14 deletions(-) diff --git a/kani-driver/src/call_cbmc.rs b/kani-driver/src/call_cbmc.rs index dc4ae72b2db..d1cc218c3f2 100644 --- a/kani-driver/src/call_cbmc.rs +++ b/kani-driver/src/call_cbmc.rs @@ -37,10 +37,14 @@ pub struct CbmcInfo { #[derive(Debug, Clone, Default)] pub struct CbmcStats { pub runtime_symex_s: Option, - pub size_program_expression: Option, - pub slicing_removed_assignments: Option, - pub vccs_generated: Option, - pub vccs_remaining: Option, + // `u64`, not `u32`: these are unbounded counts scraped from CBMC's own text output (program + // expression size, VCCs, sliced assignments), and a sufficiently large real run overflowing + // `u32` used to collapse silently to `null` via `parse::().ok()` -- indistinguishable + // from "not measured". + pub size_program_expression: Option, + pub slicing_removed_assignments: Option, + pub vccs_generated: Option, + pub vccs_remaining: Option, pub runtime_postprocess_equation_s: Option, pub runtime_convert_ssa_s: Option, pub runtime_post_process_s: Option, @@ -107,6 +111,12 @@ fn merge_cbmc_stats(items: &[ParserItem]) -> Option { /// Record the statistic a single CBMC status message carries, if it carries one. Later messages win, /// matching CBMC's own behaviour of reporting a running figure more than once. /// Returns whether this message was recognized. +/// +/// Every field assignment below goes through [`record_stat`], which only overwrites a field when +/// parsing succeeds. Without that, a later message that merely *resembles* a recognized label but +/// fails to parse (a wording tweak, an unexpected unit, a truncated line) would silently erase an +/// already-recorded valid measurement by assigning it `None` -- indistinguishable from "never +/// measured" to a consumer of the export. fn record_cbmc_stat(message: &str, stats: &mut CbmcStats) -> bool { // "Generated 1 VCC(s), 1 remaining after simplification" if let Some(counts) = message @@ -114,9 +124,9 @@ fn record_cbmc_stat(message: &str, stats: &mut CbmcStats) -> bool { .and_then(|rest| rest.strip_suffix(" remaining after simplification")) && let Some((generated, remaining)) = counts.split_once(" VCC(s), ") { - stats.vccs_generated = generated.parse().ok(); - stats.vccs_remaining = remaining.parse().ok(); - return stats.vccs_generated.is_some() || stats.vccs_remaining.is_some(); + let generated_ok = record_stat(&mut stats.vccs_generated, parse_leading_number(generated)); + let remaining_ok = record_stat(&mut stats.vccs_remaining, parse_leading_number(remaining)); + return generated_ok || remaining_ok; } // "slicing removed 81 assignments", or "simple slicing removed 5 assignments" when only the @@ -126,8 +136,7 @@ fn record_cbmc_stat(message: &str, stats: &mut CbmcStats) -> bool { .strip_prefix("slicing removed ") .or_else(|| rest.strip_prefix("simple slicing removed ")) { - stats.slicing_removed_assignments = count.parse().ok(); - return stats.slicing_removed_assignments.is_some(); + return record_stat(&mut stats.slicing_removed_assignments, parse_leading_number(count)); } // Everything else is reported as "