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    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    /// The `Drop` guard restores; a commit keeps.
483    #[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        // Forget the guard: stand in for a killed process.
526        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    /// A marker that records a file as present with no backup to read is
545    /// corruption — never a file to delete — so a second recovery racing
546    /// the first cannot remove what the first just put back.
547    #[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    /// A restore that cannot write keeps its backups and its marker, and
573    /// says which file is not back.
574    #[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        // A directory where the file must go back: the rename fails.
583        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    /// A marker that cannot be removed is neutralized in place before
595    /// any backup goes: a later recovery reads it as finished and never
596    /// rolls the committed bump back, and never overwrites a later edit.
597    #[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    /// A truncated marker — an interruption mid-write — with its backups
636    /// intact is recovered on the next run, not after the grace period.
637    #[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    /// A finished marker is residue, never a pending run, whether or not
660    /// it can be removed.
661    #[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        // A marker that exists and cannot be read proves nothing finished.
677        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    /// A finished marker left behind is cleared and reported as such.
685    #[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    /// A marker that can be neither removed nor rewritten keeps every
706    /// backup and is reported as still active; nothing is deleted first.
707    #[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}