Skip to main content

canic_backup/persistence/layout_lifetime/
mod.rs

1//! Module: persistence::layout_lifetime
2//!
3//! Responsibility: exclude layout deletion and retain unfinished restore references.
4//! Does not own: restore transitions, command execution, or retention selection.
5//! Boundary: one stable parent-side lock protects backup contents and durable references.
6
7#[cfg(test)]
8mod tests;
9
10use crate::persistence::{
11    JournalLock, JournalLockError, PersistenceError, read_json, write_json_durable,
12};
13
14use std::{
15    fs, io,
16    path::{Path, PathBuf},
17};
18
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22const REFERENCES_FILE: &str = "restore-references.json";
23
24///
25/// BackupLayoutGuard
26///
27/// Persistence-owned exclusive layout access, shared by backup, restore and prune.
28/// Its lock file lives outside the directory that prune removes.
29///
30
31#[derive(Debug)]
32pub struct BackupLayoutGuard {
33    root: PathBuf,
34    _lock: JournalLock,
35}
36
37impl BackupLayoutGuard {
38    pub(crate) fn acquire(root: &Path) -> Result<Self, JournalLockError> {
39        let root = root.canonicalize()?;
40        let parent = root
41            .parent()
42            .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
43        let name = root
44            .file_name()
45            .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
46        let key = format!("{:x}", Sha256::digest(name.as_encoded_bytes()));
47        let lock = JournalLock::acquire(&parent.join(format!(".canic-backup-{key}")))?;
48        // Prune may have won after canonicalization. Never recreate its removed root.
49        if !fs::symlink_metadata(&root)?.is_dir() {
50            return Err(io::Error::from(io::ErrorKind::NotADirectory).into());
51        }
52        Ok(Self { root, _lock: lock })
53    }
54
55    /// Return the resolved root protected by this guard.
56    #[must_use]
57    pub fn root(&self) -> &Path {
58        &self.root
59    }
60
61    /// Report durable restore dependencies, including external or missing journals.
62    pub fn has_restore_references(&self) -> Result<bool, PersistenceError> {
63        Ok(!self.read_references()?.restores.is_empty())
64    }
65
66    pub(crate) fn retain_restore(
67        &self,
68        journal: &Path,
69        authority: &str,
70    ) -> Result<(), PersistenceError> {
71        let mut references = self.read_references()?;
72        let reference = RestoreReferenceRecord {
73            journal: journal.to_path_buf(),
74            authority: authority.to_owned(),
75        };
76        if let Some(existing) = references
77            .restores
78            .iter()
79            .find(|entry| entry.journal == journal)
80        {
81            if existing != &reference {
82                return Err(PersistenceError::RestoreReferenceConflict {
83                    path: journal.display().to_string(),
84                });
85            }
86            // Adopt a reference whose rename may have outlived a failed directory sync.
87            fs::File::open(self.root.join(REFERENCES_FILE))?.sync_all()?;
88            fs::File::open(&self.root)?.sync_all()?;
89            return Ok(());
90        }
91        references.restores.push(reference);
92        write_json_durable(&self.root.join(REFERENCES_FILE), &references)
93    }
94
95    pub(crate) fn release_restore(
96        &self,
97        journal: &Path,
98        authority: &str,
99    ) -> Result<(), PersistenceError> {
100        let mut references = self.read_references()?;
101        let Some(index) = references
102            .restores
103            .iter()
104            .position(|entry| entry.journal == journal)
105        else {
106            return Ok(());
107        };
108        if references.restores[index].authority != authority {
109            return Err(PersistenceError::RestoreReferenceConflict {
110                path: journal.display().to_string(),
111            });
112        }
113        references.restores.remove(index);
114        write_json_durable(&self.root.join(REFERENCES_FILE), &references)
115    }
116
117    fn read_references(&self) -> Result<RestoreReferencesRecord, PersistenceError> {
118        let path = self.root.join(REFERENCES_FILE);
119        match fs::symlink_metadata(&path) {
120            Err(error) if error.kind() == io::ErrorKind::NotFound => {
121                return Ok(RestoreReferencesRecord {
122                    version: 1,
123                    restores: Vec::new(),
124                });
125            }
126            Err(error) => return Err(error.into()),
127            Ok(metadata) if !metadata.is_file() => {
128                return Err(PersistenceError::InvalidRestoreReferences {
129                    path: path.display().to_string(),
130                });
131            }
132            Ok(_) => {}
133        }
134        let references: RestoreReferencesRecord = read_json(&path)?;
135        if references.version != 1 {
136            return Err(PersistenceError::InvalidRestoreReferences {
137                path: path.display().to_string(),
138            });
139        }
140        Ok(references)
141    }
142}
143
144///
145/// BackupExecutionGuard
146///
147/// Holds both layout lifetime and execution-journal mutation authority.
148///
149
150#[derive(Debug)]
151pub struct BackupExecutionGuard {
152    pub(super) _lifetime: BackupLayoutGuard,
153    pub(super) _journal: JournalLock,
154}
155
156///
157/// RestoreReferencesRecord
158///
159/// Durable unfinished restore dependencies belonging to one backup layout.
160///
161
162#[derive(Deserialize, Serialize)]
163#[serde(deny_unknown_fields)]
164struct RestoreReferencesRecord {
165    version: u16,
166    restores: Vec<RestoreReferenceRecord>,
167}
168
169///
170/// RestoreReferenceRecord
171///
172/// Binds one journal location to its immutable restore authority digest.
173///
174
175#[derive(Deserialize, Eq, PartialEq, Serialize)]
176#[serde(deny_unknown_fields)]
177struct RestoreReferenceRecord {
178    journal: PathBuf,
179    authority: String,
180}