From 070a9f8c7d743751b9967f15bf8fa6b52f88bc8d Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 12:30:55 +0100 Subject: [PATCH 1/2] feat(storage): per-finding hexad emission (issue #33 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-WeakPoint hexad path to persist_assemblyline_report so a batch scan can persist one hexad per finding in addition to the existing aggregate hexad. Subject identity is `finding::::`, chosen for cross-run stability so the upcoming S2 (campaign register-pr) and S3 (query) slices can join on it without diffing JSON. New public surface: - HexadSemantic gains an optional `finding: Option` (additive, skip_serializing_if = none → existing consumers unaffected). - FindingSemantic carries finding_id / repo / file / line / category / rule_id / rule_name / severity / description / first_seen_run / last_seen_run / framework. rule_id and rule_name reuse the canonical SARIF mapping (sarif.rs::rule_id / rule_name now pub(crate)). - build_finding_hexads(report) -> Vec. - STORE_FINDING_HEXADS_ENV = "PANIC_ATTACK_STORE_FINDING_HEXADS" — when set non-empty AND StorageMode::VerisimDb is configured, persist_assemblyline_report writes one file per finding under `/hexads/findings/`. Behaviour preserved: - Default path unchanged (env var off → no per-finding writes). - Aggregate hexad still emitted in every VerisimDb run. - Suppressed WeakPoints are skipped, keeping the store aligned with fleet/CI counts. S1 sets first_seen_run == last_seen_run; back-stamping from a prior hexad is S2's job (per the issue), not S1's. Tests: 7 new (id stability, category discrimination, count per WP, suppression skip, canonical rule_id/name, file write + round-trip, env-var default-off). Full suite: 215 lib + 13 + 16 + 6 + 12 + 3 + 7 + 12 + 14 + 20 + 10 + 8 + 22 + 22 + 12 + 2 doc — all green. Clippy clean with -D warnings. Refs #33. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/report/sarif.rs | 4 +- src/storage/mod.rs | 402 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 395 insertions(+), 11 deletions(-) diff --git a/src/report/sarif.rs b/src/report/sarif.rs index db7ca42..cd67a6d 100644 --- a/src/report/sarif.rs +++ b/src/report/sarif.rs @@ -112,7 +112,7 @@ pub struct SarifRegion { } /// Map WeakPointCategory to a stable rule ID -fn rule_id(category: &WeakPointCategory) -> &'static str { +pub(crate) fn rule_id(category: &WeakPointCategory) -> &'static str { match category { WeakPointCategory::UncheckedAllocation => "PA001", WeakPointCategory::UnboundedAllocation => "PA001b", @@ -144,7 +144,7 @@ fn rule_id(category: &WeakPointCategory) -> &'static str { } /// Map WeakPointCategory to a human-readable name -fn rule_name(category: &WeakPointCategory) -> &'static str { +pub(crate) fn rule_name(category: &WeakPointCategory) -> &'static str { match category { WeakPointCategory::UncheckedAllocation => "unchecked-allocation", WeakPointCategory::UnboundedAllocation => "unbounded-allocation", diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 91f0d02..4c43d08 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -88,6 +88,51 @@ pub struct HexadSemantic { /// Migration-specific semantic data (present when target is ReScript) #[serde(skip_serializing_if = "Option::is_none")] pub migration: Option, + /// Finding-level semantic data (present when this hexad represents a + /// single WeakPoint emitted by `build_finding_hexads`, issue #33 S1). + #[serde(skip_serializing_if = "Option::is_none")] + pub finding: Option, +} + +/// Semantic facets of a per-finding hexad (issue #33 S1). +/// +/// A per-finding hexad represents one `WeakPoint` from an assemblyline scan +/// of one repository. The `finding_id` is stable across runs (same +/// repo/file/line/category → same id), so subsequent slices (S2 PR-state +/// tracking, S3 cross-repo query) can identify a finding without comparing +/// JSON blobs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindingSemantic { + /// Stable per-finding identifier: `finding::::`. + pub finding_id: String, + /// Repository name (basename of repo path). + pub repo_name: String, + /// File path, repo-relative. + pub file: String, + /// Line number from the original `WeakPoint`, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + /// `WeakPointCategory` Debug name (e.g. "UnsafeCode"). + pub category: String, + /// Stable rule ID (e.g. "PA004"). Mirrors the SARIF rule mapping. + pub rule_id: String, + /// Human-readable rule slug (e.g. "unsafe-code"). Mirrors SARIF. + pub rule_name: String, + /// Severity label (lowercase: "critical", "high", "medium", "low"). + pub severity: String, + /// Per-finding description from the `WeakPoint`. + pub description: String, + /// Run id of the *current* run (also written to `last_seen_run`). + /// + /// S1 sets `first_seen_run == last_seen_run`. A later slice (S2 or a + /// query-side aggregation in S3) is responsible for back-stamping + /// `first_seen_run` from a prior hexad with the same `finding_id`. + pub first_seen_run: String, + /// Run id of the run that emitted this hexad. + pub last_seen_run: String, + /// Framework hint, when derivable. Reserved for future enrichment. + #[serde(skip_serializing_if = "Option::is_none")] + pub framework: Option, } /// Migration-specific semantic data for VeriSimDB hexads @@ -175,6 +220,7 @@ fn build_hexad(report: &AssaultReport) -> Result { robustness_score: report.overall_assessment.robustness_score, categories, migration, + finding: None, }, document, }) @@ -296,11 +342,176 @@ fn build_assemblyline_hexad( robustness_score: 0.0, categories, migration: None, + finding: None, }, document, }) } +/// Env var that opts a run into per-finding hexad emission (issue #33 S1). +/// +/// When set to a non-empty value AND `StorageMode::VerisimDb` is configured, +/// `persist_assemblyline_report` writes one hexad per `WeakPoint` under +/// `/hexads/findings/` in addition to the existing aggregate hexad. +pub const STORE_FINDING_HEXADS_ENV: &str = "PANIC_ATTACK_STORE_FINDING_HEXADS"; + +/// Return `true` when per-finding hexad emission is requested via env var. +fn finding_hexads_enabled() -> bool { + std::env::var(STORE_FINDING_HEXADS_ENV) + .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false) +} + +/// Build the stable finding-id for a `WeakPoint`. +/// +/// Pattern: `finding::::` — chosen so that two +/// scans of the same repo see the same id for the same finding, which is +/// the property S2 (`campaign register-pr`) and S3 (`query`) need. +/// +/// File and line components fall back to literal `"unknown"` / `"0"` when +/// the underlying `WeakPoint` lacks them, so the id is always well-formed. +fn build_finding_id(repo_name: &str, wp: &crate::types::WeakPoint) -> String { + let file = wp + .file + .clone() + .or_else(|| wp.location.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let line = wp + .line + .map(|n| n.to_string()) + .unwrap_or_else(|| "0".to_string()); + format!("finding:{}:{}:{}:{:?}", repo_name, file, line, wp.category) +} + +/// Map `Severity` to a lowercase string label. +fn severity_label(severity: &crate::types::Severity) -> &'static str { + match severity { + crate::types::Severity::Critical => "critical", + crate::types::Severity::High => "high", + crate::types::Severity::Medium => "medium", + crate::types::Severity::Low => "low", + } +} + +/// Build one hexad per `WeakPoint` across all repo results in an +/// assemblyline report (issue #33 S1). +/// +/// Subject identity lives in `semantic.finding.finding_id`; each emitted +/// hexad's top-level `id` remains per-run-unique so two runs of the same +/// finding produce two distinct hexad files (the join key is the +/// `finding_id`, not the hexad id). +/// +/// `run_id` is shared across every finding-hexad in this run and stamped +/// into both `first_seen_run` and `last_seen_run` (S1 has no prior-run +/// lookup; that's a follow-up slice's job). +pub fn build_finding_hexads( + report: &crate::assemblyline::AssemblylineReport, +) -> Result> { + let now = Utc::now(); + let run_id = format!( + "pa-asmline-{}-{}", + now.format("%Y%m%d%H%M%S"), + &uuid_from_timestamp(now.timestamp_millis()) + ); + + let mut hexads = Vec::new(); + for (repo_idx, result) in report.results.iter().enumerate() { + let Some(assail_report) = &result.report else { + continue; + }; + let language = format!("{:?}", assail_report.language); + + for (wp_idx, wp) in assail_report.weak_points.iter().enumerate() { + // Skip suppressed findings — they're audit-only, not lifecycle + // material. Keeps the hexad store aligned with fleet/CI counts. + if wp.suppressed { + continue; + } + + let finding_id = build_finding_id(&result.repo_name, wp); + let category_str = format!("{:?}", wp.category); + let rule_id_str = crate::report::sarif::rule_id(&wp.category).to_string(); + let rule_name_str = crate::report::sarif::rule_name(&wp.category).to_string(); + let severity_str = severity_label(&wp.severity).to_string(); + + // Per-hexad id: pa-finding----. + // Repo/wp indices keep collision-free even within a millisecond. + let hexad_id = format!( + "pa-finding-{}-{}-{}-{}", + now.format("%Y%m%d%H%M%S"), + repo_idx, + wp_idx, + &uuid_from_timestamp(now.timestamp_millis()), + ); + + let document = serde_json::json!({ + "finding_id": finding_id, + "repo_name": result.repo_name, + "repo_path": result.repo_path.display().to_string(), + "weak_point": wp, + }); + + hexads.push(PanicAttackHexad { + schema: "verisimdb.hexad.v1".to_string(), + id: hexad_id, + created_at: now.to_rfc3339(), + provenance: HexadProvenance { + tool: "panic-attack".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + program_path: result.repo_path.display().to_string(), + language: language.clone(), + attestation_hash: None, + }, + semantic: HexadSemantic { + total_weak_points: 1, + critical_count: matches!(wp.severity, crate::types::Severity::Critical) + as usize, + high_count: matches!(wp.severity, crate::types::Severity::High) as usize, + total_crashes: 0, + robustness_score: 0.0, + categories: vec![category_str.clone()], + migration: None, + finding: Some(FindingSemantic { + finding_id: finding_id.clone(), + repo_name: result.repo_name.clone(), + file: wp + .file + .clone() + .or_else(|| wp.location.clone()) + .unwrap_or_else(|| "unknown".to_string()), + line: wp.line, + category: category_str, + rule_id: rule_id_str, + rule_name: rule_name_str, + severity: severity_str, + description: wp.description.clone(), + first_seen_run: run_id.clone(), + last_seen_run: run_id.clone(), + framework: None, + }), + }, + document, + }); + } + } + + Ok(hexads) +} + +/// Write a slice of hexads under `/hexads/findings/` (one file +/// per hexad). Returns the paths written. +fn write_finding_hexads(hexads: &[PanicAttackHexad], base_dir: &Path) -> Result> { + let dir = base_dir.join("hexads").join("findings"); + fs::create_dir_all(&dir)?; + let mut written = Vec::with_capacity(hexads.len()); + for hexad in hexads { + let path = dir.join(format!("{}.json", hexad.id)); + fs::write(&path, serde_json::to_string_pretty(hexad)?)?; + written.push(path); + } + Ok(written) +} + /// Persist an assemblyline report to storage (filesystem and/or verisimdb). /// /// This is the batch-scan counterpart to `persist_report()` — it stores @@ -327,19 +538,16 @@ pub fn persist_assemblyline_report( if modes.contains(&StorageMode::VerisimDb) { let hexad = build_assemblyline_hexad(report)?; + let base_dir = directory + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("verisimdb-data")); #[cfg(feature = "http")] { if std::env::var("VERISIMDB_URL").is_ok() { - let base_dir = directory - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("verisimdb-data")); let mut http_paths = push_hexad_with_fallback(&hexad, &base_dir)?; stored.append(&mut http_paths); } else { - let base_dir = directory - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("verisimdb-data")); let hexad_dir = base_dir.join("hexads"); fs::create_dir_all(&hexad_dir)?; let path = hexad_dir.join(format!("{}.json", hexad.id)); @@ -349,15 +557,21 @@ pub fn persist_assemblyline_report( } #[cfg(not(feature = "http"))] { - let base_dir = directory - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("verisimdb-data")); let hexad_dir = base_dir.join("hexads"); fs::create_dir_all(&hexad_dir)?; let path = hexad_dir.join(format!("{}.json", hexad.id)); fs::write(&path, serde_json::to_string_pretty(&hexad)?)?; stored.push(path); } + + // Per-finding hexads (issue #33 S1) — additive, env-var gated, and + // always file-side for now. HTTP push for finding hexads is left + // to S3/query path so we don't add chattiness to the API mid-S1. + if finding_hexads_enabled() { + let finding_hexads = build_finding_hexads(report)?; + let mut paths = write_finding_hexads(&finding_hexads, &base_dir)?; + stored.append(&mut paths); + } } Ok(stored) @@ -774,4 +988,174 @@ mod tests { assert_eq!("disk".parse::(), Ok(StorageMode::Filesystem)); assert_eq!("bogus".parse::(), Err(())); } + + // ----- Issue #33 S1: per-finding hexad tests ----------------------- + + use crate::assemblyline::{AssemblylineReport, RepoResult}; + use crate::types::{ + AssailReport, Language, ProgramStatistics, Severity, WeakPoint, WeakPointCategory, + }; + use std::path::PathBuf; + + fn sample_weak_point(file: &str, line: u32, category: WeakPointCategory) -> WeakPoint { + WeakPoint { + category, + location: Some(format!("{}:{}", file, line)), + file: Some(file.to_string()), + line: Some(line), + severity: Severity::High, + description: format!("test finding at {}:{}", file, line), + recommended_attack: Vec::new(), + suppressed: false, + } + } + + fn sample_assemblyline(repo: &str, wps: Vec) -> AssemblylineReport { + let assail = AssailReport { + schema_version: "2.5".to_string(), + program_path: PathBuf::from(format!("/tmp/{}", repo)), + language: Language::Rust, + frameworks: Vec::new(), + weak_points: wps, + statistics: ProgramStatistics::default(), + file_statistics: Vec::new(), + recommended_attacks: Vec::new(), + dependency_graph: Default::default(), + taint_matrix: Default::default(), + migration_metrics: None, + suppressed_count: 0, + }; + AssemblylineReport { + schema_version: "2.5".to_string(), + created_at: "2026-05-26T00:00:00Z".to_string(), + directory: PathBuf::from("/tmp"), + repos_scanned: 1, + repos_with_findings: 1, + repos_skipped: 0, + total_weak_points: assail.weak_points.len(), + total_critical: 0, + results: vec![RepoResult { + repo_path: PathBuf::from(format!("/tmp/{}", repo)), + repo_name: repo.to_string(), + weak_point_count: assail.weak_points.len(), + critical_count: 0, + high_count: assail.weak_points.len(), + total_files: 1, + total_lines: 10, + error: None, + fingerprint: None, + report: Some(assail), + }], + } + } + + #[test] + fn build_finding_id_stable_per_finding() { + let wp = sample_weak_point("src/main.rs", 42, WeakPointCategory::UnsafeCode); + let id_1 = build_finding_id("foo", &wp); + let id_2 = build_finding_id("foo", &wp); + assert_eq!(id_1, id_2); + assert_eq!(id_1, "finding:foo:src/main.rs:42:UnsafeCode"); + } + + #[test] + fn build_finding_id_differs_by_category() { + let wp1 = sample_weak_point("src/main.rs", 42, WeakPointCategory::UnsafeCode); + let wp2 = sample_weak_point("src/main.rs", 42, WeakPointCategory::PanicPath); + assert_ne!(build_finding_id("foo", &wp1), build_finding_id("foo", &wp2)); + } + + #[test] + fn build_finding_hexads_emits_one_per_weak_point() { + let report = sample_assemblyline( + "demo", + vec![ + sample_weak_point("src/a.rs", 1, WeakPointCategory::UnsafeCode), + sample_weak_point("src/b.rs", 7, WeakPointCategory::PanicPath), + sample_weak_point("src/c.rs", 9, WeakPointCategory::CommandInjection), + ], + ); + let hexads = build_finding_hexads(&report).expect("build ok"); + assert_eq!(hexads.len(), 3); + for h in &hexads { + let f = h + .semantic + .finding + .as_ref() + .expect("each per-finding hexad must carry FindingSemantic"); + assert!(f.finding_id.starts_with("finding:demo:")); + assert_eq!(f.repo_name, "demo"); + assert_eq!(f.severity, "high"); + assert!(!f.rule_id.is_empty()); + assert_eq!(f.first_seen_run, f.last_seen_run); + } + } + + #[test] + fn build_finding_hexads_skips_suppressed() { + let mut suppressed = sample_weak_point("src/a.rs", 1, WeakPointCategory::UnsafeCode); + suppressed.suppressed = true; + let report = sample_assemblyline( + "demo", + vec![ + suppressed, + sample_weak_point("src/b.rs", 2, WeakPointCategory::PanicPath), + ], + ); + let hexads = build_finding_hexads(&report).expect("build ok"); + assert_eq!(hexads.len(), 1); + assert_eq!( + hexads[0].semantic.finding.as_ref().unwrap().category, + "PanicPath" + ); + } + + #[test] + fn build_finding_hexads_uses_canonical_rule_ids() { + let report = sample_assemblyline( + "demo", + vec![sample_weak_point( + "src/x.rs", + 3, + WeakPointCategory::UnsafeCode, + )], + ); + let hexads = build_finding_hexads(&report).expect("build ok"); + let f = hexads[0].semantic.finding.as_ref().unwrap(); + assert_eq!(f.rule_id, "PA004"); + assert_eq!(f.rule_name, "unsafe-code"); + } + + #[test] + fn write_finding_hexads_writes_one_file_per_hexad() { + let dir = tempfile::tempdir().expect("tempdir"); + let report = sample_assemblyline( + "demo", + vec![ + sample_weak_point("src/a.rs", 1, WeakPointCategory::UnsafeCode), + sample_weak_point("src/b.rs", 2, WeakPointCategory::PanicPath), + ], + ); + let hexads = build_finding_hexads(&report).expect("build ok"); + let paths = write_finding_hexads(&hexads, dir.path()).expect("write ok"); + assert_eq!(paths.len(), 2); + for p in &paths { + assert!(p.exists()); + // sanity: parses back as a hexad + let content = std::fs::read_to_string(p).unwrap(); + let parsed: PanicAttackHexad = serde_json::from_str(&content).unwrap(); + assert!(parsed.semantic.finding.is_some()); + } + } + + #[test] + fn finding_hexads_disabled_by_default() { + // Snapshot+restore so we don't trample on parallel-test global state. + let original = std::env::var(STORE_FINDING_HEXADS_ENV).ok(); + std::env::remove_var(STORE_FINDING_HEXADS_ENV); + assert!(!finding_hexads_enabled()); + if let Some(v) = original { + std::env::set_var(STORE_FINDING_HEXADS_ENV, v); + } + } } From cc7d25d299f62392c6bd23791969602f5613b443 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 14:20:14 +0100 Subject: [PATCH 2/2] fix(storage): env var alone now opts run into VerisimDb mode (issue #33 prerequisite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recon flagged that PANIC_ATTACK_STORE_FINDING_HEXADS=1 was dead without a manifest configuring reports.storage-targets=verisimdb. The env check sat inside the VerisimDb arm of persist_assemblyline_report, but storage_modes() defaulted to [Filesystem] only — so the operational opt-in path was unreachable without a fully-populated 0-AI-MANIFEST.a2ml. Add resolve_storage_modes() that augments declared modes with VerisimDb when the env var is truthy. Wire it at the single binding site in main.rs. Smoke-verified end-to-end: assemblyline scan against a tiny multi-repo dir now emits 5 per-finding hexads under hexads/findings/ from env var alone. 3 new tests + 1 existing finding_hexads_disabled_by_default test now share a Mutex to serialize their env-var mutations under cargo's parallel runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.rs | 4 +-- src/storage/mod.rs | 72 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index b31e0f4..c73174b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ use crate::attack::AttackProfile; use crate::axial::{AxialConfig, ExecutionCommand as AxialExecutionCommand}; use crate::i18n::Lang; use crate::report::{format_diff, load_report, ReportOutputFormat, ReportTui, ReportView}; -use crate::storage::{latest_reports, persist_report}; +use crate::storage::{latest_reports, persist_report, resolve_storage_modes}; use anyhow::{anyhow, Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; @@ -1110,7 +1110,7 @@ fn run_main() -> Result<()> { Manifest::default() } }; - let storage_modes = manifest.storage_modes(); + let storage_modes = resolve_storage_modes(manifest.storage_modes()); let manifest_formats = manifest.report_formats(); match cli.command { diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 4c43d08..9f94dfb 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -350,18 +350,37 @@ fn build_assemblyline_hexad( /// Env var that opts a run into per-finding hexad emission (issue #33 S1). /// -/// When set to a non-empty value AND `StorageMode::VerisimDb` is configured, -/// `persist_assemblyline_report` writes one hexad per `WeakPoint` under +/// When set to a non-empty truthy value, the run opts into `StorageMode::VerisimDb` +/// even without an `0-AI-MANIFEST.a2ml` configuring `reports.storage-targets`. +/// `persist_assemblyline_report` then writes one hexad per `WeakPoint` under /// `/hexads/findings/` in addition to the existing aggregate hexad. +/// +/// Truthy = any non-empty value other than `0` / `false` (case-insensitive). pub const STORE_FINDING_HEXADS_ENV: &str = "PANIC_ATTACK_STORE_FINDING_HEXADS"; /// Return `true` when per-finding hexad emission is requested via env var. -fn finding_hexads_enabled() -> bool { +pub fn finding_hexads_enabled() -> bool { std::env::var(STORE_FINDING_HEXADS_ENV) .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")) .unwrap_or(false) } +/// Augment a `storage_modes` list with `StorageMode::VerisimDb` when +/// `PANIC_ATTACK_STORE_FINDING_HEXADS` is truthy. +/// +/// This is the operational opt-in path for issue #33 S1–S3: the env var alone +/// is sufficient to reach the hexad-write path, without requiring a manifest's +/// `reports.storage-targets` to include `verisimdb`. +pub fn resolve_storage_modes(declared: Vec) -> Vec { + if finding_hexads_enabled() && !declared.contains(&StorageMode::VerisimDb) { + let mut out = declared; + out.push(StorageMode::VerisimDb); + out + } else { + declared + } +} + /// Build the stable finding-id for a `WeakPoint`. /// /// Pattern: `finding::::` — chosen so that two @@ -996,6 +1015,12 @@ mod tests { AssailReport, Language, ProgramStatistics, Severity, WeakPoint, WeakPointCategory, }; use std::path::PathBuf; + use std::sync::Mutex; + + /// Serialize env-var tests so cargo's parallel runner can't race on + /// `PANIC_ATTACK_STORE_FINDING_HEXADS`. Each test acquires this lock for + /// its full body. + static ENV_LOCK: Mutex<()> = Mutex::new(()); fn sample_weak_point(file: &str, line: u32, category: WeakPointCategory) -> WeakPoint { WeakPoint { @@ -1150,7 +1175,7 @@ mod tests { #[test] fn finding_hexads_disabled_by_default() { - // Snapshot+restore so we don't trample on parallel-test global state. + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let original = std::env::var(STORE_FINDING_HEXADS_ENV).ok(); std::env::remove_var(STORE_FINDING_HEXADS_ENV); assert!(!finding_hexads_enabled()); @@ -1158,4 +1183,43 @@ mod tests { std::env::set_var(STORE_FINDING_HEXADS_ENV, v); } } + + #[test] + fn resolve_storage_modes_passthrough_when_env_unset() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let original = std::env::var(STORE_FINDING_HEXADS_ENV).ok(); + std::env::remove_var(STORE_FINDING_HEXADS_ENV); + let modes = resolve_storage_modes(vec![StorageMode::Filesystem]); + assert_eq!(modes, vec![StorageMode::Filesystem]); + if let Some(v) = original { + std::env::set_var(STORE_FINDING_HEXADS_ENV, v); + } + } + + #[test] + fn resolve_storage_modes_adds_verisimdb_when_env_set() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let original = std::env::var(STORE_FINDING_HEXADS_ENV).ok(); + std::env::set_var(STORE_FINDING_HEXADS_ENV, "1"); + let modes = resolve_storage_modes(vec![StorageMode::Filesystem]); + assert!(modes.contains(&StorageMode::Filesystem)); + assert!(modes.contains(&StorageMode::VerisimDb)); + std::env::remove_var(STORE_FINDING_HEXADS_ENV); + if let Some(v) = original { + std::env::set_var(STORE_FINDING_HEXADS_ENV, v); + } + } + + #[test] + fn resolve_storage_modes_idempotent_when_verisimdb_already_present() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let original = std::env::var(STORE_FINDING_HEXADS_ENV).ok(); + std::env::set_var(STORE_FINDING_HEXADS_ENV, "1"); + let modes = resolve_storage_modes(vec![StorageMode::VerisimDb]); + assert_eq!(modes, vec![StorageMode::VerisimDb]); + std::env::remove_var(STORE_FINDING_HEXADS_ENV); + if let Some(v) = original { + std::env::set_var(STORE_FINDING_HEXADS_ENV, v); + } + } }