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