use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::ownership::Sha256;
use crate::error::AppError;
use crate::plan::Plan;
pub const RESULT_SCHEMA: &str = "sdd.result/1";
pub const PLAN_TTL_DAYS: i64 = 7;
pub const RESULT_TTL_DAYS: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Disposition {
Succeeded,
Invalidated,
Retryable,
RecoveryRequired,
}
impl Disposition {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Invalidated)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationOutcome {
pub kind: String,
pub path: String,
pub applied: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refusal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostconditionOutcome {
pub id: String,
pub held: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Result {
pub schema: String,
pub plan_id: String,
pub fingerprint: Sha256,
pub result_id: String,
pub disposition: Disposition,
pub finished_at: String,
pub operations: Vec<OperationOutcome>,
pub postconditions: Vec<PostconditionOutcome>,
pub recovery_required: bool,
pub affected: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct Store {
root: Utf8PathBuf,
}
#[derive(Debug, Clone)]
pub struct PlanDirectory {
pub root: Utf8PathBuf,
pub plan: Utf8PathBuf,
pub blobs: Utf8PathBuf,
pub journal: Utf8PathBuf,
}
impl Store {
#[must_use]
pub fn new(state_root: &Utf8Path) -> Self {
Self {
root: state_root.join(crate::domain::paths::PLAN_STORE_DIR),
}
}
#[must_use]
pub fn root(&self) -> &Utf8Path {
&self.root
}
fn attempt_slug(value: &str) -> String {
let held: String = value
.chars()
.map(|held| {
if held.is_ascii_alphanumeric() || held == '-' {
held
} else {
'-'
}
})
.collect();
if held.is_empty() {
"attempt".to_string()
} else {
held
}
}
pub fn checked(fingerprint: &str) -> std::result::Result<&str, AppError> {
fingerprint.parse::<Sha256>().map_err(|_| {
AppError::Refused(format!(
"'{fingerprint}' is not a plan id; a plan id is the 64-character fingerprint 'sdd reconcile plan' printed"
))
})?;
Ok(fingerprint)
}
#[must_use]
pub fn directory(&self, fingerprint: &str) -> PlanDirectory {
let root = self.root.join("plans").join(fingerprint);
PlanDirectory {
plan: root.join("plan.json"),
blobs: root.join("blobs"),
journal: root.join("apply.journal"),
root,
}
}
#[must_use]
pub fn results(&self, fingerprint: &str) -> Utf8PathBuf {
self.root.join("results").join(fingerprint)
}
#[must_use]
pub fn lock_path(&self) -> Utf8PathBuf {
self.root.join("store.lock")
}
pub fn plan_lock_path(&self, fingerprint: &str) -> std::result::Result<Utf8PathBuf, AppError> {
Ok(self
.root
.join("locks")
.join(format!("{}.lock", Self::checked(fingerprint)?)))
}
pub fn create(&self) -> std::result::Result<(), AppError> {
for directory in [
self.root.clone(),
self.root.join("plans"),
self.root.join("results"),
] {
std::fs::create_dir_all(&directory)?;
owner_only(&directory)?;
}
Ok(())
}
#[must_use]
pub fn holds(&self, fingerprint: &str) -> bool {
Self::checked(fingerprint).is_ok_and(|held| self.directory(held).plan.is_file())
}
pub fn put(
&self,
plan: &Plan,
blobs: &BTreeMap<Sha256, Vec<u8>>,
) -> std::result::Result<PlanDirectory, AppError> {
self.create()?;
let held = self.directory(Self::checked(&plan.identity.plan_id)?);
std::fs::create_dir_all(&held.blobs)?;
owner_only(&held.root)?;
owner_only(&held.blobs)?;
for (digest, bytes) in blobs {
let path = held.blobs.join(digest.to_string());
if !path.is_file() {
crate::adapters::fs::write_atomic(&path, bytes)?;
}
}
let text = serde_json::to_string_pretty(plan)
.map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
crate::adapters::fs::write_atomic(&held.plan, format!("{text}\n").as_bytes())?;
Ok(held)
}
pub fn get(&self, fingerprint: &str) -> std::result::Result<Plan, AppError> {
let held = self.directory(Self::checked(fingerprint)?);
let text = std::fs::read_to_string(&held.plan).map_err(|_| {
AppError::Refused(format!(
"no executable plan carries the id {fingerprint}; run 'sdd reconcile plan' again"
))
})?;
let plan: Plan = serde_json::from_str(&text).map_err(|source| {
AppError::Refused(format!("{} does not parse: {source}", held.plan))
})?;
if plan.identity.plan_id != fingerprint {
return Err(AppError::Refused(format!(
"{} carries the id {} and was fetched as {fingerprint}",
held.plan, plan.identity.plan_id
)));
}
Ok(plan)
}
pub fn blob(
&self,
fingerprint: &str,
digest: &Sha256,
) -> std::result::Result<Vec<u8>, AppError> {
let path = self
.directory(Self::checked(fingerprint)?)
.blobs
.join(digest.to_string());
let bytes = std::fs::read(&path).map_err(|source| {
AppError::Refused(format!(
"the plan {fingerprint} carries no blob {digest}: {source}"
))
})?;
if &Sha256::of(&bytes) != digest {
return Err(AppError::Refused(format!(
"the blob at {path} no longer hashes to {digest}"
)));
}
Ok(bytes)
}
pub fn record(&self, plan: &Plan, result: &Result) -> std::result::Result<(), AppError> {
let id = Self::checked(&plan.identity.plan_id)?;
let directory = self
.results(Self::checked(&result.fingerprint.to_string())?)
.join(Self::attempt_slug(&result.result_id));
std::fs::create_dir_all(&directory)?;
owner_only(&directory)?;
let redacted = redact(plan);
let text = serde_json::to_string_pretty(&redacted)
.map_err(|source| anyhow::anyhow!("the plan did not serialize: {source}"))?;
crate::adapters::fs::write_atomic(
&directory.join("plan.json"),
format!("{text}\n").as_bytes(),
)?;
let text = serde_json::to_string_pretty(result)
.map_err(|source| anyhow::anyhow!("the result did not serialize: {source}"))?;
crate::adapters::fs::write_atomic(
&directory.join("result.json"),
format!("{text}\n").as_bytes(),
)?;
if result.disposition.is_terminal() {
let held = self.directory(id).root;
if let Err(cause) = std::fs::remove_dir_all(&held)
&& held.exists()
{
return Err(AppError::Refused(format!(
"the result was recorded and the plan at {held} could not be removed: {cause}; remove it by hand before planning the same inputs again"
)));
}
}
Ok(())
}
#[must_use]
pub fn latest_result(&self, fingerprint: &str) -> Option<Result> {
let fingerprint = Self::checked(fingerprint).ok()?;
let mut found: Vec<(String, Result)> = std::fs::read_dir(self.results(fingerprint))
.ok()?
.filter_map(std::result::Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_str()?.to_string();
let text = std::fs::read_to_string(entry.path().join("result.json")).ok()?;
let held: Result = serde_json::from_str(&text).ok()?;
Some((name, held))
})
.collect();
found.sort_by(|left, right| left.0.cmp(&right.0));
found.pop().map(|(_, held)| held)
}
pub fn prune(
&self,
now: jiff::Timestamp,
keep: Option<&str>,
) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
let mut removed = Vec::new();
removed.extend(self.prune_under(
&self.root.join("plans"),
now,
PLAN_TTL_DAYS,
keep,
true,
)?);
removed.extend(self.prune_under(
&self.root.join("results"),
now,
RESULT_TTL_DAYS,
keep,
false,
)?);
Ok(removed)
}
fn prune_under(
&self,
root: &Utf8Path,
now: jiff::Timestamp,
days: i64,
keep: Option<&str>,
guard_journal: bool,
) -> std::result::Result<Vec<Utf8PathBuf>, AppError> {
let Ok(entries) = std::fs::read_dir(root) else {
return Ok(Vec::new());
};
let mut removed = Vec::new();
for entry in entries.filter_map(std::result::Result::ok) {
let Ok(path) = Utf8PathBuf::from_path_buf(entry.path()) else {
continue;
};
let name = path.file_name().unwrap_or_default();
if Some(name) == keep {
continue;
}
if !path.is_dir() || Self::checked(name).is_err() {
continue;
}
if guard_journal && path.join("apply.journal").exists() {
continue;
}
let _guard = if guard_journal {
match self.hold_plan(name) {
Some(held) => Some(held),
None => continue,
}
} else {
None
};
if older_than(&path, now, days) {
std::fs::remove_dir_all(&path)?;
removed.push(path);
}
}
Ok(removed)
}
fn hold_plan(&self, fingerprint: &str) -> Option<crate::transaction::lock::Lock> {
let path = self.plan_lock_path(fingerprint).ok()?;
crate::transaction::lock::Lock::exclusive(&path, "plan store prune").ok()
}
}
fn older_than(path: &Utf8Path, now: jiff::Timestamp, days: i64) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
let Ok(elapsed) = modified.elapsed() else {
return false;
};
let _ = now;
#[expect(
clippy::cast_sign_loss,
reason = "the allowance is a positive number of days, declared as a constant here"
)]
let allowance = std::time::Duration::from_secs(days as u64 * 24 * 60 * 60);
elapsed > allowance
}
#[cfg(unix)]
fn owner_only(path: &Utf8Path) -> std::result::Result<(), AppError> {
let mut permissions = std::fs::metadata(path)?.permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
std::fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(not(unix))]
#[expect(
clippy::unnecessary_wraps,
reason = "the signature is the platform-independent one its callers use"
)]
const fn owner_only(_path: &Utf8Path) -> std::result::Result<(), AppError> {
Ok(())
}
#[must_use]
pub fn redact(plan: &Plan) -> Plan {
let mut held = plan.clone();
held.observed_state.repository.root = Utf8PathBuf::from("<target>");
held.observed_state.host.cache_root = None;
held
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn store(dir: &tempfile::TempDir) -> Store {
Store::new(&Utf8PathBuf::from(dir.path().to_str().unwrap()))
}
#[test]
fn the_store_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let held = store(&dir);
held.create().unwrap();
for path in [held.root().to_owned(), held.root().join("plans")] {
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(&path).unwrap().permissions(),
);
assert_eq!(mode & 0o777, 0o700, "{path} is not owner-only");
}
}
#[test]
fn a_directory_a_command_named_is_never_pruned() {
let dir = tempfile::tempdir().unwrap();
let held = store(&dir);
held.create().unwrap();
let one = held.directory("keepme");
std::fs::create_dir_all(&one.root).unwrap();
let removed = held.prune(jiff::Timestamp::now(), Some("keepme")).unwrap();
assert!(removed.is_empty());
assert!(one.root.is_dir());
}
#[test]
fn a_journal_holds_its_directory_past_ordinary_expiry() {
let dir = tempfile::tempdir().unwrap();
let held = store(&dir);
held.create().unwrap();
let one = held.directory("unfinished");
std::fs::create_dir_all(&one.root).unwrap();
std::fs::write(&one.journal, "{}").unwrap();
let removed = held.prune(jiff::Timestamp::now(), None).unwrap();
assert!(removed.is_empty());
assert!(one.root.is_dir());
}
#[test]
fn a_blob_that_no_longer_hashes_to_its_name_refuses() {
let dir = tempfile::tempdir().unwrap();
let held = store(&dir);
held.create().unwrap();
let one = held.directory("f");
std::fs::create_dir_all(&one.blobs).unwrap();
let digest = Sha256::of(b"intended");
std::fs::write(one.blobs.join(digest.to_string()), b"tampered").unwrap();
assert!(held.blob("f", &digest).is_err());
}
#[test]
fn an_absent_plan_refuses_with_the_next_command() {
let dir = tempfile::tempdir().unwrap();
let error = store(&dir).get("nope").unwrap_err();
assert!(error.to_string().contains("sdd reconcile plan"), "{error}");
}
}