use std::fs;
use std::path::{Path, PathBuf};
use camino::Utf8Path;
use super::{backup_dir, marker_path};
use crate::error::RkError;
use crate::maintenance::{GIT_HOOK_VARS, last_line};
use crate::probes::nix_bin;
const FILES: [&str; 2] = ["flake.nix", "flake.lock"];
const PENDING_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepFailure {
pub step: &'static str,
pub detail: String,
}
#[derive(Debug)]
pub struct Txn {
target: PathBuf,
backup: PathBuf,
marker: PathBuf,
committed: bool,
}
pub fn open(target: &Utf8Path, key: &str) -> Result<Txn, RkError> {
let (Some(backup), Some(marker)) = (backup_dir(key), marker_path(key)) else {
return Err(RkError::Io(std::io::Error::other(
"neither XDG_STATE_HOME nor HOME is set, so the transaction has no backup root",
)));
};
open_at(target, backup, marker)
}
fn open_at(target: &Utf8Path, backup: PathBuf, marker: PathBuf) -> Result<Txn, RkError> {
fs::create_dir_all(&backup)?;
for name in FILES {
let source = target.join(name);
let copy = backup.join(name);
if source.exists() {
fs::copy(&source, ©)?;
} else if copy.exists() {
fs::remove_file(©)?;
}
}
let record = serde_json::json!({
"target": target.as_str(),
"pid": std::process::id(),
"present": {
FILES[0]: target.join(FILES[0]).exists(),
FILES[1]: target.join(FILES[1]).exists(),
},
});
crate::atomic::write(&marker, record.to_string().as_bytes())?;
Ok(Txn {
target: target.as_std_path().to_path_buf(),
backup,
marker,
committed: false,
})
}
impl Txn {
pub fn commit(mut self) -> Result<(), FinishFailure> {
self.committed = true;
finish(&self.backup, &self.marker)
}
pub fn abort(mut self) -> Result<Vec<String>, AbortFailure> {
self.committed = true;
let restored =
restore(&self.target, &self.backup, &self.marker).map_err(AbortFailure::Restore)?;
finish(&self.backup, &self.marker).map_err(AbortFailure::Finish)?;
Ok(restored)
}
#[must_use]
pub fn marker(&self) -> &Path {
&self.marker
}
}
impl Drop for Txn {
fn drop(&mut self) {
if !self.committed {
if restore(&self.target, &self.backup, &self.marker).is_ok() {
let _ = finish(&self.backup, &self.marker);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbortFailure {
Restore(RestoreFailure),
Finish(FinishFailure),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinishFailure {
pub marker: PathBuf,
pub detail: String,
}
impl std::fmt::Display for FinishFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"the transaction marker {} is still active and must be removed by hand: {}",
self.marker.display(),
self.detail
)
}
}
fn finish(backup: &Path, marker: &Path) -> Result<(), FinishFailure> {
if marker.exists() {
let neutralized = crate::atomic::write(marker, FINISHED_MARKER).or_else(|_| {
fs::write(marker, FINISHED_MARKER).and_then(|()| {
fs::OpenOptions::new()
.write(true)
.open(marker)
.and_then(|file| file.sync_all())
})
});
if let Err(source) = neutralized {
return Err(FinishFailure {
marker: marker.to_path_buf(),
detail: source.to_string(),
});
}
}
for name in FILES {
let _ = fs::remove_file(backup.join(name));
}
let _ = fs::remove_dir(backup);
let _ = fs::remove_file(marker);
if let Some(parent) = marker.parent() {
let _ = fs::remove_dir(parent);
}
Ok(())
}
const FINISHED_MARKER: &[u8] = br#"{"committed":true}"#;
#[must_use]
pub fn marker_is_pending(marker: &Path) -> bool {
match fs::read(marker) {
Ok(bytes) => {
serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|record| record["committed"].as_bool())
!= Some(true)
}
Err(source) => source.kind() != std::io::ErrorKind::NotFound,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreFailure {
pub file: &'static str,
pub detail: String,
}
impl std::fmt::Display for RestoreFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} could not be restored: {}", self.file, self.detail)
}
}
fn restore(target: &Path, backup: &Path, marker: &Path) -> Result<Vec<String>, RestoreFailure> {
let record: serde_json::Value = fs::read(marker)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or(serde_json::Value::Null);
let mut restored = Vec::new();
for name in FILES {
let copy = backup.join(name);
let destination = target.join(name);
let was_present = record["present"][name].as_bool();
let outcome = if copy.exists() {
fs::read(©).and_then(|bytes| crate::atomic::write(&destination, &bytes))
} else {
match was_present {
Some(false) if destination.exists() => fs::remove_file(&destination),
Some(true) => Err(std::io::Error::other(
"the backup is missing although the file existed before the run",
)),
_ => continue,
}
};
match outcome {
Ok(()) => restored.push(name.to_owned()),
Err(source) => {
return Err(RestoreFailure {
file: name,
detail: source.to_string(),
});
}
}
}
Ok(restored)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Recovery {
Restored(Vec<String>),
Failed(RestoreFailure),
Unfinished(FinishFailure),
Finished,
}
pub fn recover_pending(target: &Utf8Path, key: &str) -> Result<Option<Recovery>, RkError> {
let (Some(backup), Some(marker)) = (backup_dir(key), marker_path(key)) else {
return Ok(None);
};
recover_at(target, &backup, &marker)
}
fn recover_at(
target: &Utf8Path,
backup: &Path,
marker: &Path,
) -> Result<Option<Recovery>, RkError> {
if !marker.exists() {
return Ok(None);
}
let record: serde_json::Value =
serde_json::from_slice(&fs::read(marker)?).unwrap_or(serde_json::Value::Null);
if record["committed"].as_bool() == Some(true) {
let _ = finish(backup, marker);
return Ok(Some(Recovery::Finished));
}
let pid = record["pid"].as_u64();
if pid.is_some() && !owner_gone(pid, marker) {
return Ok(None);
}
match restore(target.as_std_path(), backup, marker) {
Ok(restored) => match finish(backup, marker) {
Ok(()) => Ok(Some(Recovery::Restored(restored))),
Err(failure) => Ok(Some(Recovery::Unfinished(failure))),
},
Err(failure) => Ok(Some(Recovery::Failed(failure))),
}
}
pub(crate) fn owner_gone(pid: Option<u64>, marker: &Path) -> bool {
owner_gone_after(pid, marker, PENDING_GRACE)
}
pub(crate) fn owner_gone_after(
pid: Option<u64>,
marker: &Path,
grace: std::time::Duration,
) -> bool {
if let Some(pid) = pid {
if Path::new("/proc/self").is_dir() {
match Path::new(&format!("/proc/{pid}")).try_exists() {
Ok(true) => return false,
Ok(false) => return true,
Err(_) => {}
}
}
}
fs::metadata(marker)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > grace)
}
pub fn flake_update(target: &Utf8Path) -> Result<(), StepFailure> {
nix(target, "flake-update", &["flake", "update", "release-kit"]).map(|_| ())
}
pub fn current_system(target: &Utf8Path) -> Result<String, StepFailure> {
let output = nix(
target,
"current-system",
&[
"eval",
"--raw",
"--impure",
"--expr",
"builtins.currentSystem",
],
)?;
let system = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if system.is_empty() {
return Err(StepFailure {
step: "current-system",
detail: "nix eval answered no system".to_owned(),
});
}
Ok(system)
}
pub fn build_devshell(target: &Utf8Path, system: &str) -> Result<(), StepFailure> {
let attribute = format!(".#devShells.{system}.default");
nix(target, "build", &["build", "--no-link", &attribute]).map(|_| ())
}
fn nix(
target: &Utf8Path,
step: &'static str,
args: &[&str],
) -> Result<std::process::Output, StepFailure> {
let mut command = std::process::Command::new(nix_bin());
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
let output = command
.args(args)
.current_dir(target.as_std_path())
.output()
.map_err(|source| StepFailure {
step,
detail: format!("nix did not run: {source}"),
})?;
if output.status.success() {
Ok(output)
} else {
Err(StepFailure {
step,
detail: last_line(&output.stderr),
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use camino::Utf8PathBuf;
use super::{Recovery, open_at, owner_gone, recover_at};
fn scratch() -> (tempfile::TempDir, Utf8PathBuf) {
let dir = tempfile::tempdir().expect("a scratch dir exists");
let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
(dir, path)
}
#[test]
fn a_dropped_transaction_restores_and_a_committed_one_keeps() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
let lock = target.join("flake.lock");
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
assert!(marker.exists(), "an open transaction leaves its marker");
std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
std::fs::write(&lock, "{}\n").expect("writes");
drop(txn);
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"old\n"
);
assert!(
!lock.exists(),
"a lock that did not exist before is removed"
);
assert!(!marker.exists());
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
txn.commit().expect("the marker clears");
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"new\n"
);
assert!(!backup.exists(), "a committed transaction leaves no backup");
assert!(!marker.exists());
}
#[test]
fn a_marker_with_a_dead_owner_is_recovered() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
std::mem::forget(txn);
std::fs::write(&marker, r#"{"target":"t","pid":4294967295}"#).expect("writes");
let restored = recover_at(&target, &backup, &marker).expect("recovers");
assert_eq!(
restored,
Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
);
assert!(!marker.exists());
assert_eq!(
recover_at(&target, &backup, &marker).expect("recovers"),
None
);
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"old\n"
);
}
#[test]
fn a_missing_backup_for_a_present_file_deletes_nothing() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "kept\n").expect("writes");
std::fs::create_dir_all(&backup).expect("creates");
std::fs::write(
&marker,
r#"{"pid":4294967295,"present":{"flake.nix":true,"flake.lock":false}}"#,
)
.expect("writes");
let outcome = recover_at(&target, &backup, &marker).expect("judges");
assert!(
matches!(outcome, Some(Recovery::Failed(ref failure)) if failure.file == "flake.nix"),
"{outcome:?}"
);
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"kept\n"
);
assert!(marker.exists(), "the marker stays for a later recovery");
}
#[test]
fn a_failed_restore_keeps_its_material() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::remove_file(target.join("flake.nix")).expect("removes");
std::fs::create_dir(target.join("flake.nix")).expect("blocks");
let failure = txn.abort().expect_err("the restore fails");
assert!(
matches!(failure, super::AbortFailure::Restore(ref inner) if inner.file == "flake.nix"),
"{failure:?}"
);
assert!(marker.exists(), "the marker stays");
assert!(backup.join("flake.nix").exists(), "the backup stays");
}
#[cfg(unix)]
#[test]
fn an_unremovable_marker_is_neutralized_before_the_backups_go() {
use std::os::unix::fs::PermissionsExt as _;
let (_state, state) = scratch();
let (_target, target) = scratch();
let dir = state.join("txn");
std::fs::create_dir_all(&dir).expect("creates");
let backup = dir.join("backup").into_std_path_buf();
let marker = dir.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
let outcome = txn.commit();
assert!(
outcome.is_ok(),
"the marker was neutralized in place: {outcome:?}"
);
assert!(marker.exists(), "the marker could not be removed");
assert!(
!backup.join("flake.nix").exists(),
"no backup survives a commit"
);
std::fs::write(target.join("flake.nix"), "edited later\n").expect("writes");
assert_eq!(
recover_at(&target, &backup, &marker).expect("judges"),
Some(Recovery::Finished),
"a neutralized marker is a finished run, not a pending one"
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"edited later\n",
"a later edit is never overwritten"
);
}
#[test]
fn a_malformed_marker_with_backups_is_recovered_now() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
std::mem::forget(txn);
std::fs::write(&marker, "").expect("truncates");
assert_eq!(
recover_at(&target, &backup, &marker).expect("recovers"),
Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
);
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"old\n"
);
assert!(!marker.exists());
}
#[test]
fn a_finished_marker_is_not_pending() {
let (_state, state) = scratch();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
assert!(!super::marker_is_pending(&marker));
std::fs::write(&marker, r#"{"pid":1}"#).expect("writes");
assert!(super::marker_is_pending(&marker));
std::fs::write(&marker, "").expect("writes");
assert!(
super::marker_is_pending(&marker),
"a truncated marker is pending"
);
std::fs::remove_file(&marker).expect("removes");
assert!(!super::marker_is_pending(&marker));
std::fs::create_dir(&marker).expect("a directory where the marker is");
assert!(
super::marker_is_pending(&marker),
"an unreadable marker is pending"
);
}
#[test]
fn a_finished_marker_is_cleared_and_named() {
let (_state, state) = scratch();
let (_target, target) = scratch();
let backup = state.join("backup").into_std_path_buf();
let marker = state.join("pending.json").into_std_path_buf();
std::fs::create_dir_all(&state).expect("creates");
std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
assert_eq!(
recover_at(&target, &backup, &marker).expect("judges"),
Some(Recovery::Finished)
);
assert!(!marker.exists());
assert_eq!(
std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
"new\n"
);
}
#[cfg(unix)]
#[test]
fn a_marker_that_cannot_be_finished_keeps_its_backups_and_is_reported() {
use std::os::unix::fs::PermissionsExt as _;
let (_state, state) = scratch();
let (_target, target) = scratch();
let dir = state.join("txn");
std::fs::create_dir_all(&dir).expect("creates");
let backup = dir.join("backup").into_std_path_buf();
let marker = dir.join("pending.json").into_std_path_buf();
std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o444)).expect("locks");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
let outcome = txn.abort();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o644)).expect("unlocks");
assert!(
matches!(outcome, Err(super::AbortFailure::Finish(_))),
"{outcome:?}"
);
assert!(backup.join("flake.nix").exists(), "nothing was deleted");
assert!(marker.exists());
}
#[test]
fn a_live_owner_is_left_alone() {
let (_dir, dir) = scratch();
let marker = dir.join("pending.json");
std::fs::write(&marker, "{}").expect("writes");
if std::path::Path::new("/proc/self").is_dir() {
assert!(!owner_gone(
Some(u64::from(std::process::id())),
marker.as_std_path()
));
assert!(owner_gone(Some(4_294_967_295), marker.as_std_path()));
}
assert!(
!owner_gone(None, marker.as_std_path()),
"with no pid the grace period alone decides"
);
}
}