1use 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#[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 pub period_id: Option<String>,
41}
42
43pub fn prepare(
47 reg: &LoadedRegistry,
48 control_id: &str,
49 opts: &PrepareOpts,
50) -> Result<PrepareContext> {
51 let _lock = RootLock::acquire(®.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 let existing = list_pending(®.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, ®.inventory, today);
71
72 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 let scope_layout = if ctrl.scope.is_none() || resolved.len() == 1 {
94 ScopeLayout::Flat
95 } else {
96 ScopeLayout::BySystem
97 };
98
99 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::derive(ctrl.cadence, today)
126 }
127 }
128 };
129
130 let run_dir = allocate_run_dir(®.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(®.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 atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
164 Ok(ctx)
165}
166
167pub 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
176pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
180 let _lock = RootLock::acquire(®.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(®.root, &prepare.control_id, &prepare.run_id)?;
189 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
190 let skill_sha256 = sha256_for_skill(®.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
230pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
234 let _lock = RootLock::acquire(®.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 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 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 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 let prior_run = prior_run_link(®.root, &prepare.control_id, &prepare.run_id)?;
298
299 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
300 let skill_sha256 = sha256_for_skill(®.root, ®.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 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#[derive(Debug, Clone)]
346pub struct PendingRun {
347 pub control_id: String,
348 pub run_id: String,
349 pub run_dir: PathBuf,
350}
351
352pub 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 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 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
388fn 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 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
485fn 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 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 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 ®.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(®.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 let q = {
560 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#[allow(dead_code)]
577fn _silence_unused() -> String {
578 sha256_bytes(b"")
579}