Skip to main content

release_kit/devshell/
txn.rs

1//! The fenced two-file transaction behind `rk devshell sync`.
2//!
3//! The tag in `flake.nix` and the `release-kit` node in `flake.lock`
4//! move together or not at all. Before the first write both files are
5//! copied under the state root and a marker names the target and this
6//! process; a build inside the transaction is the fence, so a pin that
7//! does not build against the consumer's own nixpkgs never reaches the
8//! tree. Any failure restores both files through the `Drop` guard, which
9//! covers every `?`, every early return, and a panic. The crate forbids
10//! `unsafe`, so no signal handler exists: a terminal interrupt during
11//! the build leaves the marker, and the next run recovers from it.
12
13use 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
23/// The two files the transaction guards, in restore order.
24const FILES: [&str; 2] = ["flake.nix", "flake.lock"];
25
26/// A marker older than this whose owner procfs cannot judge is recovered
27/// anyway: a build does not take a day.
28const PENDING_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
29
30/// One failed step: which, and the child's last stderr line.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct StepFailure {
33    /// `flake-update`, `current-system`, or `build`.
34    pub step: &'static str,
35    /// The child's last non-empty stderr line, or why it did not run.
36    pub detail: String,
37}
38
39/// An open transaction: the backups exist and the marker names this
40/// process. Dropping it without `commit` restores both files.
41#[derive(Debug)]
42pub struct Txn {
43    target: PathBuf,
44    backup: PathBuf,
45    marker: PathBuf,
46    committed: bool,
47}
48
49/// Open a transaction over the target's two files.
50///
51/// # Errors
52///
53/// Returns [`RkError::Io`] where the state root is unknown, the backup
54/// cannot be written, or `flake.nix` cannot be copied.
55pub 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
64/// [`open`] with the state paths named, which is what the unit tests
65/// use in place of the environment.
66fn 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, &copy)?;
73        } else if copy.exists() {
74            fs::remove_file(&copy)?;
75        }
76    }
77    // The marker records which files existed, so a restore never reads a
78    // missing backup as "the file was absent" and deletes a real file.
79    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    /// Keep the new contents and finish the transaction.
98    ///
99    /// # Errors
100    ///
101    /// The finishing failure: the marker could be neither neutralized
102    /// nor removed, so it is still active beside its backups, and the
103    /// caller must name it, because the next recovery would roll the
104    /// finished bump back.
105    pub fn commit(mut self) -> Result<(), FinishFailure> {
106        self.committed = true;
107        finish(&self.backup, &self.marker)
108    }
109
110    /// Put both files back and finish the transaction; the names restored.
111    ///
112    /// # Errors
113    ///
114    /// Where a file cannot be put back, the marker and the backups stay
115    /// active for the next run's recovery. Where the files are back and
116    /// the marker cannot be finished, the error says so: an active marker
117    /// beside its backups would overwrite later edits on the next
118    /// recovery.
119    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    /// The marker's path, for a report that must name it.
128    #[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            // Best effort on an unwinding path: a restore that fails, or a
138            // finish that fails, keeps the material for the next run.
139            if restore(&self.target, &self.backup, &self.marker).is_ok() {
140                let _ = finish(&self.backup, &self.marker);
141            }
142        }
143    }
144}
145
146/// Why an abort did not leave the target clean.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum AbortFailure {
149    /// A file is not back; the marker and backups stay for recovery.
150    Restore(RestoreFailure),
151    /// Both files are back, and the marker is still active.
152    Finish(FinishFailure),
153}
154
155/// A marker that could be neither neutralized nor removed.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct FinishFailure {
158    /// The marker still active.
159    pub marker: PathBuf,
160    /// Why, one line.
161    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
175/// Finish a transaction whose files are final — new after a commit, old
176/// after a restore. The order is the discipline: first neutralize the
177/// marker durably in place, so an interruption anywhere after this
178/// point leaves a marker every later run reads as finished; then drop
179/// the backups; then remove the marker. Nothing is deleted before the
180/// marker is safe.
181///
182/// # Errors
183///
184/// The neutralizing write's failure: nothing was deleted, the marker is
185/// still active beside its backups, and the caller names it.
186fn finish(backup: &Path, marker: &Path) -> Result<(), FinishFailure> {
187    if marker.exists() {
188        // Whole or not at all: a temp file and a rename, so no
189        // interruption leaves a truncated marker. Where the directory
190        // refuses the rename, the in-place write is the last resort, and
191        // a malformed marker is read as recoverable anyway.
192        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
218/// The bytes a finished marker holds; a recovery reads it as done.
219const FINISHED_MARKER: &[u8] = br#"{"committed":true}"#;
220
221/// Whether a marker names a run still to recover.
222///
223/// It exists and is not a finished one. A finished marker that could not
224/// be removed is residue, never a pending run, so it blocks nothing. Only
225/// an absent marker reads as not pending; a marker that exists and cannot
226/// be read is pending, because nothing proves it finished.
227#[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/// A restore that could not put a file back; the backups stay.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct RestoreFailure {
243    /// The file that is not back.
244    pub file: &'static str,
245    /// Why, one line.
246    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
255/// Restore both files from the backup. A backed-up file is copied back;
256/// a file the marker records as absent is removed; a file the marker
257/// records as present with no backup to read is corruption, and the
258/// restore stops there with the material kept. The names touched.
259fn 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(&copy).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                // No marker knowledge and no backup: leave the file alone.
278                _ => 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/// What a recovery attempt found.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum Recovery {
297    /// Both files are back and the marker is cleared; the names restored.
298    Restored(Vec<String>),
299    /// A file could not be put back; the marker and backups stay.
300    Failed(RestoreFailure),
301    /// Both files are back, and the marker could not be finished: it is
302    /// still active, and the next recovery would overwrite later edits.
303    Unfinished(FinishFailure),
304    /// A finished transaction's marker was still there; nothing was
305    /// restored, and the marker is gone or stays as residue that never
306    /// reads as pending.
307    Finished,
308}
309
310/// Recover a transaction an earlier run left open, where its owner is
311/// provably gone.
312///
313/// `Ok(None)` where no marker exists or the owner may still be running.
314/// The caller holds the checkout's lock, so two runs never recover the
315/// same marker at once.
316///
317/// # Errors
318///
319/// Returns [`RkError::Io`] where the marker exists and does not read.
320pub 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
327/// [`recover_pending`] with the state paths named.
328fn 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        // A finished transaction whose marker could not be removed at
340        // the time: finish it now, and recover nothing. Where it still
341        // cannot go, it stays as residue that never reads as pending.
342        let _ = finish(backup, marker);
343        return Ok(Some(Recovery::Finished));
344    }
345    // A marker with no readable owner is a marker no live run wrote —
346    // an open transaction writes its record whole before any step, and
347    // the caller holds the checkout's lock — so it is recovered now
348    // rather than after the grace period.
349    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
362/// Whether a recorded owner is provably gone. Only a readable procfs
363/// answer decides; where it cannot, a marker past the grace period is
364/// treated as abandoned.
365pub(crate) fn owner_gone(pid: Option<u64>, marker: &Path) -> bool {
366    owner_gone_after(pid, marker, PENDING_GRACE)
367}
368
369/// [`owner_gone`] with the grace period named, for the lock's shorter one.
370pub(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
391/// `nix flake update release-kit`, refreshing the one node.
392///
393/// # Errors
394///
395/// The failing step with nix's last stderr line.
396pub fn flake_update(target: &Utf8Path) -> Result<(), StepFailure> {
397    nix(target, "flake-update", &["flake", "update", "release-kit"]).map(|_| ())
398}
399
400/// The concrete system attribute this host builds for, from nix itself:
401/// guessing it from the compile target would be wrong on the systems
402/// that matter.
403///
404/// # Errors
405///
406/// The failing step with nix's last stderr line.
407pub 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
429/// `nix build --no-link` of the default devshell: the fence. `--no-link`
430/// keeps a directory entry from dropping a `result` symlink.
431///
432/// # Errors
433///
434/// The failing step with nix's last stderr line.
435pub 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
440/// One nix launch against the target, through the shared resolver, with
441/// the git hook variables scrubbed like every other child this crate
442/// spawns against a named target.
443fn 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    /// The `Drop` guard restores; a commit keeps.
485    #[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        // Forget the guard: stand in for a killed process.
528        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    /// A marker that records a file as present with no backup to read is
547    /// corruption — never a file to delete — so a second recovery racing
548    /// the first cannot remove what the first just put back.
549    #[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    /// A restore that cannot write keeps its backups and its marker, and
575    /// says which file is not back.
576    #[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        // A directory where the file must go back: the rename fails.
585        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    /// A marker that cannot be removed is neutralized in place before
597    /// any backup goes: a later recovery reads it as finished and never
598    /// rolls the committed bump back, and never overwrites a later edit.
599    #[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    /// A truncated marker — an interruption mid-write — with its backups
638    /// intact is recovered on the next run, not after the grace period.
639    #[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    /// A finished marker is residue, never a pending run, whether or not
662    /// it can be removed.
663    #[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        // A marker that exists and cannot be read proves nothing finished.
679        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    /// A finished marker left behind is cleared and reported as such.
687    #[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    /// A marker that can be neither removed nor rewritten keeps every
708    /// backup and is reported as still active; nothing is deleted first.
709    #[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}