pub mod digest;
#[cfg(test)]
#[path = "tests_digest.rs"]
mod tests_digest;
#[cfg(test)]
#[path = "tests_verify.rs"]
mod tests_verify;
use crate::core::plan_selectors::PlanSelectors;
use crate::core::types::{ExecutionPlan, ForjarConfig, PlanAction};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
pub const SEAL_VERSION: &str = "forjar-plan-seal-v1";
pub const DEFAULT_TTL_SECS: u64 = 900;
pub const MIN_TTL_SECS: u64 = 60;
pub const MAX_TTL_SECS: u64 = 3600;
pub const TTL_NO_EXPIRY: u64 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Leg {
Config,
State,
Diff,
Seal,
}
impl Leg {
pub fn name(self) -> &'static str {
match self {
Self::Config => "config",
Self::State => "state",
Self::Diff => "diff",
Self::Seal => "seal",
}
}
fn remedy(self) -> &'static str {
match self {
Self::Config => "the config changed since the plan was sealed",
Self::State => "a machine's state lock changed since the plan was sealed",
Self::Diff => "the plan body was modified after it was sealed",
Self::Seal => "the plan's seal does not match its own fields",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SealError {
PlanExpired {
sealed_at: u64,
expires_at: u64,
now: u64,
},
PlanHashMismatch {
leg: Leg,
expected: String,
actual: String,
},
PlanMalformed(String),
PlanVersionUnknown(String),
}
impl SealError {
pub fn code(&self) -> &'static str {
match self {
Self::PlanExpired { .. } => "PLAN_EXPIRED",
Self::PlanHashMismatch { .. } => "PLAN_HASH_MISMATCH",
Self::PlanMalformed(_) => "PLAN_MALFORMED",
Self::PlanVersionUnknown(_) => "PLAN_VERSION_UNKNOWN",
}
}
}
impl fmt::Display for SealError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: ", self.code())?;
match self {
Self::PlanExpired {
sealed_at,
expires_at,
now,
} => write!(
f,
"plan sealed at {sealed_at} expired at {expires_at} (now {now}) \
— re-run `forjar plan`"
),
Self::PlanHashMismatch {
leg,
expected,
actual,
} => write!(
f,
"{} ({} leg: expected {expected}, got {actual}) — re-run `forjar plan`",
leg.remedy(),
leg.name()
),
Self::PlanMalformed(why) => write!(f, "{why}"),
Self::PlanVersionUnknown(v) => write!(
f,
"plan seal version '{v}' is not understood by this forjar \
(expected '{SEAL_VERSION}') — re-run `forjar plan`"
),
}
}
}
impl std::error::Error for SealError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanSeal {
pub version: String,
pub plan_id: String,
pub config_hash: String,
pub state_hash: String,
pub diff_hash: String,
pub sealed_at_unix: u64,
pub ttl_secs: u64,
pub seal: String,
}
pub fn clamp_ttl(requested: Option<u64>) -> u64 {
match requested {
None | Some(TTL_NO_EXPIRY) => TTL_NO_EXPIRY,
Some(secs) => secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS),
}
}
pub fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn seal_at(
plan: &ExecutionPlan,
selectors: &PlanSelectors,
config: &ForjarConfig,
state_dir: &Path,
ttl_secs: Option<u64>,
now: u64,
) -> Result<PlanSeal, String> {
let config_hash = digest::config_leg(config)?;
let state_hash = digest::state_leg(config, state_dir)?;
let diff_hash = digest::diff_leg(plan, selectors)?;
let ttl_secs = clamp_ttl(ttl_secs);
let seal = digest::compose(&config_hash, &state_hash, &diff_hash, now, ttl_secs);
Ok(PlanSeal {
version: SEAL_VERSION.to_string(),
plan_id: digest::plan_id(&seal),
config_hash,
state_hash,
diff_hash,
sealed_at_unix: now,
ttl_secs,
seal,
})
}
pub fn seal(
plan: &ExecutionPlan,
selectors: &PlanSelectors,
config: &ForjarConfig,
state_dir: &Path,
ttl_secs: Option<u64>,
) -> Result<PlanSeal, String> {
seal_at(plan, selectors, config, state_dir, ttl_secs, now_unix())
}
pub fn verify_at(
sealed: &PlanSeal,
plan: &ExecutionPlan,
selectors: &PlanSelectors,
config: &ForjarConfig,
state_dir: &Path,
now: u64,
) -> Result<(), SealError> {
check_version(sealed)?;
check_body_partition(plan)?;
check_self_consistency(sealed)?;
check_legs(sealed, plan, selectors, config, state_dir)?;
check_expiry(sealed, now)
}
pub fn verify(
sealed: &PlanSeal,
plan: &ExecutionPlan,
selectors: &PlanSelectors,
config: &ForjarConfig,
state_dir: &Path,
) -> Result<(), SealError> {
verify_at(sealed, plan, selectors, config, state_dir, now_unix())
}
fn check_version(sealed: &PlanSeal) -> Result<(), SealError> {
if sealed.version != SEAL_VERSION {
return Err(SealError::PlanVersionUnknown(sealed.version.clone()));
}
Ok(())
}
pub fn check_body_partition(plan: &ExecutionPlan) -> Result<(), SealError> {
let tally = |want: PlanAction| plan.changes.iter().filter(|c| c.action == want).count() as u32;
let expected = [
("to_create", plan.to_create, tally(PlanAction::Create)),
("to_update", plan.to_update, tally(PlanAction::Update)),
("to_destroy", plan.to_destroy, tally(PlanAction::Destroy)),
("unchanged", plan.unchanged, tally(PlanAction::NoOp)),
];
for (field, stated, actual) in expected {
if stated != actual {
return Err(SealError::PlanMalformed(format!(
"plan body is inconsistent: '{field}' says {stated} but the change \
list contains {actual} — the counters do not partition the changes"
)));
}
}
Ok(())
}
fn check_self_consistency(sealed: &PlanSeal) -> Result<(), SealError> {
let recomposed = digest::compose(
&sealed.config_hash,
&sealed.state_hash,
&sealed.diff_hash,
sealed.sealed_at_unix,
sealed.ttl_secs,
);
if recomposed != sealed.seal {
return Err(SealError::PlanHashMismatch {
leg: Leg::Seal,
expected: sealed.seal.clone(),
actual: recomposed,
});
}
Ok(())
}
fn check_legs(
sealed: &PlanSeal,
plan: &ExecutionPlan,
selectors: &PlanSelectors,
config: &ForjarConfig,
state_dir: &Path,
) -> Result<(), SealError> {
let malformed = |e: String| SealError::PlanMalformed(e);
compare(
Leg::Config,
&sealed.config_hash,
digest::config_leg(config).map_err(malformed)?,
)?;
compare(
Leg::State,
&sealed.state_hash,
digest::state_leg(config, state_dir).map_err(malformed)?,
)?;
compare(
Leg::Diff,
&sealed.diff_hash,
digest::diff_leg(plan, selectors).map_err(malformed)?,
)
}
fn compare(leg: Leg, expected: &str, actual: String) -> Result<(), SealError> {
if expected != actual {
return Err(SealError::PlanHashMismatch {
leg,
expected: expected.to_string(),
actual,
});
}
Ok(())
}
fn check_expiry(sealed: &PlanSeal, now: u64) -> Result<(), SealError> {
if sealed.ttl_secs == TTL_NO_EXPIRY {
return Ok(());
}
let expires_at = sealed.sealed_at_unix.saturating_add(sealed.ttl_secs);
if now > expires_at {
return Err(SealError::PlanExpired {
sealed_at: sealed.sealed_at_unix,
expires_at,
now,
});
}
Ok(())
}