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