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)]
10pub use ic_snapshot_artifact::{IcSnapshotArtifactError, IcSnapshotArtifactWriter};
11pub use integrity::DownloadIntegrityError;
12pub use local_restore_artifact::{
13    LocalRestoreArtifactError, LocalRestoreArtifactPublicationError, LocalRestoreArtifactView,
14};
15pub use local_restore_source::LocalRestoreSourceError;
16pub use manifest::{DownloadManifestError, read_download_manifest};
17
18use super::{
19    BackupLayoutGuard, JournalLock, JournalLockError, PersistenceError, commit_artifact_directory,
20    create_json_durable, read_json, write_json_durable,
21};
22use crate::{
23    model::{
24        artifacts::ArtifactChecksumRecord,
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        check_size(&record)?;
64        create_json_durable(&path, &record)?;
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 = ArtifactChecksumRecord::from_hash(expected_intent)
84            .map_err(DownloadJournalRecordError::from)?;
85        let path = layout.root().join(JOURNAL_FILE);
86        let lock = JournalLock::acquire(&path)?;
87        let record: DownloadJournalRecord = read_json(&path, MAX_DOWNLOAD_JOURNAL_BYTES)?;
88        check_size(&record)?;
89        if record.intent() != expected.hash() {
90            return Err(DownloadJournalError::IntentMismatch);
91        }
92        Ok(Self {
93            layout,
94            _lock: lock,
95            record,
96            usable: true,
97        })
98    }
99
100    /// Read retained progress; failed publication requires reopening before further use.
101    ///
102    /// # Errors
103    /// Rejects an indeterminate write outcome or a replaced layout.
104    pub fn record(&self) -> Result<&DownloadJournalRecord, DownloadJournalError> {
105        self.check_usable()?;
106        Ok(&self.record)
107    }
108
109    /// Return the canonical journal location whose sidecar this guard owns.
110    #[must_use]
111    pub fn path(&self) -> PathBuf {
112        self.layout.root().join(JOURNAL_FILE)
113    }
114
115    /// Retain the caller's complete-download attestation for the exact snapshot.
116    ///
117    /// Requires a safe existing staging directory. The caller must already have
118    /// validated complete backend metadata/extent coverage and command quiescence;
119    /// traversability alone does not establish IC transfer completeness.
120    /// # Errors
121    /// Rejects identity/state conflicts, unsafe or missing staging and failed persistence.
122    pub fn record_downloaded(
123        &mut self,
124        canister: &str,
125        snapshot: &str,
126    ) -> Result<(), DownloadJournalError> {
127        let next = self.next(canister, snapshot, ArtifactStateRecord::Downloaded, None)?;
128        self.check_artifact_parent()?;
129        let entry = next.artifact(canister, snapshot)?;
130        checksum_directory(&self.layout.root().join(entry.staging_path()))?;
131        self.store(next, write_json_durable)
132    }
133
134    /// Verify staged bytes and durably retain their canonical checksum.
135    ///
136    /// # Errors
137    /// Rejects wrong identity/state, unsafe or missing bytes and failed persistence.
138    pub fn verify_artifact(
139        &mut self,
140        canister: &str,
141        snapshot: &str,
142    ) -> Result<(), DownloadJournalError> {
143        self.check_usable()?;
144        let entry = self.record.artifact(canister, snapshot)?;
145        if entry.state() != ArtifactStateRecord::Downloaded {
146            return Err(DownloadJournalRecordError::InvalidStateTransition {
147                from: entry.state(),
148                to: ArtifactStateRecord::ChecksumVerified,
149            }
150            .into());
151        }
152        self.check_artifact_parent()?;
153        let checksum = checksum_directory(&self.layout.root().join(entry.staging_path()))?;
154        let next = self.next(
155            canister,
156            snapshot,
157            ArtifactStateRecord::ChecksumVerified,
158            Some(checksum),
159        )?;
160        self.store(next, write_json_durable)
161    }
162
163    /// Publish exact verified bytes or adopt a matching tree after a lost response.
164    ///
165    /// Leaves staging and retained intent intact on rejection. Durable state does
166    /// not silently trigger fresh artifact verification; that is a distinct action.
167    /// # Errors
168    /// Rejects wrong identity/state, changed bytes, unsafe paths and uncertain publication.
169    pub fn finalize_artifact(
170        &mut self,
171        canister: &str,
172        snapshot: &str,
173    ) -> Result<(), DownloadJournalError> {
174        self.finalize_with(canister, snapshot, write_json_durable)
175    }
176
177    fn finalize_with(
178        &mut self,
179        canister: &str,
180        snapshot: &str,
181        write: impl FnOnce(&std::path::Path, &DownloadJournalRecord) -> Result<(), PersistenceError>,
182    ) -> Result<(), DownloadJournalError> {
183        let next = self.next(canister, snapshot, ArtifactStateRecord::Durable, None)?;
184        check_size(&next)?;
185        self.check_artifact_parent()?;
186        let entry = next.artifact(canister, snapshot)?;
187        let checksum = entry
188            .checksum()
189            .ok_or(DownloadJournalRecordError::InvalidChecksumState(
190                ArtifactStateRecord::Durable,
191            ))?;
192        // A failed commit may have published bytes. Stop this guard until its
193        // durable journal is reopened and those exact bytes are reconciled.
194        self.usable = false;
195        commit_artifact_directory(
196            &self.layout.root().join(entry.staging_path()),
197            &self.layout.root().join(entry.artifact_path()),
198            checksum.hash(),
199        )?;
200        self.store(next, write)
201    }
202
203    fn next(
204        &self,
205        canister: &str,
206        snapshot: &str,
207        state: ArtifactStateRecord,
208        checksum: Option<ArtifactChecksumRecord>,
209    ) -> Result<DownloadJournalRecord, DownloadJournalError> {
210        self.check_usable()?;
211        let mut next = self.record.clone();
212        next.advance(canister, snapshot, state, checksum)?;
213        Ok(next)
214    }
215
216    fn check_usable(&self) -> Result<(), DownloadJournalError> {
217        if !self.usable {
218            return Err(DownloadJournalError::IndeterminateWrite);
219        }
220        self.layout.check_root()?;
221        Ok(())
222    }
223
224    fn check_artifact_parent(&self) -> Result<(), DownloadJournalError> {
225        let path = self.layout.root().join("artifacts");
226        if !fs::symlink_metadata(&path)?.is_dir() {
227            return Err(DownloadJournalError::UnsafeArtifactParent { path });
228        }
229        Ok(())
230    }
231
232    fn store(
233        &mut self,
234        next: DownloadJournalRecord,
235        write: impl FnOnce(&std::path::Path, &DownloadJournalRecord) -> Result<(), PersistenceError>,
236    ) -> Result<(), DownloadJournalError> {
237        check_size(&next)?;
238        self.layout.check_root()?;
239        self.usable = false;
240        write(&self.path(), &next)?;
241        self.record = next;
242        self.usable = true;
243        Ok(())
244    }
245}
246
247fn check_size(record: &DownloadJournalRecord) -> Result<(), PersistenceError> {
248    if serde_json::to_vec_pretty(record)?.len() as u64 > MAX_DOWNLOAD_JOURNAL_BYTES {
249        return Err(PersistenceError::RecordTooLarge {
250            limit: MAX_DOWNLOAD_JOURNAL_BYTES,
251        });
252    }
253    Ok(())
254}
255
256/// Typed local journal admission or durable lifecycle failure.
257#[derive(Debug, Error)]
258pub enum DownloadJournalError {
259    /// The caller's exact intent digest differs from retained evidence.
260    #[error("download journal immutable intent mismatch")]
261    IntentMismatch,
262    /// A publication may have completed; reopen and reconcile retained evidence.
263    #[error("download journal outcome is indeterminate; reopen retained evidence")]
264    IndeterminateWrite,
265    /// The fixed artifact parent is not an existing regular directory.
266    #[error("unsafe download artifact parent: {path:?}")]
267    UnsafeArtifactParent {
268        /// Rejected location.
269        path: PathBuf,
270    },
271    /// Model identity, schema or transition admission failed.
272    #[error(transparent)]
273    Record(#[from] DownloadJournalRecordError),
274    /// Local journal or layout exclusion failed.
275    #[error(transparent)]
276    Lock(#[from] JournalLockError),
277    /// Durable local record or artifact publication failed.
278    #[error(transparent)]
279    Persistence(#[from] PersistenceError),
280    /// Secure artifact traversal failed.
281    #[error(transparent)]
282    Artifact(#[from] ArtifactError),
283    /// Local fixture-independent filesystem access failed.
284    #[error(transparent)]
285    Io(#[from] io::Error),
286}
287
288#[cfg(all(test, unix))]
289mod tests;