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