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