1use std::fs;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8Path;
17
18use super::{backup_dir, marker_path};
19use crate::error::RkError;
20use crate::maintenance::{GIT_HOOK_VARS, last_line};
21use crate::probes::nix_bin;
22
23const FILES: [&str; 2] = ["flake.nix", "flake.lock"];
25
26const PENDING_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct StepFailure {
33 pub step: &'static str,
35 pub detail: String,
37}
38
39#[derive(Debug)]
42pub struct Txn {
43 target: PathBuf,
44 backup: PathBuf,
45 marker: PathBuf,
46 committed: bool,
47}
48
49pub fn open(target: &Utf8Path, key: &str) -> Result<Txn, RkError> {
56 let (Some(backup), Some(marker)) = (backup_dir(key), marker_path(key)) else {
57 return Err(RkError::Io(std::io::Error::other(
58 "neither XDG_STATE_HOME nor HOME is set, so the transaction has no backup root",
59 )));
60 };
61 open_at(target, backup, marker)
62}
63
64fn open_at(target: &Utf8Path, backup: PathBuf, marker: PathBuf) -> Result<Txn, RkError> {
67 fs::create_dir_all(&backup)?;
68 for name in FILES {
69 let source = target.join(name);
70 let copy = backup.join(name);
71 if source.exists() {
72 fs::copy(&source, ©)?;
73 } else if copy.exists() {
74 fs::remove_file(©)?;
75 }
76 }
77 let record = serde_json::json!({
80 "target": target.as_str(),
81 "pid": std::process::id(),
82 "present": {
83 FILES[0]: target.join(FILES[0]).exists(),
84 FILES[1]: target.join(FILES[1]).exists(),
85 },
86 });
87 crate::atomic::write(&marker, record.to_string().as_bytes())?;
88 Ok(Txn {
89 target: target.as_std_path().to_path_buf(),
90 backup,
91 marker,
92 committed: false,
93 })
94}
95
96impl Txn {
97 pub fn commit(mut self) -> Result<(), FinishFailure> {
106 self.committed = true;
107 finish(&self.backup, &self.marker)
108 }
109
110 pub fn abort(mut self) -> Result<Vec<String>, AbortFailure> {
120 self.committed = true;
121 let restored =
122 restore(&self.target, &self.backup, &self.marker).map_err(AbortFailure::Restore)?;
123 finish(&self.backup, &self.marker).map_err(AbortFailure::Finish)?;
124 Ok(restored)
125 }
126
127 #[must_use]
129 pub fn marker(&self) -> &Path {
130 &self.marker
131 }
132}
133
134impl Drop for Txn {
135 fn drop(&mut self) {
136 if !self.committed {
137 if restore(&self.target, &self.backup, &self.marker).is_ok() {
140 let _ = finish(&self.backup, &self.marker);
141 }
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum AbortFailure {
149 Restore(RestoreFailure),
151 Finish(FinishFailure),
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct FinishFailure {
158 pub marker: PathBuf,
160 pub detail: String,
162}
163
164impl std::fmt::Display for FinishFailure {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 write!(
167 f,
168 "the transaction marker {} is still active and must be removed by hand: {}",
169 self.marker.display(),
170 self.detail
171 )
172 }
173}
174
175fn finish(backup: &Path, marker: &Path) -> Result<(), FinishFailure> {
187 if marker.exists() {
188 let neutralized = crate::atomic::write(marker, FINISHED_MARKER).or_else(|_| {
193 fs::write(marker, FINISHED_MARKER).and_then(|()| {
194 fs::OpenOptions::new()
195 .write(true)
196 .open(marker)
197 .and_then(|file| file.sync_all())
198 })
199 });
200 if let Err(source) = neutralized {
201 return Err(FinishFailure {
202 marker: marker.to_path_buf(),
203 detail: source.to_string(),
204 });
205 }
206 }
207 for name in FILES {
208 let _ = fs::remove_file(backup.join(name));
209 }
210 let _ = fs::remove_dir(backup);
211 let _ = fs::remove_file(marker);
212 if let Some(parent) = marker.parent() {
213 let _ = fs::remove_dir(parent);
214 }
215 Ok(())
216}
217
218const FINISHED_MARKER: &[u8] = br#"{"committed":true}"#;
220
221#[must_use]
228pub fn marker_is_pending(marker: &Path) -> bool {
229 match fs::read(marker) {
230 Ok(bytes) => {
231 serde_json::from_slice::<serde_json::Value>(&bytes)
232 .ok()
233 .and_then(|record| record["committed"].as_bool())
234 != Some(true)
235 }
236 Err(source) => source.kind() != std::io::ErrorKind::NotFound,
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct RestoreFailure {
243 pub file: &'static str,
245 pub detail: String,
247}
248
249impl std::fmt::Display for RestoreFailure {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 write!(f, "{} could not be restored: {}", self.file, self.detail)
252 }
253}
254
255fn restore(target: &Path, backup: &Path, marker: &Path) -> Result<Vec<String>, RestoreFailure> {
260 let record: serde_json::Value = fs::read(marker)
261 .ok()
262 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
263 .unwrap_or(serde_json::Value::Null);
264 let mut restored = Vec::new();
265 for name in FILES {
266 let copy = backup.join(name);
267 let destination = target.join(name);
268 let was_present = record["present"][name].as_bool();
269 let outcome = if copy.exists() {
270 fs::read(©).and_then(|bytes| crate::atomic::write(&destination, &bytes))
271 } else {
272 match was_present {
273 Some(false) if destination.exists() => fs::remove_file(&destination),
274 Some(true) => Err(std::io::Error::other(
275 "the backup is missing although the file existed before the run",
276 )),
277 _ => continue,
279 }
280 };
281 match outcome {
282 Ok(()) => restored.push(name.to_owned()),
283 Err(source) => {
284 return Err(RestoreFailure {
285 file: name,
286 detail: source.to_string(),
287 });
288 }
289 }
290 }
291 Ok(restored)
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum Recovery {
297 Restored(Vec<String>),
299 Failed(RestoreFailure),
301 Unfinished(FinishFailure),
304 Finished,
308}
309
310pub fn recover_pending(target: &Utf8Path, key: &str) -> Result<Option<Recovery>, RkError> {
321 let (Some(backup), Some(marker)) = (backup_dir(key), marker_path(key)) else {
322 return Ok(None);
323 };
324 recover_at(target, &backup, &marker)
325}
326
327fn recover_at(
329 target: &Utf8Path,
330 backup: &Path,
331 marker: &Path,
332) -> Result<Option<Recovery>, RkError> {
333 if !marker.exists() {
334 return Ok(None);
335 }
336 let record: serde_json::Value =
337 serde_json::from_slice(&fs::read(marker)?).unwrap_or(serde_json::Value::Null);
338 if record["committed"].as_bool() == Some(true) {
339 let _ = finish(backup, marker);
343 return Ok(Some(Recovery::Finished));
344 }
345 let pid = record["pid"].as_u64();
350 if pid.is_some() && !owner_gone(pid, marker) {
351 return Ok(None);
352 }
353 match restore(target.as_std_path(), backup, marker) {
354 Ok(restored) => match finish(backup, marker) {
355 Ok(()) => Ok(Some(Recovery::Restored(restored))),
356 Err(failure) => Ok(Some(Recovery::Unfinished(failure))),
357 },
358 Err(failure) => Ok(Some(Recovery::Failed(failure))),
359 }
360}
361
362pub(crate) fn owner_gone(pid: Option<u64>, marker: &Path) -> bool {
366 owner_gone_after(pid, marker, PENDING_GRACE)
367}
368
369pub(crate) fn owner_gone_after(
371 pid: Option<u64>,
372 marker: &Path,
373 grace: std::time::Duration,
374) -> bool {
375 if let Some(pid) = pid {
376 if Path::new("/proc/self").is_dir() {
377 match Path::new(&format!("/proc/{pid}")).try_exists() {
378 Ok(true) => return false,
379 Ok(false) => return true,
380 Err(_) => {}
381 }
382 }
383 }
384 fs::metadata(marker)
385 .and_then(|meta| meta.modified())
386 .ok()
387 .and_then(|modified| modified.elapsed().ok())
388 .is_some_and(|age| age > grace)
389}
390
391pub fn flake_update(target: &Utf8Path) -> Result<(), StepFailure> {
397 nix(target, "flake-update", &["flake", "update", "release-kit"]).map(|_| ())
398}
399
400pub fn current_system(target: &Utf8Path) -> Result<String, StepFailure> {
408 let output = nix(
409 target,
410 "current-system",
411 &[
412 "eval",
413 "--raw",
414 "--impure",
415 "--expr",
416 "builtins.currentSystem",
417 ],
418 )?;
419 let system = String::from_utf8_lossy(&output.stdout).trim().to_owned();
420 if system.is_empty() {
421 return Err(StepFailure {
422 step: "current-system",
423 detail: "nix eval answered no system".to_owned(),
424 });
425 }
426 Ok(system)
427}
428
429pub fn build_devshell(target: &Utf8Path, system: &str) -> Result<(), StepFailure> {
436 let attribute = format!(".#devShells.{system}.default");
437 nix(target, "build", &["build", "--no-link", &attribute]).map(|_| ())
438}
439
440fn nix(
444 target: &Utf8Path,
445 step: &'static str,
446 args: &[&str],
447) -> Result<std::process::Output, StepFailure> {
448 let mut command = std::process::Command::new(nix_bin());
449 for var in GIT_HOOK_VARS {
450 command.env_remove(var);
451 }
452 let output = command
453 .args(args)
454 .current_dir(target.as_std_path())
455 .output()
456 .map_err(|source| StepFailure {
457 step,
458 detail: format!("nix did not run: {source}"),
459 })?;
460 if output.status.success() {
461 Ok(output)
462 } else {
463 Err(StepFailure {
464 step,
465 detail: last_line(&output.stderr),
466 })
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 #![allow(clippy::expect_used)]
473
474 use camino::Utf8PathBuf;
475
476 use super::{Recovery, open_at, owner_gone, recover_at};
477
478 fn scratch() -> (tempfile::TempDir, Utf8PathBuf) {
479 let dir = tempfile::tempdir().expect("a scratch dir exists");
480 let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
481 (dir, path)
482 }
483
484 #[test]
486 fn a_dropped_transaction_restores_and_a_committed_one_keeps() {
487 let (_state, state) = scratch();
488 let (_target, target) = scratch();
489 let backup = state.join("backup").into_std_path_buf();
490 let marker = state.join("pending.json").into_std_path_buf();
491 let lock = target.join("flake.lock");
492 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
493 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
494 assert!(marker.exists(), "an open transaction leaves its marker");
495 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
496 std::fs::write(&lock, "{}\n").expect("writes");
497 drop(txn);
498 assert_eq!(
499 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
500 "old\n"
501 );
502 assert!(
503 !lock.exists(),
504 "a lock that did not exist before is removed"
505 );
506 assert!(!marker.exists());
507 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
508 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
509 txn.commit().expect("the marker clears");
510 assert_eq!(
511 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
512 "new\n"
513 );
514 assert!(!backup.exists(), "a committed transaction leaves no backup");
515 assert!(!marker.exists());
516 }
517
518 #[test]
519 fn a_marker_with_a_dead_owner_is_recovered() {
520 let (_state, state) = scratch();
521 let (_target, target) = scratch();
522 let backup = state.join("backup").into_std_path_buf();
523 let marker = state.join("pending.json").into_std_path_buf();
524 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
525 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
526 std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
527 std::mem::forget(txn);
529 std::fs::write(&marker, r#"{"target":"t","pid":4294967295}"#).expect("writes");
530 let restored = recover_at(&target, &backup, &marker).expect("recovers");
531 assert_eq!(
532 restored,
533 Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
534 );
535 assert!(!marker.exists());
536 assert_eq!(
537 recover_at(&target, &backup, &marker).expect("recovers"),
538 None
539 );
540 assert_eq!(
541 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
542 "old\n"
543 );
544 }
545
546 #[test]
550 fn a_missing_backup_for_a_present_file_deletes_nothing() {
551 let (_state, state) = scratch();
552 let (_target, target) = scratch();
553 let backup = state.join("backup").into_std_path_buf();
554 let marker = state.join("pending.json").into_std_path_buf();
555 std::fs::write(target.join("flake.nix"), "kept\n").expect("writes");
556 std::fs::create_dir_all(&backup).expect("creates");
557 std::fs::write(
558 &marker,
559 r#"{"pid":4294967295,"present":{"flake.nix":true,"flake.lock":false}}"#,
560 )
561 .expect("writes");
562 let outcome = recover_at(&target, &backup, &marker).expect("judges");
563 assert!(
564 matches!(outcome, Some(Recovery::Failed(ref failure)) if failure.file == "flake.nix"),
565 "{outcome:?}"
566 );
567 assert_eq!(
568 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
569 "kept\n"
570 );
571 assert!(marker.exists(), "the marker stays for a later recovery");
572 }
573
574 #[test]
577 fn a_failed_restore_keeps_its_material() {
578 let (_state, state) = scratch();
579 let (_target, target) = scratch();
580 let backup = state.join("backup").into_std_path_buf();
581 let marker = state.join("pending.json").into_std_path_buf();
582 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
583 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
584 std::fs::remove_file(target.join("flake.nix")).expect("removes");
586 std::fs::create_dir(target.join("flake.nix")).expect("blocks");
587 let failure = txn.abort().expect_err("the restore fails");
588 assert!(
589 matches!(failure, super::AbortFailure::Restore(ref inner) if inner.file == "flake.nix"),
590 "{failure:?}"
591 );
592 assert!(marker.exists(), "the marker stays");
593 assert!(backup.join("flake.nix").exists(), "the backup stays");
594 }
595
596 #[cfg(unix)]
600 #[test]
601 fn an_unremovable_marker_is_neutralized_before_the_backups_go() {
602 use std::os::unix::fs::PermissionsExt as _;
603 let (_state, state) = scratch();
604 let (_target, target) = scratch();
605 let dir = state.join("txn");
606 std::fs::create_dir_all(&dir).expect("creates");
607 let backup = dir.join("backup").into_std_path_buf();
608 let marker = dir.join("pending.json").into_std_path_buf();
609 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
610 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
611 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
612 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
613 let outcome = txn.commit();
614 assert!(
615 outcome.is_ok(),
616 "the marker was neutralized in place: {outcome:?}"
617 );
618 assert!(marker.exists(), "the marker could not be removed");
619 assert!(
620 !backup.join("flake.nix").exists(),
621 "no backup survives a commit"
622 );
623 std::fs::write(target.join("flake.nix"), "edited later\n").expect("writes");
624 assert_eq!(
625 recover_at(&target, &backup, &marker).expect("judges"),
626 Some(Recovery::Finished),
627 "a neutralized marker is a finished run, not a pending one"
628 );
629 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
630 assert_eq!(
631 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
632 "edited later\n",
633 "a later edit is never overwritten"
634 );
635 }
636
637 #[test]
640 fn a_malformed_marker_with_backups_is_recovered_now() {
641 let (_state, state) = scratch();
642 let (_target, target) = scratch();
643 let backup = state.join("backup").into_std_path_buf();
644 let marker = state.join("pending.json").into_std_path_buf();
645 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
646 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
647 std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
648 std::mem::forget(txn);
649 std::fs::write(&marker, "").expect("truncates");
650 assert_eq!(
651 recover_at(&target, &backup, &marker).expect("recovers"),
652 Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
653 );
654 assert_eq!(
655 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
656 "old\n"
657 );
658 assert!(!marker.exists());
659 }
660
661 #[test]
664 fn a_finished_marker_is_not_pending() {
665 let (_state, state) = scratch();
666 let marker = state.join("pending.json").into_std_path_buf();
667 std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
668 assert!(!super::marker_is_pending(&marker));
669 std::fs::write(&marker, r#"{"pid":1}"#).expect("writes");
670 assert!(super::marker_is_pending(&marker));
671 std::fs::write(&marker, "").expect("writes");
672 assert!(
673 super::marker_is_pending(&marker),
674 "a truncated marker is pending"
675 );
676 std::fs::remove_file(&marker).expect("removes");
677 assert!(!super::marker_is_pending(&marker));
678 std::fs::create_dir(&marker).expect("a directory where the marker is");
680 assert!(
681 super::marker_is_pending(&marker),
682 "an unreadable marker is pending"
683 );
684 }
685
686 #[test]
688 fn a_finished_marker_is_cleared_and_named() {
689 let (_state, state) = scratch();
690 let (_target, target) = scratch();
691 let backup = state.join("backup").into_std_path_buf();
692 let marker = state.join("pending.json").into_std_path_buf();
693 std::fs::create_dir_all(&state).expect("creates");
694 std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
695 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
696 assert_eq!(
697 recover_at(&target, &backup, &marker).expect("judges"),
698 Some(Recovery::Finished)
699 );
700 assert!(!marker.exists());
701 assert_eq!(
702 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
703 "new\n"
704 );
705 }
706
707 #[cfg(unix)]
710 #[test]
711 fn a_marker_that_cannot_be_finished_keeps_its_backups_and_is_reported() {
712 use std::os::unix::fs::PermissionsExt as _;
713 let (_state, state) = scratch();
714 let (_target, target) = scratch();
715 let dir = state.join("txn");
716 std::fs::create_dir_all(&dir).expect("creates");
717 let backup = dir.join("backup").into_std_path_buf();
718 let marker = dir.join("pending.json").into_std_path_buf();
719 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
720 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
721 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o444)).expect("locks");
722 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
723 let outcome = txn.abort();
724 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
725 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o644)).expect("unlocks");
726 assert!(
727 matches!(outcome, Err(super::AbortFailure::Finish(_))),
728 "{outcome:?}"
729 );
730 assert!(backup.join("flake.nix").exists(), "nothing was deleted");
731 assert!(marker.exists());
732 }
733
734 #[test]
735 fn a_live_owner_is_left_alone() {
736 let (_dir, dir) = scratch();
737 let marker = dir.join("pending.json");
738 std::fs::write(&marker, "{}").expect("writes");
739 if std::path::Path::new("/proc/self").is_dir() {
740 assert!(!owner_gone(
741 Some(u64::from(std::process::id())),
742 marker.as_std_path()
743 ));
744 assert!(owner_gone(Some(4_294_967_295), marker.as_std_path()));
745 }
746 assert!(
747 !owner_gone(None, marker.as_std_path()),
748 "with no pid the grace period alone decides"
749 );
750 }
751}