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