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