use crate::core::types::{ForjarConfig, Resource, ResourceType};
use std::collections::{BTreeMap, BTreeSet};
mod checkers;
#[cfg(test)]
mod tests;
pub use checkers::{
acyclicity, conflict_freedom, dependency_completeness, has_conflict, input_purity,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assurance {
Proved,
Checked,
Unknown,
Falsified,
}
impl Assurance {
#[must_use]
pub fn badge(self) -> &'static str {
match self {
Self::Proved => "PROVED",
Self::Checked => "CHECKED",
Self::Unknown => "UNKNOWN",
Self::Falsified => "FALSIFIED",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
Hard,
Advisory,
}
#[derive(Debug, Clone)]
pub struct InvariantResult {
pub id: &'static str,
pub name: &'static str,
pub class: Class,
pub state: Assurance,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct ProofReport {
pub file: String,
pub resource_count: usize,
pub declarative: usize,
pub opaque: usize,
pub invariants: Vec<InvariantResult>,
pub plan_hash: String,
}
impl ProofReport {
#[must_use]
pub fn passed(&self) -> bool {
!self
.invariants
.iter()
.any(|i| i.class == Class::Hard && i.state == Assurance::Falsified)
}
#[must_use]
pub fn has_opaque(&self) -> bool {
self.opaque > 0
}
}
#[must_use]
pub fn is_opaque(t: &ResourceType) -> bool {
matches!(
t,
ResourceType::Cron | ResourceType::Task | ResourceType::Recipe | ResourceType::Build
)
}
#[must_use]
pub fn resource_targets(r: &Resource) -> Option<Vec<String>> {
if is_opaque(&r.resource_type) {
return None;
}
let mut out = Vec::new();
match &r.resource_type {
ResourceType::File => {
if let Some(p) = &r.path {
out.push(format!("path:{}", normalize_path(p)));
}
}
ResourceType::Mount => {
if let Some(t) = r.target.as_ref().or(r.path.as_ref()) {
out.push(format!("mount:{}", normalize_path(t)));
}
}
ResourceType::Package => {
for p in &r.packages {
out.push(format!("pkg:{p}"));
}
}
ResourceType::Service => {
if let Some(n) = &r.name {
out.push(format!("svc:{n}"));
}
}
ResourceType::User => {
if let Some(n) = &r.name {
out.push(format!("user:{n}"));
}
}
ResourceType::Network | ResourceType::OverlayInterface => {
if let Some(n) = r.name.as_ref().or(r.target.as_ref()) {
out.push(format!("net:{n}"));
}
}
_ => {
if let Some(n) = r.name.as_ref().or(r.target.as_ref()).or(r.path.as_ref()) {
out.push(format!("res:{n}"));
}
}
}
Some(out)
}
#[must_use]
pub fn normalize_path(p: &str) -> String {
let mut segs: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => {
segs.pop();
}
s => segs.push(s),
}
}
let lead = if p.starts_with('/') { "/" } else { "" };
format!("{lead}{}", segs.join("/"))
}
#[must_use]
pub fn prove(cfg: &ForjarConfig, file: &str) -> ProofReport {
let declarative = cfg
.resources
.values()
.filter(|r| !is_opaque(&r.resource_type))
.count();
let opaque = cfg.resources.len() - declarative;
let invariants = vec![
acyclicity(cfg),
dependency_completeness(cfg),
conflict_freedom(cfg),
checkers::idempotency(cfg),
checkers::protected_safety(cfg),
input_purity(cfg),
];
ProofReport {
file: file.to_string(),
resource_count: cfg.resources.len(),
declarative,
opaque,
invariants,
plan_hash: plan_hash(cfg),
}
}
#[must_use]
pub fn plan_hash(cfg: &ForjarConfig) -> String {
let mut items: BTreeMap<&String, String> = BTreeMap::new();
for (k, r) in &cfg.resources {
let targets = resource_targets(r).unwrap_or_default().join(",");
let mut deps: Vec<&String> = r.depends_on.iter().collect();
deps.sort();
let deps: BTreeSet<&&String> = deps.iter().collect();
items.insert(
k,
format!(
"{}|{}|{targets}|{}",
r.resource_type,
r.machine,
deps.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(",")
),
);
}
let mut hasher = blake3::Hasher::new();
for (k, v) in &items {
hasher.update(k.as_bytes());
hasher.update(b"\0");
hasher.update(v.as_bytes());
hasher.update(b"\n");
}
format!("b3:{}", &hasher.finalize().to_hex()[..16])
}