Skip to main content

ic_backup/ops/persistence/download_journal/
mod.rs

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