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 use camino::Utf8PathBuf;
473
474 use super::{Recovery, open_at, owner_gone, recover_at};
475
476 fn scratch() -> (tempfile::TempDir, Utf8PathBuf) {
477 let dir = tempfile::tempdir().expect("a scratch dir exists");
478 let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
479 (dir, path)
480 }
481
482 #[test]
484 fn a_dropped_transaction_restores_and_a_committed_one_keeps() {
485 let (_state, state) = scratch();
486 let (_target, target) = scratch();
487 let backup = state.join("backup").into_std_path_buf();
488 let marker = state.join("pending.json").into_std_path_buf();
489 let lock = target.join("flake.lock");
490 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
491 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
492 assert!(marker.exists(), "an open transaction leaves its marker");
493 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
494 std::fs::write(&lock, "{}\n").expect("writes");
495 drop(txn);
496 assert_eq!(
497 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
498 "old\n"
499 );
500 assert!(
501 !lock.exists(),
502 "a lock that did not exist before is removed"
503 );
504 assert!(!marker.exists());
505 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
506 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
507 txn.commit().expect("the marker clears");
508 assert_eq!(
509 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
510 "new\n"
511 );
512 assert!(!backup.exists(), "a committed transaction leaves no backup");
513 assert!(!marker.exists());
514 }
515
516 #[test]
517 fn a_marker_with_a_dead_owner_is_recovered() {
518 let (_state, state) = scratch();
519 let (_target, target) = scratch();
520 let backup = state.join("backup").into_std_path_buf();
521 let marker = state.join("pending.json").into_std_path_buf();
522 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
523 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
524 std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
525 std::mem::forget(txn);
527 std::fs::write(&marker, r#"{"target":"t","pid":4294967295}"#).expect("writes");
528 let restored = recover_at(&target, &backup, &marker).expect("recovers");
529 assert_eq!(
530 restored,
531 Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
532 );
533 assert!(!marker.exists());
534 assert_eq!(
535 recover_at(&target, &backup, &marker).expect("recovers"),
536 None
537 );
538 assert_eq!(
539 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
540 "old\n"
541 );
542 }
543
544 #[test]
548 fn a_missing_backup_for_a_present_file_deletes_nothing() {
549 let (_state, state) = scratch();
550 let (_target, target) = scratch();
551 let backup = state.join("backup").into_std_path_buf();
552 let marker = state.join("pending.json").into_std_path_buf();
553 std::fs::write(target.join("flake.nix"), "kept\n").expect("writes");
554 std::fs::create_dir_all(&backup).expect("creates");
555 std::fs::write(
556 &marker,
557 r#"{"pid":4294967295,"present":{"flake.nix":true,"flake.lock":false}}"#,
558 )
559 .expect("writes");
560 let outcome = recover_at(&target, &backup, &marker).expect("judges");
561 assert!(
562 matches!(outcome, Some(Recovery::Failed(ref failure)) if failure.file == "flake.nix"),
563 "{outcome:?}"
564 );
565 assert_eq!(
566 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
567 "kept\n"
568 );
569 assert!(marker.exists(), "the marker stays for a later recovery");
570 }
571
572 #[test]
575 fn a_failed_restore_keeps_its_material() {
576 let (_state, state) = scratch();
577 let (_target, target) = scratch();
578 let backup = state.join("backup").into_std_path_buf();
579 let marker = state.join("pending.json").into_std_path_buf();
580 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
581 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
582 std::fs::remove_file(target.join("flake.nix")).expect("removes");
584 std::fs::create_dir(target.join("flake.nix")).expect("blocks");
585 let failure = txn.abort().expect_err("the restore fails");
586 assert!(
587 matches!(failure, super::AbortFailure::Restore(ref inner) if inner.file == "flake.nix"),
588 "{failure:?}"
589 );
590 assert!(marker.exists(), "the marker stays");
591 assert!(backup.join("flake.nix").exists(), "the backup stays");
592 }
593
594 #[cfg(unix)]
598 #[test]
599 fn an_unremovable_marker_is_neutralized_before_the_backups_go() {
600 use std::os::unix::fs::PermissionsExt as _;
601 let (_state, state) = scratch();
602 let (_target, target) = scratch();
603 let dir = state.join("txn");
604 std::fs::create_dir_all(&dir).expect("creates");
605 let backup = dir.join("backup").into_std_path_buf();
606 let marker = dir.join("pending.json").into_std_path_buf();
607 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
608 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
609 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
610 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
611 let outcome = txn.commit();
612 assert!(
613 outcome.is_ok(),
614 "the marker was neutralized in place: {outcome:?}"
615 );
616 assert!(marker.exists(), "the marker could not be removed");
617 assert!(
618 !backup.join("flake.nix").exists(),
619 "no backup survives a commit"
620 );
621 std::fs::write(target.join("flake.nix"), "edited later\n").expect("writes");
622 assert_eq!(
623 recover_at(&target, &backup, &marker).expect("judges"),
624 Some(Recovery::Finished),
625 "a neutralized marker is a finished run, not a pending one"
626 );
627 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
628 assert_eq!(
629 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
630 "edited later\n",
631 "a later edit is never overwritten"
632 );
633 }
634
635 #[test]
638 fn a_malformed_marker_with_backups_is_recovered_now() {
639 let (_state, state) = scratch();
640 let (_target, target) = scratch();
641 let backup = state.join("backup").into_std_path_buf();
642 let marker = state.join("pending.json").into_std_path_buf();
643 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
644 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
645 std::fs::write(target.join("flake.nix"), "half\n").expect("writes");
646 std::mem::forget(txn);
647 std::fs::write(&marker, "").expect("truncates");
648 assert_eq!(
649 recover_at(&target, &backup, &marker).expect("recovers"),
650 Some(Recovery::Restored(vec!["flake.nix".to_owned()]))
651 );
652 assert_eq!(
653 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
654 "old\n"
655 );
656 assert!(!marker.exists());
657 }
658
659 #[test]
662 fn a_finished_marker_is_not_pending() {
663 let (_state, state) = scratch();
664 let marker = state.join("pending.json").into_std_path_buf();
665 std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
666 assert!(!super::marker_is_pending(&marker));
667 std::fs::write(&marker, r#"{"pid":1}"#).expect("writes");
668 assert!(super::marker_is_pending(&marker));
669 std::fs::write(&marker, "").expect("writes");
670 assert!(
671 super::marker_is_pending(&marker),
672 "a truncated marker is pending"
673 );
674 std::fs::remove_file(&marker).expect("removes");
675 assert!(!super::marker_is_pending(&marker));
676 std::fs::create_dir(&marker).expect("a directory where the marker is");
678 assert!(
679 super::marker_is_pending(&marker),
680 "an unreadable marker is pending"
681 );
682 }
683
684 #[test]
686 fn a_finished_marker_is_cleared_and_named() {
687 let (_state, state) = scratch();
688 let (_target, target) = scratch();
689 let backup = state.join("backup").into_std_path_buf();
690 let marker = state.join("pending.json").into_std_path_buf();
691 std::fs::create_dir_all(&state).expect("creates");
692 std::fs::write(&marker, super::FINISHED_MARKER).expect("writes");
693 std::fs::write(target.join("flake.nix"), "new\n").expect("writes");
694 assert_eq!(
695 recover_at(&target, &backup, &marker).expect("judges"),
696 Some(Recovery::Finished)
697 );
698 assert!(!marker.exists());
699 assert_eq!(
700 std::fs::read_to_string(target.join("flake.nix")).expect("reads"),
701 "new\n"
702 );
703 }
704
705 #[cfg(unix)]
708 #[test]
709 fn a_marker_that_cannot_be_finished_keeps_its_backups_and_is_reported() {
710 use std::os::unix::fs::PermissionsExt as _;
711 let (_state, state) = scratch();
712 let (_target, target) = scratch();
713 let dir = state.join("txn");
714 std::fs::create_dir_all(&dir).expect("creates");
715 let backup = dir.join("backup").into_std_path_buf();
716 let marker = dir.join("pending.json").into_std_path_buf();
717 std::fs::write(target.join("flake.nix"), "old\n").expect("writes");
718 let txn = open_at(&target, backup.clone(), marker.clone()).expect("opens");
719 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o444)).expect("locks");
720 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("locks");
721 let outcome = txn.abort();
722 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlocks");
723 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o644)).expect("unlocks");
724 assert!(
725 matches!(outcome, Err(super::AbortFailure::Finish(_))),
726 "{outcome:?}"
727 );
728 assert!(backup.join("flake.nix").exists(), "nothing was deleted");
729 assert!(marker.exists());
730 }
731
732 #[test]
733 fn a_live_owner_is_left_alone() {
734 let (_dir, dir) = scratch();
735 let marker = dir.join("pending.json");
736 std::fs::write(&marker, "{}").expect("writes");
737 if std::path::Path::new("/proc/self").is_dir() {
738 assert!(!owner_gone(
739 Some(u64::from(std::process::id())),
740 marker.as_std_path()
741 ));
742 assert!(owner_gone(Some(4_294_967_295), marker.as_std_path()));
743 }
744 assert!(
745 !owner_gone(None, marker.as_std_path()),
746 "with no pid the grace period alone decides"
747 );
748 }
749}