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, SkillRef, 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    // Resolve the runbook now (local-first, then bundled) so the prepare
147    // context can point the agent straight at it and pin its sha. Fail
148    // here rather than at finalize if the skill resolves to nothing.
149    let skill = {
150        let r = crate::skills::resolve(&reg.root, &ctrl.skill).ok_or_else(|| {
151            anyhow!(
152                "skill `{}` not found (no local skills/{}.md and not bundled)",
153                ctrl.skill,
154                ctrl.skill
155            )
156        })?;
157        SkillRef {
158            name: r.name,
159            source: r.source.as_str().to_string(),
160            sha256: r.sha256,
161        }
162    };
163
164    let ctx = PrepareContext {
165        schema_version: SCHEMA_VERSION,
166        control_id: control_id.to_string(),
167        run_id: run_id_from_dir(&run_dir)?,
168        run_dir: run_dir.clone(),
169        started_at: now,
170        skill,
171        operator: opts.operator.clone(),
172        note: opts.note.clone(),
173        scope_layout,
174        resolved_scope: resolved,
175        registry_git_sha,
176        period_id,
177    };
178
179    let json = serde_json::to_vec_pretty(&ctx)?;
180    atomic_write(&run_dir.join(PREPARE_FILE), &json)?;
181    // Sentinel last — once it exists, the run is officially pending.
182    atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
183    Ok(ctx)
184}
185
186/// Re-emit the prepare context for a pending run. No-op idempotent
187/// helper for resuming after an interrupted agent session.
188pub fn resume(run_dir: &Path) -> Result<PrepareContext> {
189    let path = run_dir.join(PREPARE_FILE);
190    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
191    let ctx: PrepareContext = serde_json::from_slice(&bytes)?;
192    Ok(ctx)
193}
194
195/// Tear down a pending run by sealing a failed manifest. Records the
196/// reason in `manifest.failure_reason` so the audit trail says why.
197/// Leaves any partial evidence on disk under the run dir.
198pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
199    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
200
201    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
202    let ctrl = reg
203        .controls
204        .get(&prepare.control_id)
205        .ok_or_else(|| anyhow!("control `{}` not found", prepare.control_id))?;
206
207    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
208    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
209    let skill_sha256 = sha256_for_skill(&reg.root, &ctrl.skill)?;
210
211    let manifest = Manifest {
212        schema_version: SCHEMA_VERSION,
213        control_id: prepare.control_id.clone(),
214        run_id: prepare.run_id.clone(),
215        started_at: prepare.started_at,
216        completed_at: Utc::now(),
217        operator: prepare.operator.clone(),
218        agent: AgentInfo {
219            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
220            skill: ctrl.skill.clone(),
221            skill_sha256,
222            control_sha256,
223        },
224        registry_git_sha: prepare.registry_git_sha.clone(),
225        scope_layout: prepare.scope_layout,
226        resolved_scope: prepare.resolved_scope.clone(),
227        prior_run,
228        artifacts: Vec::new(),
229        by_system: Vec::new(),
230        status: RunOutcome::Failed,
231        failure_reason: Some(reason.to_string()),
232        draft_risks: Vec::new(),
233        draft_issues: Vec::new(),
234        external_links: Vec::new(),
235        period_id: prepare.period_id.clone(),
236    };
237
238    let bytes = serde_json::to_vec(&manifest)?;
239    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
240    update_state(reg, &manifest)?;
241
242    let pending = run_dir.join(PENDING_SENTINEL);
243    if pending.exists() {
244        fs::remove_file(&pending)?;
245    }
246    Ok(manifest)
247}
248
249/// Hash every artifact, link the manifest to the prior run, atomically
250/// write `manifest.json`, update `state.json`, and remove the pending
251/// sentinel. Returns the sealed manifest.
252pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
253    finalize_at(reg, run_dir, Utc::now())
254}
255
256/// Like [`finalize`], but with an injectable completion clock. The CLI
257/// path uses [`finalize`] (wall-clock); tests pin `completed_at` so the
258/// coverage math (on-time vs late) is deterministic instead of drifting
259/// with the calendar.
260pub fn finalize_at(
261    reg: &LoadedRegistry,
262    run_dir: &Path,
263    completed_at: DateTime<Utc>,
264) -> Result<Manifest> {
265    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
266
267    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
268    let result: RunResult = read_json(&run_dir.join(RESULT_FILE))?;
269    if prepare.control_id != result.control_id || prepare.run_id != result.run_id {
270        bail!(
271            "result.json mismatches prepare.json (prepare={}/{}, result={}/{})",
272            prepare.control_id,
273            prepare.run_id,
274            result.control_id,
275            result.run_id,
276        );
277    }
278
279    // Skip the per-run metadata files when hashing.
280    let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
281    let hashed = hasher::hash_tree(run_dir, &exclude)?;
282
283    let mut artifacts: Vec<Artifact> = Vec::new();
284    let mut by_system_artifacts: BTreeMap<String, Vec<Artifact>> = BTreeMap::new();
285
286    for h in &hashed {
287        let art = Artifact {
288            path: h.path.clone(),
289            sha256: h.sha256.clone(),
290            bytes: h.bytes,
291        };
292        if let Some(sys) = h.path.strip_prefix("by-system/") {
293            // by-system/<name>/raw/<file>...
294            let name = sys.split('/').next().unwrap_or("").to_string();
295            by_system_artifacts.entry(name).or_default().push(art);
296        } else {
297            artifacts.push(art);
298        }
299    }
300
301    let by_system_blocks: Vec<BySystemBlock> = result
302        .by_system
303        .iter()
304        .map(|sr| BySystemBlock {
305            name: sr.name.clone(),
306            status: sr.status,
307            summary: None,
308            artifacts: by_system_artifacts.remove(&sr.name).unwrap_or_default(),
309        })
310        .collect();
311
312    // Anything captured under by-system that wasn't reflected in result.json
313    // becomes its own block too — we never silently drop hashed artifacts.
314    let mut extra_blocks: Vec<BySystemBlock> = by_system_artifacts
315        .into_iter()
316        .map(|(name, arts)| BySystemBlock {
317            name,
318            status: SystemOutcome::Complete,
319            summary: None,
320            artifacts: arts,
321        })
322        .collect();
323    let mut by_system_blocks = by_system_blocks;
324    by_system_blocks.append(&mut extra_blocks);
325    by_system_blocks.sort_by(|a, b| a.name.cmp(&b.name));
326
327    // Find the prior run for this control and link via its manifest sha.
328    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
329
330    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
331    let skill_sha256 = sha256_for_skill(&reg.root, &reg.controls[&prepare.control_id].skill)?;
332
333    let manifest = Manifest {
334        schema_version: SCHEMA_VERSION,
335        control_id: prepare.control_id.clone(),
336        run_id: prepare.run_id.clone(),
337        started_at: prepare.started_at,
338        completed_at,
339        operator: prepare.operator.clone(),
340        agent: AgentInfo {
341            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
342            skill: reg.controls[&prepare.control_id].skill.clone(),
343            skill_sha256,
344            control_sha256,
345        },
346        registry_git_sha: prepare.registry_git_sha.clone(),
347        scope_layout: prepare.scope_layout,
348        resolved_scope: prepare.resolved_scope.clone(),
349        prior_run,
350        artifacts,
351        by_system: by_system_blocks,
352        status: result.status,
353        failure_reason: None,
354        draft_risks: result.draft_risks.clone(),
355        draft_issues: result.draft_issues.clone(),
356        external_links: result.external_links.clone(),
357        period_id: prepare.period_id.clone(),
358    };
359
360    // Compact canonical JSON (no pretty-printing) so `jq`-style
361    // reformatting cannot silently break the chain hash. Operators who
362    // want to read a manifest pipe it through `jq .` on demand.
363    let bytes = serde_json::to_vec(&manifest)?;
364    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
365
366    update_state(reg, &manifest)?;
367
368    let pending = run_dir.join(PENDING_SENTINEL);
369    if pending.exists() {
370        fs::remove_file(&pending)?;
371    }
372    Ok(manifest)
373}
374
375/// Pending run pointer: control id, run id, and the run dir.
376#[derive(Debug, Clone)]
377pub struct PendingRun {
378    pub control_id: String,
379    pub run_id: String,
380    pub run_dir: PathBuf,
381}
382
383/// Walk `<root>/evidence/` looking for `.run-pending` sentinels. A
384/// sentinel sitting next to a sealed `manifest.json` is treated as
385/// crash-recovery debris from a finalize that died after the manifest
386/// landed but before sentinel removal: it's silently swept rather than
387/// surfaced as a pending run (which would block fresh prepares for that
388/// control).
389pub fn list_pending(root: &Path) -> Result<Vec<PendingRun>> {
390    let mut out = Vec::new();
391    let evidence = root.join("evidence");
392    if !evidence.exists() {
393        return Ok(out);
394    }
395    // Layout depth: evidence/<y>/<q>/<cid>/<rid>/.run-pending = 5 below evidence.
396    for entry in walkdir::WalkDir::new(&evidence).max_depth(5) {
397        let entry = entry?;
398        if entry.file_name() == PENDING_SENTINEL {
399            let run_dir = entry.path().parent().unwrap().to_path_buf();
400            // Crash recovery: sealed manifest + stale sentinel → sweep.
401            if run_dir.join(MANIFEST_FILE).exists() {
402                let _ = fs::remove_file(entry.path());
403                continue;
404            }
405            let prepare_path = run_dir.join(PREPARE_FILE);
406            if prepare_path.exists() {
407                let prepare: PrepareContext = read_json(&prepare_path)?;
408                out.push(PendingRun {
409                    control_id: prepare.control_id,
410                    run_id: prepare.run_id,
411                    run_dir,
412                });
413            }
414        }
415    }
416    Ok(out)
417}
418
419// ---------- helpers --------------------------------------------------------
420
421fn allocate_run_dir(root: &Path, control_id: &str, today: NaiveDate) -> Result<PathBuf> {
422    let q = quarter_label(today);
423    let base = root
424        .join("evidence")
425        .join(today.year().to_string())
426        .join(&q)
427        .join(control_id);
428    fs::create_dir_all(&base)?;
429    let mut n = 1u32;
430    loop {
431        let id = format!("{}-run-{:03}", today, n);
432        let candidate = base.join(&id);
433        if !candidate.exists() {
434            fs::create_dir_all(&candidate)?;
435            return Ok(candidate);
436        }
437        n += 1;
438        if n > 999 {
439            bail!("run-id counter overflowed for {control_id} on {today}");
440        }
441    }
442}
443
444fn quarter_label(d: NaiveDate) -> String {
445    let q = (d.month() - 1) / 3 + 1;
446    format!("q{q}")
447}
448
449fn run_id_from_dir(dir: &Path) -> Result<String> {
450    Ok(dir
451        .file_name()
452        .ok_or_else(|| anyhow!("run dir has no basename"))?
453        .to_string_lossy()
454        .into_owned())
455}
456
457fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
458    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
459    Ok(serde_json::from_slice(&bytes)?)
460}
461
462fn prior_run_link(root: &Path, control_id: &str, current_run_id: &str) -> Result<Option<PriorRun>> {
463    let evidence = root.join("evidence");
464    if !evidence.exists() {
465        return Ok(None);
466    }
467    let mut all_manifests: Vec<(String, PathBuf)> = Vec::new();
468    for entry in walkdir::WalkDir::new(&evidence) {
469        let entry = entry?;
470        if entry.file_name() != MANIFEST_FILE {
471            continue;
472        }
473        let dir = entry.path().parent().unwrap();
474        let id = dir
475            .file_name()
476            .and_then(|s| s.to_str())
477            .unwrap_or("")
478            .to_string();
479        // Restrict to manifests for the same control id.
480        let parent_control = dir
481            .parent()
482            .and_then(|p| p.file_name())
483            .and_then(|s| s.to_str())
484            .unwrap_or("");
485        if parent_control != control_id {
486            continue;
487        }
488        all_manifests.push((id, entry.path().to_path_buf()));
489    }
490    all_manifests.sort_by(|a, b| a.0.cmp(&b.0));
491    let prior = all_manifests
492        .into_iter()
493        .rfind(|(id, _)| id.as_str() < current_run_id);
494    match prior {
495        None => Ok(None),
496        Some((id, path)) => {
497            let sha = sha256_file(&path)?;
498            Ok(Some(PriorRun {
499                run_id: id,
500                manifest_sha256: sha,
501            }))
502        }
503    }
504}
505
506fn sha256_for_control(root: &Path, control_id: &str) -> Result<String> {
507    let path = root.join("controls").join(format!("{control_id}.yaml"));
508    sha256_file(&path).with_context(|| format!("hash control {}", path.display()))
509}
510
511fn sha256_for_skill(root: &Path, skill: &str) -> Result<String> {
512    crate::skills::resolve(root, skill)
513        .map(|r| r.sha256)
514        .ok_or_else(|| {
515            anyhow!("skill `{skill}` not found (no local skills/{skill}.md and not bundled)")
516        })
517}
518
519/// Resolve the registry repo's HEAD commit hex via gix. Errors when
520/// `root` isn't a git repo or HEAD can't be peeled (no commits yet,
521/// detached weirdness, etc) — `prepare` requires a real git sha to
522/// pin what the registry said at run time.
523///
524/// Public so `doctor` can use the *same* check `prepare` does: a repo that
525/// fails here is a repo `run prepare` will reject, so doctor's verdict is
526/// aligned with reality rather than just testing for a `.git` directory.
527pub fn git_head(root: &Path) -> Result<String> {
528    let repo = gix::open(root).context("open repo")?;
529    let head = repo.head().context("read HEAD")?;
530    let id = head.into_peeled_id().context("peel HEAD to a commit")?;
531    Ok(id.to_hex().to_string())
532}
533
534/// True if the working tree at `root` has uncommitted changes to *tracked*
535/// files (index vs. worktree vs. HEAD). Untracked files are not considered.
536/// Errors only when `root` is not a usable git repo.
537pub fn git_is_dirty(root: &Path) -> Result<bool> {
538    let repo = gix::open(root).context("open repo")?;
539    repo.is_dirty().context("compute worktree status")
540}
541
542fn update_state(reg: &LoadedRegistry, manifest: &Manifest) -> Result<()> {
543    let path = reg.root.join(STATE_FILE);
544    // Bail loudly on a corrupt state file rather than silently dropping
545    // every prior control's entry by replacing with default. Operators
546    // who genuinely want to reset state.json can remove it; finalize
547    // will then start fresh.
548    let mut state: crate::model::State = if path.exists() {
549        let bytes = fs::read(&path)?;
550        serde_json::from_slice(&bytes).with_context(|| {
551            format!(
552                "{} is corrupt; refusing to overwrite — remove it manually to reset state",
553                path.display()
554            )
555        })?
556    } else {
557        crate::model::State::default()
558    };
559
560    // Compute the next firing date as part of finalize so state.json is
561    // a useful cache for `secunit due` and downstream report skills,
562    // rather than a placeholder readers have to re-derive on every load.
563    // Anchor at "the day after the run's target date" (parsed from the
564    // run-id's YYYY-MM-DD prefix, not wall-clock completion time) so a
565    // weekly control with target Monday returns *next* Monday, not today.
566    let run_date = manifest
567        .run_id
568        .get(0..10)
569        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
570    let next_due = run_date.and_then(|d| {
571        let lookup_from = d + chrono::Duration::days(1);
572        reg.controls.get(&manifest.control_id).and_then(|ctrl| {
573            crate::registry::resolver::next_due(
574                ctrl,
575                &reg.schedule,
576                None,
577                lookup_from,
578                reg.config.weekly_default_weekday,
579            )
580        })
581    });
582
583    state.controls.insert(
584        manifest.control_id.clone(),
585        StateEntry {
586            last_run_id: Some(manifest.run_id.clone()),
587            last_run_path: Some(manifest_relative_path(&reg.root, manifest)),
588            last_run_at: Some(manifest.completed_at),
589            last_status: match manifest.status {
590                RunOutcome::Complete => RunStatus::Complete,
591                RunOutcome::Partial => RunStatus::InProgress,
592                RunOutcome::Failed => RunStatus::Failed,
593            },
594            next_due,
595        },
596    );
597    state.updated_at = Some(Utc::now());
598    let bytes = serde_json::to_vec_pretty(&state)?;
599    atomic_write(&path, &bytes)?;
600    Ok(())
601}
602
603fn manifest_relative_path(_root: &Path, manifest: &Manifest) -> String {
604    // Keep portability cheap: compute from known shape.
605    let q = {
606        // Re-derive from the run id (YYYY-MM-DD-run-NNN).
607        let date = manifest
608            .run_id
609            .get(0..10)
610            .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
611        date.map(quarter_label).unwrap_or_else(|| "q0".into())
612    };
613    let year = manifest.run_id.get(0..4).unwrap_or("0000");
614    format!(
615        "evidence/{year}/{q}/{cid}/{rid}/",
616        cid = manifest.control_id,
617        rid = manifest.run_id,
618    )
619}
620
621// quiet unused-import lint when sha256_bytes only appears in cfg(test).
622#[allow(dead_code)]
623fn _silence_unused() -> String {
624    sha256_bytes(b"")
625}