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