Skip to main content

ic_backup/ops/persistence/layout_lifetime/
mod.rs

1//! Stable parent-side layout locks and durable unfinished restore dependencies.
2
3use crate::{
4    model::restore_references::{RestoreReferenceRecord, RestoreReferencesRecord},
5    ops::persistence::{
6        JournalLock, JournalLockError, PersistenceError, read_json, write_json_durable,
7    },
8};
9use sha2::{Digest, Sha256};
10use std::{
11    fs::{self, File},
12    io,
13    path::{Path, PathBuf},
14};
15
16const REFERENCES_FILE: &str = "restore-references.json";
17/// Maximum bytes decoded or published for one restore-dependency record.
18pub const MAX_RESTORE_REFERENCE_BYTES: u64 = 1024 * 1024;
19
20/// Exclusive access to an existing resolved backup layout and its dependencies.
21///
22/// All layout writers/removers must use the same parent-side lock. The operator
23/// owns the trusted parent; this guard does not fence arbitrary filesystem writers.
24#[derive(Debug)]
25pub struct BackupLayoutGuard {
26    root: PathBuf,
27    directory: File,
28    _lock: JournalLock,
29}
30
31impl BackupLayoutGuard {
32    /// Resolve and exclusively lock an existing directory without recreating it.
33    ///
34    /// An explicitly selected root symlink resolves once. The lock remains beside
35    /// the resolved directory, including after that directory is removed.
36    ///
37    /// # Errors
38    /// Returns lock contention, unsafe lock entries, missing roots or IO failures.
39    pub fn acquire(root: &Path) -> Result<Self, JournalLockError> {
40        let root = root.canonicalize()?;
41        let parent = root
42            .parent()
43            .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
44        let name = root
45            .file_name()
46            .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
47        let key = format!("{:x}", Sha256::digest(name.as_encoded_bytes()));
48        let lock = JournalLock::acquire(&parent.join(format!(".ic-backup-layout-{key}")))?;
49        let directory = open_directory(&root)?;
50        Ok(Self {
51            root,
52            directory,
53            _lock: lock,
54        })
55    }
56
57    /// Return the resolved root protected by this guard.
58    #[must_use]
59    pub fn root(&self) -> &Path {
60        &self.root
61    }
62
63    /// Read bounded, validated restore dependencies; absent records mean empty.
64    ///
65    /// # Errors
66    /// Rejects replaced layouts, unsafe entries, invalid records and IO failures.
67    pub fn restore_references(&self) -> Result<RestoreReferencesRecord, PersistenceError> {
68        self.check_root()?;
69        let path = self.root.join(REFERENCES_FILE);
70        match fs::symlink_metadata(&path) {
71            Err(error) if error.kind() == io::ErrorKind::NotFound => {
72                return Ok(RestoreReferencesRecord::empty());
73            }
74            Err(error) => return Err(error.into()),
75            Ok(metadata) if !metadata.is_file() => {
76                return Err(PersistenceError::InvalidRestoreReferences { path });
77            }
78            Ok(_) => {}
79        }
80        read_json(&path, MAX_RESTORE_REFERENCE_BYTES)
81    }
82
83    /// Report retained dependencies even if their external journals are missing.
84    ///
85    /// # Errors
86    /// Returns the same conservative read failures as [`Self::restore_references`].
87    pub fn has_restore_references(&self) -> Result<bool, PersistenceError> {
88        Ok(!self.restore_references()?.is_empty())
89    }
90
91    /// Durably retain exact immutable restore intent before publishing its journal.
92    ///
93    /// Resolve the existing journal parent once; the journal itself may be absent.
94    /// This binds local custody only and does not establish restore authority.
95    /// No release operation is exposed until terminal/custody contracts are implemented.
96    ///
97    /// # Errors
98    /// Rejects conflicting intent, invalid locations, unsafe records, limits and IO failures.
99    pub fn retain_restore(
100        &self,
101        journal: &Path,
102        authority: &str,
103    ) -> Result<RestoreReferenceRecord, PersistenceError> {
104        self.retain_with(journal, authority, write_json_durable)
105    }
106
107    fn retain_with(
108        &self,
109        journal: &Path,
110        authority: &str,
111        write: impl FnOnce(&Path, &RestoreReferencesRecord) -> Result<(), PersistenceError>,
112    ) -> Result<RestoreReferenceRecord, PersistenceError> {
113        let mut references = self.restore_references()?;
114        let reference = RestoreReferenceRecord::new(journal_identity(journal)?, authority)?;
115        let path = self.root.join(REFERENCES_FILE);
116        if references.retain(reference.clone())? {
117            let bytes = serde_json::to_vec_pretty(&references)?;
118            if bytes.len() as u64 > MAX_RESTORE_REFERENCE_BYTES {
119                return Err(PersistenceError::RecordTooLarge {
120                    limit: MAX_RESTORE_REFERENCE_BYTES,
121                });
122            }
123            write(&path, &references)?;
124        } else {
125            // Complete durability after a rename whose response was lost.
126            sync_reference(&path)?;
127            self.directory.sync_all()?;
128        }
129        Ok(reference)
130    }
131
132    pub(super) fn check_root(&self) -> Result<(), PersistenceError> {
133        #[cfg(unix)]
134        {
135            use std::os::unix::fs::MetadataExt;
136            let current = fs::symlink_metadata(&self.root)?;
137            let held = self.directory.metadata()?;
138            if current.is_dir() && current.dev() == held.dev() && current.ino() == held.ino() {
139                return Ok(());
140            }
141        }
142        Err(PersistenceError::LayoutChanged {
143            path: self.root.clone(),
144        })
145    }
146}
147
148fn journal_identity(path: &Path) -> Result<PathBuf, PersistenceError> {
149    let name = path
150        .file_name()
151        .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
152    let parent = path
153        .parent()
154        .filter(|parent| !parent.as_os_str().is_empty())
155        .unwrap_or_else(|| Path::new("."));
156    let parent = parent.canonicalize()?;
157    if !parent.is_dir() {
158        return Err(io::Error::from(io::ErrorKind::NotADirectory).into());
159    }
160    let journal = parent.join(name);
161    match fs::symlink_metadata(&journal) {
162        Ok(metadata) if !metadata.is_file() => {
163            return Err(PersistenceError::InvalidRestoreReferences { path: journal });
164        }
165        Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error.into()),
166        _ => {}
167    }
168    Ok(journal)
169}
170
171fn open_directory(path: &Path) -> io::Result<File> {
172    #[cfg(unix)]
173    {
174        use rustix::fs::{Mode, OFlags, open};
175        let fd = open(
176            path,
177            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
178            Mode::empty(),
179        )
180        .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;
181        Ok(File::from(fd))
182    }
183    #[cfg(not(unix))]
184    {
185        let _ = path;
186        Err(io::Error::from(io::ErrorKind::Unsupported))
187    }
188}
189
190fn sync_reference(path: &Path) -> io::Result<()> {
191    #[cfg(unix)]
192    {
193        use rustix::fs::{FileType, Mode, OFlags, fstat, open};
194        let fd = open(
195            path,
196            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
197            Mode::empty(),
198        )
199        .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;
200        let metadata =
201            fstat(&fd).map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;
202        if !FileType::from_raw_mode(metadata.st_mode).is_file() {
203            return Err(io::Error::from(io::ErrorKind::InvalidInput));
204        }
205        File::from(fd).sync_all()
206    }
207    #[cfg(not(unix))]
208    {
209        let _ = path;
210        Err(io::Error::from(io::ErrorKind::Unsupported))
211    }
212}
213
214#[cfg(all(test, unix))]
215mod tests;