Skip to main content

ic_backup/ops/persistence/download_journal/
mod.rs

1//! Locked durable local lifecycle updates and verified artifact publication.
2
3#[cfg(unix)]
4mod ic_snapshot_artifact;
5mod integrity;
6mod local_restore_artifact;
7mod local_restore_source;
8mod manifest;
9#[cfg(unix)]
10mod metrics;
11#[cfg(unix)]
12pub use ic_snapshot_artifact::{
13    IcSnapshotArtifactError, IcSnapshotArtifactWriter, IcSnapshotUploadArtifactError,
14};
15pub use integrity::DownloadIntegrityError;
16pub use local_restore_artifact::{
17    LocalRestoreArtifactError, LocalRestoreArtifactPublicationError, LocalRestoreArtifactView,
18};
19pub use local_restore_source::LocalRestoreSourceError;
20pub use manifest::{DownloadManifestError, read_download_manifest};
21#[cfg(unix)]
22pub use metrics::IcSnapshotLocalMetrics;
23
24use super::{
25    BackupLayoutGuard, JournalLock, JournalLockError, PersistenceError, commit_artifact_directory,
26    create_json_durable, read_json, write_json_durable,
27};
28use crate::{
29    model::{
30        artifacts::ArtifactChecksumRecord,
31        download_journal::{
32            ArtifactStateRecord, DownloadArtifactRequest, DownloadJournalRecord,
33            DownloadJournalRecordError, MAX_DOWNLOAD_JOURNAL_BYTES,
34        },
35    },
36    ops::artifacts::{ArtifactError, checksum_directory},
37};
38use std::{fs, io, path::PathBuf};
39use thiserror::Error;
40
41const JOURNAL_FILE: &str = "download-journal.json";
42
43/// Exclusive local lifecycle access borrowing the stable backup layout guard.
44///
45/// The caller owns backend artifact completeness and fresh remote authority.
46/// These operations never invoke a transport, remove staging or release references.
47#[derive(Debug)]
48pub struct DownloadJournalGuard<'a> {
49    layout: &'a BackupLayoutGuard,
50    _lock: JournalLock,
51    record: DownloadJournalRecord,
52    usable: bool,
53    #[cfg(unix)]
54    ic_snapshot_metrics: std::sync::Mutex<IcSnapshotLocalMetrics>,
55}
56
57impl<'a> DownloadJournalGuard<'a> {
58    /// Exclusively create exact intent and snapshot identities without replacing evidence.
59    ///
60    /// # Errors
61    /// Rejects existing/unsafe journals, locked or replaced layouts and invalid/bounded records.
62    pub fn create(
63        layout: &'a BackupLayoutGuard,
64        intent: &str,
65        artifacts: Vec<DownloadArtifactRequest>,
66    ) -> Result<Self, DownloadJournalError> {
67        layout.check_root()?;
68        let path = layout.root().join(JOURNAL_FILE);
69        let lock = JournalLock::acquire(&path)?;
70        let record = DownloadJournalRecord::new(intent, artifacts)?;
71        check_size(&record)?;
72        create_json_durable(&path, &record)?;
73        Ok(Self {
74            layout,
75            _lock: lock,
76            record,
77            usable: true,
78            #[cfg(unix)]
79            ic_snapshot_metrics: std::sync::Mutex::default(),
80        })
81    }
82
83    /// Open retained bounded v1 evidence under exact caller-supplied intent.
84    ///
85    /// Reads only local journal evidence; it does not reverify artifacts or remote state.
86    /// # Errors
87    /// Rejects missing/unsafe/corrupt journals, intent mismatch and locked/replaced layouts.
88    pub fn open(
89        layout: &'a BackupLayoutGuard,
90        expected_intent: &str,
91    ) -> Result<Self, DownloadJournalError> {
92        layout.check_root()?;
93        let expected = ArtifactChecksumRecord::from_hash(expected_intent)
94            .map_err(DownloadJournalRecordError::from)?;
95        let path = layout.root().join(JOURNAL_FILE);
96        let lock = JournalLock::acquire(&path)?;
97        let record: DownloadJournalRecord = read_json(&path, MAX_DOWNLOAD_JOURNAL_BYTES)?;
98        check_size(&record)?;
99        if record.intent() != expected.hash() {
100            return Err(DownloadJournalError::IntentMismatch);
101        }
102        Ok(Self {
103            layout,
104            _lock: lock,
105            record,
106            usable: true,
107            #[cfg(unix)]
108            ic_snapshot_metrics: std::sync::Mutex::default(),
109        })
110    }
111
112    /// Read retained progress; failed publication requires reopening before further use.
113    ///
114    /// # Errors
115    /// Rejects an indeterminate write outcome or a replaced layout.
116    pub fn record(&self) -> Result<&DownloadJournalRecord, DownloadJournalError> {
117        self.check_usable()?;
118        Ok(&self.record)
119    }
120
121    /// Return the canonical journal location whose sidecar this guard owns.
122    #[must_use]
123    pub fn path(&self) -> PathBuf {
124        self.layout.root().join(JOURNAL_FILE)
125    }
126
127    /// Retain the caller's complete-download attestation for the exact snapshot.
128    ///
129    /// Requires a safe existing staging directory. The caller must already have
130    /// validated complete backend metadata/extent coverage and command quiescence;
131    /// traversability alone does not establish IC transfer completeness.
132    /// # Errors
133    /// Rejects identity/state conflicts, unsafe or missing staging and failed persistence.
134    pub fn record_downloaded(
135        &mut self,
136        canister: &str,
137        snapshot: &str,
138    ) -> Result<(), DownloadJournalError> {
139        let next = self.next(canister, snapshot, ArtifactStateRecord::Downloaded, None)?;
140        self.check_artifact_parent()?;
141        let entry = next.artifact(canister, snapshot)?;
142        checksum_directory(&self.layout.root().join(entry.staging_path()))?;
143        self.store(next, write_json_durable)
144    }
145
146    /// Verify staged bytes and durably retain their canonical checksum.
147    ///
148    /// # Errors
149    /// Rejects wrong identity/state, unsafe or missing bytes and failed persistence.
150    pub fn verify_artifact(
151        &mut self,
152        canister: &str,
153        snapshot: &str,
154    ) -> Result<(), DownloadJournalError> {
155        self.check_usable()?;
156        let entry = self.record.artifact(canister, snapshot)?;
157        if entry.state() != ArtifactStateRecord::Downloaded {
158            return Err(DownloadJournalRecordError::InvalidStateTransition {
159                from: entry.state(),
160                to: ArtifactStateRecord::ChecksumVerified,
161            }
162            .into());
163        }
164        self.check_artifact_parent()?;
165        let checksum = checksum_directory(&self.layout.root().join(entry.staging_path()))?;
166        let next = self.next(
167            canister,
168            snapshot,
169            ArtifactStateRecord::ChecksumVerified,
170            Some(checksum),
171        )?;
172        self.store(next, write_json_durable)
173    }
174
175    /// Publish exact verified bytes or adopt a matching tree after a lost response.
176    ///
177    /// Leaves staging and retained intent intact on rejection. Durable state does
178    /// not silently trigger fresh artifact verification; that is a distinct action.
179    /// # Errors
180    /// Rejects wrong identity/state, changed bytes, unsafe paths and uncertain publication.
181    pub fn finalize_artifact(
182        &mut self,
183        canister: &str,
184        snapshot: &str,
185    ) -> Result<(), DownloadJournalError> {
186        self.finalize_with(canister, snapshot, write_json_durable)
187    }
188
189    fn finalize_with(
190        &mut self,
191        canister: &str,
192        snapshot: &str,
193        write: impl FnOnce(&std::path::Path, &DownloadJournalRecord) -> Result<(), PersistenceError>,
194    ) -> Result<(), DownloadJournalError> {
195        let next = self.next(canister, snapshot, ArtifactStateRecord::Durable, None)?;
196        check_size(&next)?;
197        self.check_artifact_parent()?;
198        let entry = next.artifact(canister, snapshot)?;
199        let checksum = entry
200            .checksum()
201            .ok_or(DownloadJournalRecordError::InvalidChecksumState(
202                ArtifactStateRecord::Durable,
203            ))?;
204        // A failed commit may have published bytes. Stop this guard until its
205        // durable journal is reopened and those exact bytes are reconciled.
206        self.usable = false;
207        commit_artifact_directory(
208            &self.layout.root().join(entry.staging_path()),
209            &self.layout.root().join(entry.artifact_path()),
210            checksum.hash(),
211        )?;
212        self.store(next, write)
213    }
214
215    fn next(
216        &self,
217        canister: &str,
218        snapshot: &str,
219        state: ArtifactStateRecord,
220        checksum: Option<ArtifactChecksumRecord>,
221    ) -> Result<DownloadJournalRecord, DownloadJournalError> {
222        self.check_usable()?;
223        let mut next = self.record.clone();
224        next.advance(canister, snapshot, state, checksum)?;
225        Ok(next)
226    }
227
228    fn check_usable(&self) -> Result<(), DownloadJournalError> {
229        if !self.usable {
230            return Err(DownloadJournalError::IndeterminateWrite);
231        }
232        self.layout.check_root()?;
233        Ok(())
234    }
235
236    fn check_artifact_parent(&self) -> Result<(), DownloadJournalError> {
237        let path = self.layout.root().join("artifacts");
238        if !fs::symlink_metadata(&path)?.is_dir() {
239            return Err(DownloadJournalError::UnsafeArtifactParent { path });
240        }
241        Ok(())
242    }
243
244    fn store(
245        &mut self,
246        next: DownloadJournalRecord,
247        write: impl FnOnce(&std::path::Path, &DownloadJournalRecord) -> Result<(), PersistenceError>,
248    ) -> Result<(), DownloadJournalError> {
249        check_size(&next)?;
250        self.layout.check_root()?;
251        self.usable = false;
252        write(&self.path(), &next)?;
253        self.record = next;
254        self.usable = true;
255        Ok(())
256    }
257}
258
259fn check_size(record: &DownloadJournalRecord) -> Result<(), PersistenceError> {
260    super::json::check_json_size(record, MAX_DOWNLOAD_JOURNAL_BYTES)
261}
262
263/// Typed local journal admission or durable lifecycle failure.
264#[derive(Debug, Error)]
265pub enum DownloadJournalError {
266    /// The caller's exact intent digest differs from retained evidence.
267    #[error("download journal immutable intent mismatch")]
268    IntentMismatch,
269    /// A publication may have completed; reopen and reconcile retained evidence.
270    #[error("download journal outcome is indeterminate; reopen retained evidence")]
271    IndeterminateWrite,
272    /// The fixed artifact parent is not an existing regular directory.
273    #[error("unsafe download artifact parent: {path:?}")]
274    UnsafeArtifactParent {
275        /// Rejected location.
276        path: PathBuf,
277    },
278    /// Model identity, schema or transition admission failed.
279    #[error(transparent)]
280    Record(#[from] DownloadJournalRecordError),
281    /// Local journal or layout exclusion failed.
282    #[error(transparent)]
283    Lock(#[from] JournalLockError),
284    /// Durable local record or artifact publication failed.
285    #[error(transparent)]
286    Persistence(#[from] PersistenceError),
287    /// Secure artifact traversal failed.
288    #[error(transparent)]
289    Artifact(#[from] ArtifactError),
290    /// Local fixture-independent filesystem access failed.
291    #[error(transparent)]
292    Io(#[from] io::Error),
293}
294
295#[cfg(all(test, unix))]
296mod tests;