Skip to main content

secunit_core/evidence/
runner.rs

1//! prepare → (skill executes) → finalize, plus abort and resume.
2//!
3//! All filesystem state changes happen here; the agent only writes data
4//! files into the slots `prepare` carved out. Hash chaining and atomic
5//! writes are handled in `hasher`; concurrency in `lock`.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, bail, Context, Result};
12use chrono::{DateTime, Datelike, NaiveDate, Utc};
13
14use super::hasher::{self, atomic_write, sha256_bytes, sha256_file};
15use super::lock::RootLock;
16use super::manifest::{
17    AgentInfo, Artifact, BySystemBlock, Manifest, PrepareContext, PriorRun, RunOutcome, RunResult,
18    ScopeLayout, SystemOutcome,
19};
20use crate::model::{Cadence, LoadedRegistry, RunStatus, StateEntry};
21use crate::registry::{period, resolver};
22use crate::SCHEMA_VERSION;
23
24const PENDING_SENTINEL: &str = ".run-pending";
25const PREPARE_FILE: &str = "prepare.json";
26const RESULT_FILE: &str = "result.json";
27const MANIFEST_FILE: &str = "manifest.json";
28const STATE_FILE: &str = "state.json";
29
30/// Options for `prepare`. Only `today` is required; the rest are
31/// agent-supplied metadata.
32#[derive(Debug, Clone, Default)]
33pub struct PrepareOpts {
34    pub today: Option<NaiveDate>,
35    pub operator: Option<String>,
36    pub note: Option<String>,
37    pub now: Option<DateTime<Utc>>,
38    /// Operator-supplied period claim. `None` means derive from `today` —
39    /// `period_id` records the calendar period the work was performed in.
40    pub period_id: Option<String>,
41}
42
43/// Allocate a run directory, snapshot scope, write `prepare.json`, drop
44/// the `.run-pending` sentinel, and return the prepare context. Holds
45/// the root lock for the duration so concurrent prepares serialise.
46pub fn prepare(
47    reg: &LoadedRegistry,
48    control_id: &str,
49    opts: &PrepareOpts,
50) -> Result<PrepareContext> {
51    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
52    let ctrl = reg
53        .controls
54        .get(control_id)
55        .ok_or_else(|| anyhow!("control `{control_id}` not found"))?;
56
57    let now = opts.now.unwrap_or_else(Utc::now);
58    let today = opts.today.unwrap_or_else(|| now.date_naive());
59
60    // Refuse to allocate a second pending run for the same control.
61    let existing = list_pending(&reg.root)?;
62    if let Some(p) = existing.iter().find(|r| r.control_id == control_id) {
63        bail!(
64            "pending run already exists for `{}` at {}",
65            control_id,
66            p.run_dir.display()
67        );
68    }
69
70    let resolved = resolver::resolve_scope(ctrl, &reg.inventory, today);
71
72    // Empty scope against a non-org-wide control is the silent-failure
73    // case: allocating a run dir would seal a "successful" manifest with
74    // zero artifacts, advancing the chain as if work happened. Fail
75    // early instead — operator either updates inventory.yaml or retires
76    // the control.
77    if ctrl.scope.is_some() && resolved.is_empty() {
78        let scope_desc = match &ctrl.scope {
79            Some(crate::model::Scope::Inventory(s)) => {
80                format!("kind: {}, has_tags: {:?}", s.kind, s.has_tags)
81            }
82            Some(crate::model::Scope::Inline(_)) => "inline".to_string(),
83            None => unreachable!(),
84        };
85        bail!(
86            "control `{control_id}` has scope ({scope_desc}) but no inventory entries match on {today}. Either update inventory.yaml or retire the control."
87        );
88    }
89
90    // Per storage.md: flat is legal when scope is empty (org-wide) or
91    // resolves to exactly one entry. Default to flat in both cases —
92    // by-system would just nest a lone system under an extra dir.
93    let scope_layout = if ctrl.scope.is_none() || resolved.len() == 1 {
94        ScopeLayout::Flat
95    } else {
96        ScopeLayout::BySystem
97    };
98
99    // Resolve period_id before allocating any disk state so an invalid
100    // --period rejects without leaving a half-formed run dir behind.
101    let period_id = match &opts.period_id {
102        Some(supplied) => {
103            if matches!(ctrl.cadence, Cadence::Continuous) {
104                bail!(
105                    "control `{control_id}` has continuous cadence and does not have schedule periods; --period is not allowed"
106                );
107            }
108            if period::bounds(ctrl.cadence, supplied).is_none() {
109                bail!(
110                    "`{supplied}` is not a valid period id for cadence {:?}",
111                    ctrl.cadence
112                );
113            }
114            Some(supplied.clone())
115        }
116        None => {
117            if matches!(ctrl.cadence, Cadence::Continuous) {
118                None
119            } else {
120                // period_id records the calendar period the work was
121                // done in, so anchor on `today`. Using `next_due` here
122                // would attribute a run on Sat 2026-05-02 (W18) to the
123                // upcoming Mon 2026-05-04 (W19), leaving the current
124                // week stuck Open in coverage.
125                period::derive(ctrl.cadence, today)
126            }
127        }
128    };
129
130    let run_dir = allocate_run_dir(&reg.root, control_id, today)?;
131    if matches!(scope_layout, ScopeLayout::BySystem) {
132        for sys in &resolved {
133            fs::create_dir_all(run_dir.join("by-system").join(&sys.name).join("raw"))?;
134        }
135    } else {
136        fs::create_dir_all(run_dir.join("raw"))?;
137    }
138
139    let registry_git_sha = git_head(&reg.root).with_context(|| {
140        format!(
141            "{} is not a git repository — `cd` into a checked-out registry, or `git init && git commit` if starting fresh",
142            reg.root.display()
143        )
144    })?;
145
146    let ctx = PrepareContext {
147        schema_version: SCHEMA_VERSION,
148        control_id: control_id.to_string(),
149        run_id: run_id_from_dir(&run_dir)?,
150        run_dir: run_dir.clone(),
151        started_at: now,
152        operator: opts.operator.clone(),
153        note: opts.note.clone(),
154        scope_layout,
155        resolved_scope: resolved,
156        registry_git_sha,
157        period_id,
158    };
159
160    let json = serde_json::to_vec_pretty(&ctx)?;
161    atomic_write(&run_dir.join(PREPARE_FILE), &json)?;
162    // Sentinel last — once it exists, the run is officially pending.
163    atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
164    Ok(ctx)
165}
166
167/// Re-emit the prepare context for a pending run. No-op idempotent
168/// helper for resuming after an interrupted agent session.
169pub fn resume(run_dir: &Path) -> Result<PrepareContext> {
170    let path = run_dir.join(PREPARE_FILE);
171    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
172    let ctx: PrepareContext = serde_json::from_slice(&bytes)?;
173    Ok(ctx)
174}
175
176/// Tear down a pending run by sealing a failed manifest. Records the
177/// reason in `manifest.failure_reason` so the audit trail says why.
178/// Leaves any partial evidence on disk under the run dir.
179pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
180    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
181
182    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
183    let ctrl = reg
184        .controls
185        .get(&prepare.control_id)
186        .ok_or_else(|| anyhow!("control `{}` not found", prepare.control_id))?;
187
188    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
189    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
190    let skill_sha256 = sha256_for_skill(&reg.root, &ctrl.skill)?;
191
192    let manifest = Manifest {
193        schema_version: SCHEMA_VERSION,
194        control_id: prepare.control_id.clone(),
195        run_id: prepare.run_id.clone(),
196        started_at: prepare.started_at,
197        completed_at: Utc::now(),
198        operator: prepare.operator.clone(),
199        agent: AgentInfo {
200            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
201            skill: ctrl.skill.clone(),
202            skill_sha256,
203            control_sha256,
204        },
205        registry_git_sha: prepare.registry_git_sha.clone(),
206        scope_layout: prepare.scope_layout,
207        resolved_scope: prepare.resolved_scope.clone(),
208        prior_run,
209        artifacts: Vec::new(),
210        by_system: Vec::new(),
211        status: RunOutcome::Failed,
212        failure_reason: Some(reason.to_string()),
213        draft_risks: Vec::new(),
214        draft_issues: Vec::new(),
215        external_links: Vec::new(),
216        period_id: prepare.period_id.clone(),
217    };
218
219    let bytes = serde_json::to_vec(&manifest)?;
220    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
221    update_state(reg, &manifest)?;
222
223    let pending = run_dir.join(PENDING_SENTINEL);
224    if pending.exists() {
225        fs::remove_file(&pending)?;
226    }
227    Ok(manifest)
228}
229
230/// Hash every artifact, link the manifest to the prior run, atomically
231/// write `manifest.json`, update `state.json`, and remove the pending
232/// sentinel. Returns the sealed manifest.
233pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
234    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
235
236    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
237    let result: RunResult = read_json(&run_dir.join(RESULT_FILE))?;
238    if prepare.control_id != result.control_id || prepare.run_id != result.run_id {
239        bail!(
240            "result.json mismatches prepare.json (prepare={}/{}, result={}/{})",
241            prepare.control_id,
242            prepare.run_id,
243            result.control_id,
244            result.run_id,
245        );
246    }
247
248    // Skip the per-run metadata files when hashing.
249    let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
250    let hashed = hasher::hash_tree(run_dir, &exclude)?;
251
252    let mut artifacts: Vec<Artifact> = Vec::new();
253    let mut by_system_artifacts: BTreeMap<String, Vec<Artifact>> = BTreeMap::new();
254
255    for h in &hashed {
256        let art = Artifact {
257            path: h.path.clone(),
258            sha256: h.sha256.clone(),
259            bytes: h.bytes,
260        };
261        if let Some(sys) = h.path.strip_prefix("by-system/") {
262            // by-system/<name>/raw/<file>...
263            let name = sys.split('/').next().unwrap_or("").to_string();
264            by_system_artifacts.entry(name).or_default().push(art);
265        } else {
266            artifacts.push(art);
267        }
268    }
269
270    let by_system_blocks: Vec<BySystemBlock> = result
271        .by_system
272        .iter()
273        .map(|sr| BySystemBlock {
274            name: sr.name.clone(),
275            status: sr.status,
276            summary: None,
277            artifacts: by_system_artifacts.remove(&sr.name).unwrap_or_default(),
278        })
279        .collect();
280
281    // Anything captured under by-system that wasn't reflected in result.json
282    // becomes its own block too — we never silently drop hashed artifacts.
283    let mut extra_blocks: Vec<BySystemBlock> = by_system_artifacts
284        .into_iter()
285        .map(|(name, arts)| BySystemBlock {
286            name,
287            status: SystemOutcome::Complete,
288            summary: None,
289            artifacts: arts,
290        })
291        .collect();
292    let mut by_system_blocks = by_system_blocks;
293    by_system_blocks.append(&mut extra_blocks);
294    by_system_blocks.sort_by(|a, b| a.name.cmp(&b.name));
295
296    // Find the prior run for this control and link via its manifest sha.
297    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
298
299    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
300    let skill_sha256 = sha256_for_skill(&reg.root, &reg.controls[&prepare.control_id].skill)?;
301
302    let manifest = Manifest {
303        schema_version: SCHEMA_VERSION,
304        control_id: prepare.control_id.clone(),
305        run_id: prepare.run_id.clone(),
306        started_at: prepare.started_at,
307        completed_at: Utc::now(),
308        operator: prepare.operator.clone(),
309        agent: AgentInfo {
310            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
311            skill: reg.controls[&prepare.control_id].skill.clone(),
312            skill_sha256,
313            control_sha256,
314        },
315        registry_git_sha: prepare.registry_git_sha.clone(),
316        scope_layout: prepare.scope_layout,
317        resolved_scope: prepare.resolved_scope.clone(),
318        prior_run,
319        artifacts,
320        by_system: by_system_blocks,
321        status: result.status,
322        failure_reason: None,
323        draft_risks: result.draft_risks.clone(),
324        draft_issues: result.draft_issues.clone(),
325        external_links: result.external_links.clone(),
326        period_id: prepare.period_id.clone(),
327    };
328
329    // Compact canonical JSON (no pretty-printing) so `jq`-style
330    // reformatting cannot silently break the chain hash. Operators who
331    // want to read a manifest pipe it through `jq .` on demand.
332    let bytes = serde_json::to_vec(&manifest)?;
333    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
334
335    update_state(reg, &manifest)?;
336
337    let pending = run_dir.join(PENDING_SENTINEL);
338    if pending.exists() {
339        fs::remove_file(&pending)?;
340    }
341    Ok(manifest)
342}
343
344/// Pending run pointer: control id, run id, and the run dir.
345#[derive(Debug, Clone)]
346pub struct PendingRun {
347    pub control_id: String,
348    pub run_id: String,
349    pub run_dir: PathBuf,
350}
351
352/// Walk `<root>/evidence/` looking for `.run-pending` sentinels. A
353/// sentinel sitting next to a sealed `manifest.json` is treated as
354/// crash-recovery debris from a finalize that died after the manifest
355/// landed but before sentinel removal: it's silently swept rather than
356/// surfaced as a pending run (which would block fresh prepares for that
357/// control).
358pub fn list_pending(root: &Path) -> Result<Vec<PendingRun>> {
359    let mut out = Vec::new();
360    let evidence = root.join("evidence");
361    if !evidence.exists() {
362        return Ok(out);
363    }
364    // Layout depth: evidence/<y>/<q>/<cid>/<rid>/.run-pending = 5 below evidence.
365    for entry in walkdir::WalkDir::new(&evidence).max_depth(5) {
366        let entry = entry?;
367        if entry.file_name() == PENDING_SENTINEL {
368            let run_dir = entry.path().parent().unwrap().to_path_buf();
369            // Crash recovery: sealed manifest + stale sentinel → sweep.
370            if run_dir.join(MANIFEST_FILE).exists() {
371                let _ = fs::remove_file(entry.path());
372                continue;
373            }
374            let prepare_path = run_dir.join(PREPARE_FILE);
375            if prepare_path.exists() {
376                let prepare: PrepareContext = read_json(&prepare_path)?;
377                out.push(PendingRun {
378                    control_id: prepare.control_id,
379                    run_id: prepare.run_id,
380                    run_dir,
381                });
382            }
383        }
384    }
385    Ok(out)
386}
387
388// ---------- helpers --------------------------------------------------------
389
390fn allocate_run_dir(root: &Path, control_id: &str, today: NaiveDate) -> Result<PathBuf> {
391    let q = quarter_label(today);
392    let base = root
393        .join("evidence")
394        .join(today.year().to_string())
395        .join(&q)
396        .join(control_id);
397    fs::create_dir_all(&base)?;
398    let mut n = 1u32;
399    loop {
400        let id = format!("{}-run-{:03}", today, n);
401        let candidate = base.join(&id);
402        if !candidate.exists() {
403            fs::create_dir_all(&candidate)?;
404            return Ok(candidate);
405        }
406        n += 1;
407        if n > 999 {
408            bail!("run-id counter overflowed for {control_id} on {today}");
409        }
410    }
411}
412
413fn quarter_label(d: NaiveDate) -> String {
414    let q = (d.month() - 1) / 3 + 1;
415    format!("q{q}")
416}
417
418fn run_id_from_dir(dir: &Path) -> Result<String> {
419    Ok(dir
420        .file_name()
421        .ok_or_else(|| anyhow!("run dir has no basename"))?
422        .to_string_lossy()
423        .into_owned())
424}
425
426fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
427    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
428    Ok(serde_json::from_slice(&bytes)?)
429}
430
431fn prior_run_link(root: &Path, control_id: &str, current_run_id: &str) -> Result<Option<PriorRun>> {
432    let evidence = root.join("evidence");
433    if !evidence.exists() {
434        return Ok(None);
435    }
436    let mut all_manifests: Vec<(String, PathBuf)> = Vec::new();
437    for entry in walkdir::WalkDir::new(&evidence) {
438        let entry = entry?;
439        if entry.file_name() != MANIFEST_FILE {
440            continue;
441        }
442        let dir = entry.path().parent().unwrap();
443        let id = dir
444            .file_name()
445            .and_then(|s| s.to_str())
446            .unwrap_or("")
447            .to_string();
448        // Restrict to manifests for the same control id.
449        let parent_control = dir
450            .parent()
451            .and_then(|p| p.file_name())
452            .and_then(|s| s.to_str())
453            .unwrap_or("");
454        if parent_control != control_id {
455            continue;
456        }
457        all_manifests.push((id, entry.path().to_path_buf()));
458    }
459    all_manifests.sort_by(|a, b| a.0.cmp(&b.0));
460    let prior = all_manifests
461        .into_iter()
462        .rfind(|(id, _)| id.as_str() < current_run_id);
463    match prior {
464        None => Ok(None),
465        Some((id, path)) => {
466            let sha = sha256_file(&path)?;
467            Ok(Some(PriorRun {
468                run_id: id,
469                manifest_sha256: sha,
470            }))
471        }
472    }
473}
474
475fn sha256_for_control(root: &Path, control_id: &str) -> Result<String> {
476    let path = root.join("controls").join(format!("{control_id}.yaml"));
477    sha256_file(&path).with_context(|| format!("hash control {}", path.display()))
478}
479
480fn sha256_for_skill(root: &Path, skill: &str) -> Result<String> {
481    let path = root.join("skills").join(format!("{skill}.md"));
482    sha256_file(&path).with_context(|| format!("hash skill {}", path.display()))
483}
484
485/// Resolve the registry repo's HEAD commit hex via gix. Errors when
486/// `root` isn't a git repo or HEAD can't be peeled (no commits yet,
487/// detached weirdness, etc) — `prepare` requires a real git sha to
488/// pin what the registry said at run time.
489fn git_head(root: &Path) -> Result<String> {
490    let repo = gix::open(root).context("open repo")?;
491    let head = repo.head().context("read HEAD")?;
492    let id = head.into_peeled_id().context("peel HEAD to a commit")?;
493    Ok(id.to_hex().to_string())
494}
495
496fn update_state(reg: &LoadedRegistry, manifest: &Manifest) -> Result<()> {
497    let path = reg.root.join(STATE_FILE);
498    // Bail loudly on a corrupt state file rather than silently dropping
499    // every prior control's entry by replacing with default. Operators
500    // who genuinely want to reset state.json can remove it; finalize
501    // will then start fresh.
502    let mut state: crate::model::State = if path.exists() {
503        let bytes = fs::read(&path)?;
504        serde_json::from_slice(&bytes).with_context(|| {
505            format!(
506                "{} is corrupt; refusing to overwrite — remove it manually to reset state",
507                path.display()
508            )
509        })?
510    } else {
511        crate::model::State::default()
512    };
513
514    // Compute the next firing date as part of finalize so state.json is
515    // a useful cache for `secunit due` and downstream report skills,
516    // rather than a placeholder readers have to re-derive on every load.
517    // Anchor at "the day after the run's target date" (parsed from the
518    // run-id's YYYY-MM-DD prefix, not wall-clock completion time) so a
519    // weekly control with target Monday returns *next* Monday, not today.
520    let run_date = manifest
521        .run_id
522        .get(0..10)
523        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
524    let next_due = run_date.and_then(|d| {
525        let lookup_from = d + chrono::Duration::days(1);
526        reg.controls.get(&manifest.control_id).and_then(|ctrl| {
527            crate::registry::resolver::next_due(
528                ctrl,
529                &reg.schedule,
530                None,
531                lookup_from,
532                reg.config.weekly_default_weekday,
533            )
534        })
535    });
536
537    state.controls.insert(
538        manifest.control_id.clone(),
539        StateEntry {
540            last_run_id: Some(manifest.run_id.clone()),
541            last_run_path: Some(manifest_relative_path(&reg.root, manifest)),
542            last_run_at: Some(manifest.completed_at),
543            last_status: match manifest.status {
544                RunOutcome::Complete => RunStatus::Complete,
545                RunOutcome::Partial => RunStatus::InProgress,
546                RunOutcome::Failed => RunStatus::Failed,
547            },
548            next_due,
549        },
550    );
551    state.updated_at = Some(Utc::now());
552    let bytes = serde_json::to_vec_pretty(&state)?;
553    atomic_write(&path, &bytes)?;
554    Ok(())
555}
556
557fn manifest_relative_path(_root: &Path, manifest: &Manifest) -> String {
558    // Keep portability cheap: compute from known shape.
559    let q = {
560        // Re-derive from the run id (YYYY-MM-DD-run-NNN).
561        let date = manifest
562            .run_id
563            .get(0..10)
564            .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
565        date.map(quarter_label).unwrap_or_else(|| "q0".into())
566    };
567    let year = manifest.run_id.get(0..4).unwrap_or("0000");
568    format!(
569        "evidence/{year}/{q}/{cid}/{rid}/",
570        cid = manifest.control_id,
571        rid = manifest.run_id,
572    )
573}
574
575// quiet unused-import lint when sha256_bytes only appears in cfg(test).
576#[allow(dead_code)]
577fn _silence_unused() -> String {
578    sha256_bytes(b"")
579}