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