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