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, 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#[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 skill = {
150 let r = crate::skills::resolve(®.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 atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
183 Ok(ctx)
184}
185
186pub 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
195pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
199 let _lock = RootLock::acquire(®.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(®.root, &prepare.control_id, &prepare.run_id)?;
208 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
209 let skill_sha256 = sha256_for_skill(®.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
249pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
253 finalize_at(reg, run_dir, Utc::now())
254}
255
256pub fn finalize_at(
261 reg: &LoadedRegistry,
262 run_dir: &Path,
263 completed_at: DateTime<Utc>,
264) -> Result<Manifest> {
265 let _lock = RootLock::acquire(®.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 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 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 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 let prior_run = prior_run_link(®.root, &prepare.control_id, &prepare.run_id)?;
329
330 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
331 let skill_sha256 = sha256_for_skill(®.root, ®.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 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#[derive(Debug, Clone)]
377pub struct PendingRun {
378 pub control_id: String,
379 pub run_id: String,
380 pub run_dir: PathBuf,
381}
382
383pub 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 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 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
419fn 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 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
519pub 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
534pub 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 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 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 ®.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(®.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 let q = {
606 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#[allow(dead_code)]
623fn _silence_unused() -> String {
624 sha256_bytes(b"")
625}