Skip to main content

ic_backup/ops/persistence/download_journal/
mod.rs

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