use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::ForgeError;
use crate::fsutil::{NamedFileLease, acquire_path_lock, atomic_write_file, read_to_string};
use crate::paths::journal_dir;
use crate::state::append_json_line;
use crate::util::{now_secs, valid_sha256, valid_storage_id};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum JournalPhase {
Prepared,
Stored,
Activating,
Activated,
Recorded,
RolledBack,
Abandoned,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct JournalCheckpoint {
pub(crate) run_id: String,
pub(crate) component: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) profile: Option<String>,
pub(crate) plan_hash: String,
pub(crate) config_hash: String,
pub(crate) phase: JournalPhase,
pub(crate) artifact_id: Option<String>,
pub(crate) previous_artifact_id: Option<String>,
pub(crate) activation_paths: Vec<PathBuf>,
pub(crate) updated_at: u64,
}
impl JournalCheckpoint {
pub(crate) fn new(
run_id: impl Into<String>,
component: impl Into<String>,
plan_hash: impl Into<String>,
config_hash: impl Into<String>,
profile: impl Into<String>,
) -> Self {
Self {
run_id: run_id.into(),
component: component.into(),
profile: Some(profile.into()),
plan_hash: plan_hash.into(),
config_hash: config_hash.into(),
phase: JournalPhase::Prepared,
artifact_id: None,
previous_artifact_id: None,
activation_paths: Vec::new(),
updated_at: now_secs(),
}
}
pub(crate) fn transition(&mut self, phase: JournalPhase) -> Result<(), ForgeError> {
self.set_phase(phase)?;
self.save()
}
fn set_phase(&mut self, phase: JournalPhase) -> Result<(), ForgeError> {
if !valid_transition(self.phase, phase) {
return Err(ForgeError::Config(format!(
"journal phase cannot transition from {:?} to {:?}",
self.phase, phase
)));
}
self.phase = phase;
self.updated_at = now_secs();
Ok(())
}
pub(crate) fn update_artifacts(
&mut self,
artifact_id: impl Into<String>,
previous_artifact_id: Option<String>,
activation_paths: Vec<PathBuf>,
) -> Result<(), ForgeError> {
self.artifact_id = Some(artifact_id.into());
self.previous_artifact_id = previous_artifact_id;
self.activation_paths = activation_paths;
self.updated_at = now_secs();
self.save()
}
pub(crate) fn save(&self) -> Result<(), ForgeError> {
let _lease = acquire_run_lock(&self.run_id)?;
self.save_unlocked()
}
fn save_unlocked(&self) -> Result<(), ForgeError> {
validate_checkpoint(self)?;
let path = self.path();
let bytes = serde_json::to_vec_pretty(self).map_err(|error| {
ForgeError::Parse(format!("failed to serialize journal checkpoint: {error}"))
})?;
atomic_write_file(&path, &bytes)?;
append_json_line(&self.history_path(), self)
}
pub(crate) fn path(&self) -> PathBuf {
journal_dir()
.join(&self.run_id)
.join(format!("{}.json", self.component))
}
fn history_path(&self) -> PathBuf {
journal_dir().join(&self.run_id).join("events.jsonl")
}
}
pub(crate) fn load_pending() -> Result<Vec<JournalCheckpoint>, ForgeError> {
let root = journal_dir();
if !root.is_dir() {
return Ok(Vec::new());
}
let mut pending = Vec::new();
collect_checkpoints(&root, &mut pending)?;
pending.retain(|checkpoint| {
!matches!(
checkpoint.phase,
JournalPhase::Recorded | JournalPhase::RolledBack | JournalPhase::Abandoned
)
});
pending.sort_by(|left, right| {
(&left.run_id, &left.component).cmp(&(&right.run_id, &right.component))
});
Ok(pending)
}
pub(crate) fn runs() -> Result<Vec<String>, ForgeError> {
let mut ids = std::collections::BTreeSet::new();
for checkpoint in load_pending()? {
ids.insert(checkpoint.run_id);
}
Ok(ids.into_iter().collect())
}
pub(crate) fn abandon(run_id: &str) -> Result<usize, ForgeError> {
if !valid_storage_id(run_id) {
return Err(ForgeError::Config(
"journal run_id must be a safe single-segment identifier".to_string(),
));
}
if !journal_dir().join(run_id).is_dir() {
return Ok(0);
}
let _lease = acquire_run_lock(run_id)?;
let mut checkpoints = load_pending()?
.into_iter()
.filter(|checkpoint| checkpoint.run_id == run_id)
.collect::<Vec<_>>();
for checkpoint in &mut checkpoints {
checkpoint.set_phase(JournalPhase::Abandoned)?;
checkpoint.save_unlocked()?;
}
Ok(checkpoints.len())
}
fn acquire_run_lock(run_id: &str) -> Result<NamedFileLease, ForgeError> {
acquire_path_lock(
&journal_dir().join(run_id).join("journal.lock"),
"journal run lock",
)
}
fn collect_checkpoints(root: &Path, output: &mut Vec<JournalCheckpoint>) -> Result<(), ForgeError> {
for entry in fs::read_dir(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
if metadata.file_type().is_symlink() {
return Err(ForgeError::Config(format!(
"journal does not allow symbolic links: {}",
path.display()
)));
}
if path.is_dir() {
collect_checkpoints(&path, output)?;
} else if path.extension().and_then(|value| value.to_str()) == Some("json") {
let checkpoint: JournalCheckpoint = serde_json::from_str(&read_to_string(&path)?)
.map_err(|error| {
ForgeError::Parse(format!("journal {} is corrupted: {error}", path.display()))
})?;
validate_checkpoint(&checkpoint).map_err(|error| {
ForgeError::Parse(format!(
"journal {} is semantically invalid: {error}",
path.display()
))
})?;
output.push(checkpoint);
}
}
Ok(())
}
fn validate_checkpoint(checkpoint: &JournalCheckpoint) -> Result<(), ForgeError> {
if checkpoint
.profile
.as_deref()
.is_some_and(|profile| crate::model::Profile::parse(profile).is_none())
{
return Err(ForgeError::Config(
"journal contains an invalid profile".into(),
));
}
if !valid_storage_id(&checkpoint.run_id) || !valid_storage_id(&checkpoint.component) {
return Err(ForgeError::Config(
"journal run_id and component must be safe single-segment identifiers".to_string(),
));
}
if !valid_sha256(&checkpoint.plan_hash) || !valid_sha256(&checkpoint.config_hash) {
return Err(ForgeError::Config(
"journal plan_hash and config_hash must be 64-character lowercase hexadecimal digests"
.to_string(),
));
}
let artifacts_present = checkpoint.artifact_id.is_some();
if matches!(
checkpoint.phase,
JournalPhase::Stored
| JournalPhase::Activating
| JournalPhase::Activated
| JournalPhase::Recorded
) && (!artifacts_present || checkpoint.activation_paths.is_empty())
{
return Err(ForgeError::Config(format!(
"journal {:?} phase is missing an artifact or activation path",
checkpoint.phase
)));
}
Ok(())
}
fn valid_transition(from: JournalPhase, to: JournalPhase) -> bool {
matches!(
(from, to),
(JournalPhase::Prepared, JournalPhase::Stored)
| (JournalPhase::Stored, JournalPhase::Activating)
| (JournalPhase::Activating, JournalPhase::Activated)
| (JournalPhase::Activating, JournalPhase::RolledBack)
| (JournalPhase::Activated, JournalPhase::Recorded)
| (JournalPhase::Activated, JournalPhase::RolledBack)
| (
JournalPhase::Prepared
| JournalPhase::Stored
| JournalPhase::Activating
| JournalPhase::Activated,
JournalPhase::Abandoned
)
)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::state::journal::{
JournalCheckpoint, JournalPhase, valid_transition, validate_checkpoint,
};
#[test]
fn journal_enforces_commit_point_state_machine() {
let mut checkpoint =
JournalCheckpoint::new("run", "component", hash('a'), hash('b'), "minimal");
assert!(!valid_transition(checkpoint.phase, JournalPhase::Activated));
checkpoint.phase = JournalPhase::Stored;
assert!(valid_transition(checkpoint.phase, JournalPhase::Activating));
checkpoint.phase = JournalPhase::Activated;
assert!(valid_transition(checkpoint.phase, JournalPhase::Recorded));
}
#[test]
fn checkpoint_is_strict_and_has_no_development_version_field() {
let checkpoint =
JournalCheckpoint::new("run", "component", hash('a'), hash('b'), "minimal");
let encoded = serde_json::to_string(&checkpoint).unwrap();
assert!(!encoded.contains("schema_version"));
let stale = encoded.replacen('{', "{\"schema_version\":1,", 1);
assert!(serde_json::from_str::<JournalCheckpoint>(&stale).is_err());
}
#[test]
fn checkpoint_rejects_path_traversal_and_invalid_commit_metadata() {
let mut checkpoint =
JournalCheckpoint::new("../escape", "component", hash('a'), hash('b'), "minimal");
assert!(validate_checkpoint(&checkpoint).is_err());
checkpoint.run_id = "safe-run".into();
checkpoint.phase = JournalPhase::Stored;
assert!(validate_checkpoint(&checkpoint).is_err());
checkpoint.artifact_id = Some("artifact".into());
checkpoint
.activation_paths
.push(PathBuf::from("/managed/bin/demo"));
assert!(validate_checkpoint(&checkpoint).is_ok());
}
fn hash(byte: char) -> String {
std::iter::repeat_n(byte, 64).collect()
}
#[test]
fn crash_recovery_matrix_has_only_explicit_terminal_paths() {
let allowed = [
(JournalPhase::Prepared, JournalPhase::Stored),
(JournalPhase::Stored, JournalPhase::Activating),
(JournalPhase::Activating, JournalPhase::Activated),
(JournalPhase::Activating, JournalPhase::RolledBack),
(JournalPhase::Activated, JournalPhase::Recorded),
(JournalPhase::Activated, JournalPhase::RolledBack),
];
for (from, to) in allowed {
assert!(valid_transition(from, to), "missing {from:?} -> {to:?}");
}
for phase in [
JournalPhase::Prepared,
JournalPhase::Stored,
JournalPhase::Activating,
JournalPhase::Activated,
] {
assert!(valid_transition(phase, JournalPhase::Abandoned));
}
assert!(!valid_transition(
JournalPhase::Recorded,
JournalPhase::Abandoned
));
assert!(!valid_transition(
JournalPhase::RolledBack,
JournalPhase::Abandoned
));
assert!(!valid_transition(
JournalPhase::Prepared,
JournalPhase::Activated
));
assert!(!valid_transition(
JournalPhase::Stored,
JournalPhase::Recorded
));
assert!(!valid_transition(
JournalPhase::RolledBack,
JournalPhase::Recorded
));
assert!(!valid_transition(
JournalPhase::Abandoned,
JournalPhase::Recorded
));
}
}