1use std::collections::BTreeMap;
20
21use camino::{Utf8Path, Utf8PathBuf};
22use serde::{Deserialize, Serialize};
23
24use crate::domain::ownership::Sha256;
25use crate::error::AppError;
26use crate::plan::Plan;
27
28pub const RESULT_SCHEMA: &str = "sdd.result/1";
30
31pub const PLAN_TTL_DAYS: i64 = 7;
33
34pub const RESULT_TTL_DAYS: i64 = 30;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum Disposition {
41 Succeeded,
43 Invalidated,
45 Retryable,
47 RecoveryRequired,
49}
50
51impl Disposition {
52 #[must_use]
54 pub const fn is_terminal(self) -> bool {
55 matches!(self, Self::Succeeded | Self::Invalidated)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct OperationOutcome {
62 pub kind: String,
64 pub path: String,
66 pub applied: bool,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub refusal: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct PostconditionOutcome {
76 pub id: String,
78 pub held: bool,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub detail: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Result {
88 pub schema: String,
90 pub plan_id: String,
92 pub fingerprint: Sha256,
94 pub result_id: String,
96 pub disposition: Disposition,
98 pub finished_at: String,
100 pub operations: Vec<OperationOutcome>,
102 pub postconditions: Vec<PostconditionOutcome>,
104 pub recovery_required: bool,
106 pub affected: Vec<String>,
108 pub reason: String,
110}
111
112#[derive(Debug, Clone)]
114pub struct Store {
115 root: Utf8PathBuf,
116}
117
118#[derive(Debug, Clone)]
120pub struct PlanDirectory {
121 pub root: Utf8PathBuf,
123 pub plan: Utf8PathBuf,
125 pub blobs: Utf8PathBuf,
127 pub journal: Utf8PathBuf,
129}
130
131impl Store {
132 #[must_use]
134 pub fn new(state_root: &Utf8Path) -> Self {
135 Self {
136 root: state_root.join(crate::domain::paths::PLAN_STORE_DIR),
137 }
138 }
139
140 #[must_use]
142 pub fn root(&self) -> &Utf8Path {
143 &self.root
144 }
145
146 fn attempt_slug(value: &str) -> String {
153 let held: String = value
154 .chars()
155 .map(|held| {
156 if held.is_ascii_alphanumeric() || held == '-' {
157 held
158 } else {
159 '-'
160 }
161 })
162 .collect();
163 if held.is_empty() {
164 "attempt".to_string()
165 } else {
166 held
167 }
168 }
169
170 pub fn checked(fingerprint: &str) -> std::result::Result<&str, AppError> {
182 fingerprint.parse::<Sha256>().map_err(|_| {
183 AppError::Refused(format!(
184 "'{fingerprint}' is not a plan id; a plan id is the 64-character fingerprint 'sdd reconcile plan' printed"
185 ))
186 })?;
187 Ok(fingerprint)
188 }
189
190 #[must_use]
195 pub fn directory(&self, fingerprint: &str) -> PlanDirectory {
196 let root = self.root.join("plans").join(fingerprint);
197 PlanDirectory {
198 plan: root.join("plan.json"),
199 blobs: root.join("blobs"),
200 journal: root.join("apply.journal"),
201 root,
202 }
203 }
204
205 #[must_use]
207 pub fn results(&self, fingerprint: &str) -> Utf8PathBuf {
208 self.root.join("results").join(fingerprint)
209 }
210
211 #[must_use]
218 pub fn lock_path(&self) -> Utf8PathBuf {
219 self.root.join("store.lock")
220 }
221
222 pub fn plan_lock_path(&self, fingerprint: &str) -> std::result::Result<Utf8PathBuf, AppError> {
232 Ok(self
236 .root
237 .join("locks")
238 .join(format!("{}.lock", Self::checked(fingerprint)?)))
239 }
240
241 pub fn create(&self) -> std::result::Result<(), AppError> {
251 for directory in [
252 self.root.clone(),
253 self.root.join("plans"),
254 self.root.join("results"),
255 ] {
256 std::fs::create_dir_all(&directory)?;
257 owner_only(&directory)?;
258 }
259 Ok(())
260 }
261
262 #[must_use]
264 pub fn holds(&self, fingerprint: &str) -> bool {
265 Self::checked(fingerprint).is_ok_and(|held| self.directory(held).plan.is_file())
266 }
267
268 pub fn put(
274 &self,
275 plan: &Plan,
276 blobs: &BTreeMap<Sha256, Vec<u8>>,
277 ) -> std::result::Result<PlanDirectory, AppError> {
278 self.create()?;
279 let held = self.directory(Self::checked(&plan.identity.plan_id)?);
280 std::fs::create_dir_all(&held.blobs)?;
281 owner_only(&held.root)?;
282 owner_only(&held.blobs)?;
283 for (digest, bytes) in blobs {
284 let path = held.blobs.join(digest.to_string());
285 if !path.is_file() {
286 crate::adapters::fs::write_atomic(&path, bytes)?;
287 }
288 }
289 let text = serde_json::to_string_pretty(plan)
290 .map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
291 crate::adapters::fs::write_atomic(&held.plan, format!("{text}\n").as_bytes())?;
292 Ok(held)
293 }
294
295 pub fn get(&self, fingerprint: &str) -> std::result::Result<Plan, AppError> {
302 let held = self.directory(Self::checked(fingerprint)?);
303 let text = std::fs::read_to_string(&held.plan).map_err(|_| {
304 AppError::Refused(format!(
305 "no executable plan carries the id {fingerprint}; run 'sdd reconcile plan' again"
306 ))
307 })?;
308 let plan: Plan = serde_json::from_str(&text).map_err(|source| {
309 AppError::Refused(format!("{} does not parse: {source}", held.plan))
310 })?;
311 if plan.identity.plan_id != fingerprint {
315 return Err(AppError::Refused(format!(
316 "{} carries the id {} and was fetched as {fingerprint}",
317 held.plan, plan.identity.plan_id
318 )));
319 }
320 Ok(plan)
321 }
322
323 pub fn blob(
329 &self,
330 fingerprint: &str,
331 digest: &Sha256,
332 ) -> std::result::Result<Vec<u8>, AppError> {
333 let path = self
334 .directory(Self::checked(fingerprint)?)
335 .blobs
336 .join(digest.to_string());
337 let bytes = std::fs::read(&path).map_err(|source| {
338 AppError::Refused(format!(
339 "the plan {fingerprint} carries no blob {digest}: {source}"
340 ))
341 })?;
342 if &Sha256::of(&bytes) != digest {
343 return Err(AppError::Refused(format!(
344 "the blob at {path} no longer hashes to {digest}"
345 )));
346 }
347 Ok(bytes)
348 }
349
350 pub fn record(&self, plan: &Plan, result: &Result) -> std::result::Result<(), AppError> {
360 let id = Self::checked(&plan.identity.plan_id)?;
361 let directory = self
362 .results(Self::checked(&result.fingerprint.to_string())?)
363 .join(Self::attempt_slug(&result.result_id));
364 std::fs::create_dir_all(&directory)?;
365 owner_only(&directory)?;
366 let redacted = redact(plan);
367 let text = serde_json::to_string_pretty(&redacted)
368 .map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
369 crate::adapters::fs::write_atomic(
370 &directory.join("plan.json"),
371 format!("{text}\n").as_bytes(),
372 )?;
373 let text = serde_json::to_string_pretty(result)
374 .map_err(|source| anyhow::anyhow!("the result did not serialize: {source}"))?;
375 crate::adapters::fs::write_atomic(
376 &directory.join("result.json"),
377 format!("{text}\n").as_bytes(),
378 )?;
379 if result.disposition.is_terminal() {
380 let held = self.directory(id).root;
385 if let Err(cause) = std::fs::remove_dir_all(&held)
386 && held.exists()
387 {
388 return Err(AppError::Refused(format!(
389 "the result was recorded and the plan at {held} could not be removed: {cause}; remove it by hand before planning the same inputs again"
390 )));
391 }
392 }
393 Ok(())
394 }
395
396 #[must_use]
398 pub fn latest_result(&self, fingerprint: &str) -> Option<Result> {
399 let fingerprint = Self::checked(fingerprint).ok()?;
400 let mut found: Vec<(String, Result)> = std::fs::read_dir(self.results(fingerprint))
401 .ok()?
402 .filter_map(std::result::Result::ok)
403 .filter_map(|entry| {
404 let name = entry.file_name().to_str()?.to_string();
405 let text = std::fs::read_to_string(entry.path().join("result.json")).ok()?;
406 let held: Result = serde_json::from_str(&text).ok()?;
407 Some((name, held))
408 })
409 .collect();
410 found.sort_by(|left, right| left.0.cmp(&right.0));
411 found.pop().map(|(_, held)| held)
412 }
413
414 pub fn prune(
424 &self,
425 now: jiff::Timestamp,
426 keep: Option<&str>,
427 ) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
428 let mut removed = Vec::new();
429 removed.extend(self.prune_under(
430 &self.root.join("plans"),
431 now,
432 PLAN_TTL_DAYS,
433 keep,
434 true,
435 )?);
436 removed.extend(self.prune_under(
437 &self.root.join("results"),
438 now,
439 RESULT_TTL_DAYS,
440 keep,
441 false,
442 )?);
443 Ok(removed)
444 }
445
446 fn prune_under(
447 &self,
448 root: &Utf8Path,
449 now: jiff::Timestamp,
450 days: i64,
451 keep: Option<&str>,
452 guard_journal: bool,
453 ) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
454 let Ok(entries) = std::fs::read_dir(root) else {
455 return Ok(Vec::new());
456 };
457 let mut removed = Vec::new();
458 for entry in entries.filter_map(std::result::Result::ok) {
459 let Ok(path) = Utf8PathBuf::from_path_buf(entry.path()) else {
460 continue;
461 };
462 let name = path.file_name().unwrap_or_default();
463 if Some(name) == keep {
464 continue;
465 }
466 if !path.is_dir() || Self::checked(name).is_err() {
470 continue;
471 }
472 if guard_journal && path.join("apply.journal").exists() {
473 continue;
474 }
475 let _guard = if guard_journal {
481 match self.hold_plan(name) {
482 Some(held) => Some(held),
483 None => continue,
484 }
485 } else {
486 None
487 };
488 if older_than(&path, now, days) {
489 std::fs::remove_dir_all(&path)?;
490 removed.push(path);
491 }
492 }
493 Ok(removed)
494 }
495
496 fn hold_plan(&self, fingerprint: &str) -> Option<crate::transaction::lock::Lock> {
502 let path = self.plan_lock_path(fingerprint).ok()?;
503 crate::transaction::lock::Lock::exclusive(&path, "plan store prune").ok()
504 }
505}
506
507fn older_than(path: &Utf8Path, now: jiff::Timestamp, days: i64) -> bool {
509 let Ok(metadata) = std::fs::metadata(path) else {
510 return false;
511 };
512 let Ok(modified) = metadata.modified() else {
513 return false;
514 };
515 let Ok(elapsed) = modified.elapsed() else {
516 return false;
517 };
518 let _ = now;
519 #[expect(
520 clippy::cast_sign_loss,
521 reason = "the allowance is a positive number of days, declared as a constant here"
522 )]
523 let allowance = std::time::Duration::from_secs(days as u64 * 24 * 60 * 60);
524 elapsed > allowance
525}
526
527fn owner_only(path: &Utf8Path) -> std::result::Result<(), AppError> {
529 let mut permissions = std::fs::metadata(path)?.permissions();
530 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
531 std::fs::set_permissions(path, permissions)?;
532 Ok(())
533}
534
535#[must_use]
541pub fn redact(plan: &Plan) -> Plan {
542 let mut held = plan.clone();
543 held.observed_state.repository.root = Utf8PathBuf::from("<target>");
544 held.observed_state.host.cache_root = None;
545 held
546}
547
548#[cfg(test)]
549mod tests {
550 #![allow(
551 clippy::unwrap_used,
552 reason = "a test panics as its failure signal, not as control flow"
553 )]
554
555 use super::*;
556
557 fn store(dir: &tempfile::TempDir) -> Store {
558 Store::new(&Utf8PathBuf::from(dir.path().to_str().unwrap()))
559 }
560
561 #[test]
562 fn the_store_is_owner_only() {
563 let dir = tempfile::tempdir().unwrap();
564 let held = store(&dir);
565 held.create().unwrap();
566 for path in [held.root().to_owned(), held.root().join("plans")] {
567 let mode = std::os::unix::fs::PermissionsExt::mode(
568 &std::fs::metadata(&path).unwrap().permissions(),
569 );
570 assert_eq!(mode & 0o777, 0o700, "{path} is not owner-only");
571 }
572 }
573
574 #[test]
575 fn a_directory_a_command_named_is_never_pruned() {
576 let dir = tempfile::tempdir().unwrap();
577 let held = store(&dir);
578 held.create().unwrap();
579 let one = held.directory("keepme");
580 std::fs::create_dir_all(&one.root).unwrap();
581 let removed = held.prune(jiff::Timestamp::now(), Some("keepme")).unwrap();
582 assert!(removed.is_empty());
583 assert!(one.root.is_dir());
584 }
585
586 #[test]
587 fn a_journal_holds_its_directory_past_ordinary_expiry() {
588 let dir = tempfile::tempdir().unwrap();
589 let held = store(&dir);
590 held.create().unwrap();
591 let one = held.directory("unfinished");
592 std::fs::create_dir_all(&one.root).unwrap();
593 std::fs::write(&one.journal, "{}").unwrap();
594 let removed = held.prune(jiff::Timestamp::now(), None).unwrap();
597 assert!(removed.is_empty());
598 assert!(one.root.is_dir());
599 }
600
601 #[test]
602 fn a_blob_that_no_longer_hashes_to_its_name_refuses() {
603 let dir = tempfile::tempdir().unwrap();
604 let held = store(&dir);
605 held.create().unwrap();
606 let one = held.directory("f");
607 std::fs::create_dir_all(&one.blobs).unwrap();
608 let digest = Sha256::of(b"intended");
609 std::fs::write(one.blobs.join(digest.to_string()), b"tampered").unwrap();
610 assert!(held.blob("f", &digest).is_err());
611 }
612
613 #[test]
614 fn an_absent_plan_refuses_with_the_next_command() {
615 let dir = tempfile::tempdir().unwrap();
616 let error = store(&dir).get("nope").unwrap_err();
617 assert!(error.to_string().contains("sdd reconcile plan"), "{error}");
618 }
619}