use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
identity::{CustomToolchain, InstallationId},
paths,
publish::PublishError,
state::{Installation, LocalState, PublicationId, PublicationRef},
utils,
};
const TOMBSTONE: &str = ".uninstalled";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OperationKind {
Install,
Uninstall,
ChannelMigrate,
}
impl std::fmt::Display for OperationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Install => "install",
Self::Uninstall => "uninstall",
Self::ChannelMigrate => "channel migration",
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JournalEntry {
pub id: String,
pub kind: OperationKind,
pub channel: semver::Version,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<CustomToolchain>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_channel: Option<semver::Version>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_publication: Option<PublicationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_publication: Option<PublicationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_installation: Option<Installation>,
}
impl JournalEntry {
pub fn install(
channel: semver::Version,
old_publication: Option<PublicationId>,
new_publication: PublicationId,
target_installation: Installation,
) -> Self {
Self {
id: utils::opaque_id(),
kind: OperationKind::Install,
channel,
custom: target_installation.custom.clone(),
previous_channel: None,
old_publication,
new_publication: Some(new_publication),
target_installation: Some(target_installation),
}
}
pub fn uninstall(channel: semver::Version, publication: Option<PublicationId>) -> Self {
Self {
id: utils::opaque_id(),
kind: OperationKind::Uninstall,
channel,
custom: None,
previous_channel: None,
old_publication: publication,
new_publication: None,
target_installation: None,
}
}
pub fn installation_id(&self) -> InstallationId {
match &self.custom {
Some(custom) => InstallationId::Custom(custom.name.clone()),
None => InstallationId::Version(self.channel.clone()),
}
}
pub fn with_previous(mut self, previous: &Installation) -> Self {
self.previous_channel =
(previous.channel != self.channel).then(|| previous.channel.clone());
self
}
pub fn old_channel(&self) -> &semver::Version {
self.previous_channel.as_ref().unwrap_or(&self.channel)
}
pub fn uninstall_installation(installation: &Installation) -> Self {
let publication = match &installation.publication {
PublicationRef::Managed { id, .. } => Some(id.clone()),
PublicationRef::NeedsReinstall => None,
};
let mut entry = Self::uninstall(installation.channel.clone(), publication);
entry.custom = installation.custom.clone();
entry
}
}
pub fn entry_path(home: &Path, id: &str) -> PathBuf {
paths::journal_dir(home).join(id).with_extension("json")
}
pub fn prepare(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
let dir = paths::journal_dir(home);
std::fs::create_dir_all(&dir)
.map_err(|source| PublishError::Journal { path: dir.clone(), source })?;
if let Some(existing) = read(home)?
&& existing.id != entry.id
{
return Err(PublishError::OperationInProgress {
operation: existing.kind,
installation: existing.installation_id(),
});
}
let path = entry_path(home, &entry.id);
crate::utils::atomic::write_validated(&path, entry, |written| {
serde_json::from_str::<JournalEntry>(written)
.map(|_| ())
.map_err(|err| format!("the result would not parse as a journal entry: {err}"))
})?;
Ok(())
}
pub fn commit_symlink(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
let link = paths::installation_link(home, &entry.installation_id());
let target = match (&entry.kind, &entry.new_publication) {
(OperationKind::Uninstall, _) | (_, None) => PathBuf::from(TOMBSTONE),
(_, Some(id)) => PathBuf::from(if entry.custom.is_some() { "../.." } else { ".." })
.join("publications")
.join(format!("{}-{id}", entry.channel)),
};
std::fs::create_dir_all(link.parent().expect("installation links have a parent"))
.map_err(|source| PublishError::Commit { path: link.clone(), source })?;
crate::trace!("committing {} to {}", link.display(), target.display());
utils::fs::replace_symlink(&link, &target).map_err(|err| PublishError::Commit {
path: link,
source: std::io::Error::other(err.to_string()),
})
}
pub fn record(
home: &Path,
entry: &JournalEntry,
state: &mut LocalState,
) -> Result<(), PublishError> {
match entry.kind {
OperationKind::Install | OperationKind::ChannelMigrate => {
if let Some(installation) = entry.target_installation.clone() {
state.upsert(installation);
}
},
OperationKind::Uninstall => state.remove_by_id(&entry.installation_id()),
}
state
.save(&paths::state_path(home))
.map_err(|err| PublishError::Record { reason: err.to_string() })
}
pub fn clean(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
if let Some(old) = &entry.old_publication
&& matches!(entry.kind, OperationKind::Uninstall)
{
let publication = paths::publication_dir(home, entry.old_channel(), old);
crate::trace!("removing {}", publication.display());
let _ = std::fs::remove_dir_all(publication);
}
if matches!(entry.kind, OperationKind::Uninstall) {
if entry.custom.is_none() {
for (network, linked) in crate::networks::links(home) {
if linked == entry.channel {
let link = paths::network_link(home, &network);
crate::trace!("removing {}", link.display());
let _ = std::fs::remove_file(&link);
}
}
}
let link = paths::installation_link(home, &entry.installation_id());
if is_tombstone(&link) {
let _ = std::fs::remove_file(&link);
}
crate::fault::fail_at(crate::fault::FaultPoint::PostUninstallTombstone)?;
}
let path = entry_path(home, &entry.id);
std::fs::remove_file(&path).map_err(|source| PublishError::Journal { path, source })
}
pub fn finish(
home: &Path,
entry: &JournalEntry,
state: &mut LocalState,
) -> Result<(), PublishError> {
record(home, entry, state)?;
clean(home, entry)
}
pub fn recover(home: &Path, state: &mut LocalState) -> Result<Option<OperationKind>, PublishError> {
let Some(entry) = read(home)? else {
return check_divergence(home, state).map(|_| None);
};
let link = paths::installation_link(home, &entry.installation_id());
let committed = match entry.kind {
OperationKind::Uninstall => is_tombstone(&link),
OperationKind::Install | OperationKind::ChannelMigrate => entry
.new_publication
.as_ref()
.is_some_and(|id| points_at(&link, &format!("{}-{id}", entry.channel))),
};
if committed {
crate::trace!(
"the interrupted {} of {} was committed; completing it",
entry.kind,
entry.installation_id()
);
finish(home, &entry, state)?;
return Ok(Some(entry.kind));
}
crate::trace!(
"the interrupted {} of {} never committed; discarding it",
entry.kind,
entry.installation_id()
);
if let Some(new) = &entry.new_publication {
let publication = paths::publication_dir(home, &entry.channel, new);
crate::trace!("removing {}", publication.display());
let _ = std::fs::remove_dir_all(publication);
}
let path = entry_path(home, &entry.id);
crate::trace!("removing {}", path.display());
std::fs::remove_file(&path).map_err(|source| PublishError::Journal { path, source })?;
Ok(Some(entry.kind))
}
pub fn read(home: &Path) -> Result<Option<JournalEntry>, PublishError> {
let dir = paths::journal_dir(home);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(PublishError::Journal { path: dir, source }),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let contents = std::fs::read_to_string(&path)
.map_err(|source| PublishError::Journal { path: path.clone(), source })?;
let parsed = serde_json::from_str(&contents)
.map_err(|err| PublishError::InvalidJournal { path, reason: err.to_string() })?;
return Ok(Some(parsed));
}
Ok(None)
}
fn points_at(link: &Path, name: &str) -> bool {
std::fs::read_link(link)
.ok()
.and_then(|target| target.file_name().map(|n| n == std::ffi::OsStr::new(name)))
.unwrap_or(false)
}
fn is_tombstone(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target == Path::new(TOMBSTONE))
}
fn check_divergence(home: &Path, state: &LocalState) -> Result<(), PublishError> {
for installation in &state.installations {
let PublicationRef::Managed { id, .. } = &installation.publication else {
continue;
};
let dir = paths::publication_dir(home, &installation.channel, id);
if !dir.is_dir() {
return Err(PublishError::DivergentState {
installation: installation.id(),
detail: format!("its publication '{}' is missing", dir.display()),
remediation: format!("midenup install {}", installation.id()),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{plan::PlanKey, profile::Profile, resolve::Intent};
struct Env {
_temp: tempdir::TempDir,
home: PathBuf,
}
impl Env {
fn with_installed(channel: &str) -> (Self, PublicationId) {
let temp = tempdir::TempDir::new("journal").unwrap();
let home = temp.path().join("midenup");
let channel = semver::Version::parse(channel).unwrap();
let id = PublicationId::generate();
let publication = paths::publication_dir(&home, &channel, &id);
std::fs::create_dir_all(&publication).unwrap();
std::fs::create_dir_all(paths::toolchains_dir(&home)).unwrap();
utils::fs::symlink(
&paths::toolchain_link(&home, &channel),
&PathBuf::from("..").join("publications").join(publication.file_name().unwrap()),
)
.unwrap();
let mut state = LocalState::default();
state.upsert(installation(&channel, &id));
state.save(&paths::state_path(&home)).unwrap();
(Env { _temp: temp, home }, id)
}
fn state(&self) -> LocalState {
LocalState::load(&paths::state_path(&self.home)).unwrap()
}
fn journal_is_empty(&self) -> bool {
read(&self.home).unwrap().is_none()
}
}
fn plan_key() -> PlanKey {
serde_json::from_str(&format!("\"pk1:{}\"", "a".repeat(64))).unwrap()
}
fn installation(channel: &semver::Version, id: &PublicationId) -> Installation {
Installation {
channel: channel.clone(),
custom: None,
intent: Intent::new(&[Profile::Minimal], &[]),
components: vec![],
publication: PublicationRef::Managed {
id: id.clone(),
plan_key: plan_key(),
target: "aarch64-apple-darwin".to_string(),
},
installed_at: 1735689600,
midenup_version: None,
patches: Default::default(),
}
}
fn v(version: &str) -> semver::Version {
semver::Version::parse(version).unwrap()
}
fn stage_publication(home: &Path, channel: &semver::Version, id: &PublicationId) {
std::fs::create_dir_all(paths::publication_dir(home, channel, id)).unwrap();
}
#[test]
fn named_recovery_and_uninstall_leave_canonical_and_other_names_intact() {
let (env, canonical_publication) = Env::with_installed("0.15.0");
let channel = v("0.15.0");
utils::fs::symlink(&paths::network_link(&env.home, "mainnet"), Path::new("0.15.0"))
.unwrap();
let mut state = env.state();
for name in ["first", "second"] {
let publication = PublicationId::generate();
let mut named = installation(&channel, &publication);
named.custom = Some(CustomToolchain {
name: name.parse().unwrap(),
channel: "mainnet".parse().unwrap(),
});
let entry =
JournalEntry::install(channel.clone(), None, publication.clone(), named.clone());
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &channel, &publication);
commit_symlink(&env.home, &entry).unwrap();
recover(&env.home, &mut state).unwrap();
assert_eq!(
std::fs::canonicalize(paths::installation_link(&env.home, &named.id())).unwrap(),
std::fs::canonicalize(paths::publication_dir(&env.home, &channel, &publication))
.unwrap()
);
}
assert_eq!(state.installations.len(), 3);
let first = state
.get_by_id(&InstallationId::Custom("first".parse().unwrap()))
.unwrap()
.clone();
let entry = JournalEntry::uninstall_installation(&first);
prepare(&env.home, &entry).unwrap();
commit_symlink(&env.home, &entry).unwrap();
recover(&env.home, &mut state).unwrap();
assert_eq!(state.installations.len(), 2);
assert!(state.get_by_id(&first.id()).is_none());
assert!(state.get_by_id(&InstallationId::Custom("second".parse().unwrap())).is_some());
assert!(paths::network_link(&env.home, "mainnet").exists());
assert!(paths::toolchain_link(&env.home, &channel).exists());
assert!(paths::publication_dir(&env.home, &channel, &canonical_publication).exists());
}
#[test]
fn interrupted_named_channel_advance_preserves_both_publications_until_clean() {
let (env, _) = Env::with_installed("0.15.0");
let mut state = env.state();
let old_publication = PublicationId::generate();
let mut previous = installation(&v("0.15.0"), &old_publication);
previous.custom = Some(CustomToolchain {
name: "dev".parse().unwrap(),
channel: "mainnet".parse().unwrap(),
});
stage_publication(&env.home, &previous.channel, &old_publication);
state.upsert(previous.clone());
let new_publication = PublicationId::generate();
let mut next = previous.clone();
next.channel = v("0.16.0");
next.publication = installation(&next.channel, &new_publication).publication;
let entry = JournalEntry::install(
next.channel.clone(),
Some(old_publication.clone()),
new_publication.clone(),
next.clone(),
)
.with_previous(&previous);
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &next.channel, &new_publication);
commit_symlink(&env.home, &entry).unwrap();
record(&env.home, &entry, &mut state).unwrap();
assert!(crate::publish::unreferenced(&env.home, &state).unwrap().is_empty());
recover(&env.home, &mut state).unwrap();
assert_eq!(state.get_by_id(&next.id()).unwrap().channel, v("0.16.0"));
assert!(state.get(&v("0.15.0")).is_some());
assert_eq!(
crate::publish::unreferenced(&env.home, &state).unwrap(),
vec![paths::publication_dir(&env.home, &previous.channel, &old_publication)]
);
}
#[test]
fn before_the_symlink_commit_recovery_discards_and_keeps_the_old_state() {
let (env, _) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry =
JournalEntry::install(v("0.16.0"), None, new.clone(), installation(&v("0.16.0"), &new));
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.16.0"), &new);
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.16.0")).is_none(), "uncommitted operation must be discarded");
assert!(state.get(&v("0.15.0")).is_some(), "prior installation must survive");
assert!(
!paths::publication_dir(&env.home, &v("0.16.0"), &new).exists(),
"the staged publication must be discarded"
);
assert!(env.journal_is_empty());
}
#[test]
fn after_the_symlink_commit_recovery_rolls_forward() {
let (env, _) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry =
JournalEntry::install(v("0.16.0"), None, new.clone(), installation(&v("0.16.0"), &new));
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.16.0"), &new);
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
assert!(state.get(&v("0.16.0")).is_none(), "state must not know about it yet");
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.16.0")).is_some(), "committed operation must be completed");
assert_eq!(
env.state().get(&v("0.16.0")).map(|i| i.channel.clone()),
Some(v("0.16.0")),
"and must be persisted, not merely applied in memory"
);
assert!(env.journal_is_empty());
}
#[test]
fn a_replaced_publication_is_left_for_gc() {
let (env, old) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry = JournalEntry::install(
v("0.15.0"),
Some(old.clone()),
new.clone(),
installation(&v("0.15.0"), &new),
);
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.15.0"), &new);
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(
paths::publication_dir(&env.home, &v("0.15.0"), &old).exists(),
"the replaced publication must survive; nothing may be pulled out from under a \
running process"
);
assert!(paths::publication_dir(&env.home, &v("0.15.0"), &new).exists());
}
#[test]
fn a_tombstoned_symlink_completes_the_uninstall() {
let (env, old) = Env::with_installed("0.15.0");
let entry = JournalEntry::uninstall(v("0.15.0"), Some(old.clone()));
utils::fs::symlink(&paths::network_link(&env.home, "mainnet"), &PathBuf::from("0.15.0"))
.unwrap();
prepare(&env.home, &entry).unwrap();
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.15.0")).is_none());
assert!(!paths::publication_dir(&env.home, &v("0.15.0"), &old).exists());
assert!(
std::fs::symlink_metadata(paths::toolchain_link(&env.home, &v("0.15.0"))).is_err(),
"the tombstone must be cleaned up once the removal is recorded"
);
assert!(
std::fs::symlink_metadata(paths::network_link(&env.home, "mainnet")).is_err(),
"a network link naming the removed channel must be cleaned up with it"
);
assert!(env.journal_is_empty());
}
#[test]
fn an_uncommitted_uninstall_leaves_the_channel_installed() {
let (env, old) = Env::with_installed("0.15.0");
let entry = JournalEntry::uninstall(v("0.15.0"), Some(old.clone()));
prepare(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.15.0")).is_some());
assert!(paths::publication_dir(&env.home, &v("0.15.0"), &old).exists());
}
#[test]
fn an_absent_journal_recovers_nothing() {
let (env, _) = Env::with_installed("0.15.0");
let mut state = env.state();
assert_eq!(recover(&env.home, &mut state).unwrap(), None);
}
#[test]
fn divergence_without_a_journal_is_reported_not_guessed() {
let (env, old) = Env::with_installed("0.15.0");
std::fs::remove_dir_all(paths::publication_dir(&env.home, &v("0.15.0"), &old)).unwrap();
let mut state = env.state();
let err = recover(&env.home, &mut state).expect_err("must report");
assert!(matches!(err, PublishError::DivergentState { .. }), "{err}");
assert!(
err.to_string().contains("midenup install 0.15.0"),
"the diagnostic must name the exact recovery command: {err}"
);
}
#[test]
fn a_second_operation_cannot_start_while_one_is_journalled() {
let (env, _) = Env::with_installed("0.15.0");
let first = JournalEntry::uninstall(v("0.15.0"), None);
prepare(&env.home, &first).unwrap();
let second = JournalEntry::uninstall(v("0.16.0"), None);
assert!(matches!(
prepare(&env.home, &second),
Err(PublishError::OperationInProgress { .. })
));
prepare(&env.home, &first).expect("re-preparing the same operation must be allowed");
}
}