Skip to main content

ic_backup/model/download_journal/
mod.rs

1//! Exact local artifact identity and model-owned download lifecycle transitions.
2
3use crate::model::evidence_digest::EvidenceDigest;
4mod view;
5pub use view::{DownloadArtifactView, DownloadJournalView, ResumeAction};
6
7use super::{
8    artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash},
9    bounded_sequence,
10};
11use serde::{Deserialize, Deserializer, Serialize};
12use thiserror::Error;
13
14/// Maximum artifact entries in a local download journal.
15pub const MAX_DOWNLOAD_ARTIFACTS: usize = 1024;
16/// Maximum encoded journal bytes admitted by persistence operations.
17pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
18/// Maximum ASCII bytes in an opaque backend snapshot identifier.
19pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
20
21/// Snapshot identity admitted by the caller after authoritative capture observation.
22///
23/// This passive request grants no remote authority and contains no signing material.
24#[derive(Clone, Debug)]
25pub struct DownloadArtifactRequest {
26    /// Exact source canister principal, normalized on admission.
27    pub canister_id: String,
28    /// Exact backend snapshot token; no case folding or inferred identity.
29    pub snapshot_id: String,
30    /// Observed snapshot timestamp in nanoseconds.
31    pub snapshot_taken_at_timestamp: u64,
32    /// Observed snapshot size, not the encoded artifact directory's byte length.
33    pub snapshot_total_size_bytes: u64,
34}
35
36/// Ordered local artifact lifecycle adapted from Canic's download journal.
37#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
38pub enum ArtifactStateRecord {
39    /// Exact snapshot identity retained before local download completion.
40    Created,
41    /// Caller attested a complete staged backend artifact.
42    Downloaded,
43    /// Local bytes verified and their canonical checksum retained.
44    ChecksumVerified,
45    /// Verified bytes durably published at the canonical location.
46    Durable,
47}
48
49// Internal transition inputs cannot pair a state with inappropriate checksum
50// evidence. Persisted fields retain their independent decoding/corruption checks.
51pub(crate) enum ArtifactTransition {
52    Downloaded,
53    ChecksumVerified(ArtifactChecksumRecord),
54    Durable,
55}
56
57impl ArtifactTransition {
58    const fn state(&self) -> ArtifactStateRecord {
59        match self {
60            Self::Downloaded => ArtifactStateRecord::Downloaded,
61            Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
62            Self::Durable => ArtifactStateRecord::Durable,
63        }
64    }
65}
66
67/// Immutable exact snapshot identity with validated local progress evidence.
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
69#[serde(try_from = "ArtifactFields")]
70pub struct DownloadArtifactRecord {
71    canister_id: String,
72    snapshot_id: String,
73    snapshot_taken_at_timestamp: u64,
74    snapshot_total_size_bytes: u64,
75    staging_path: String,
76    artifact_path: String,
77    #[serde(flatten)]
78    progress: ArtifactProgress,
79}
80
81// Keep verified evidence in the state that requires it; only boundary fields are independent.
82#[derive(Clone, Debug, Eq, PartialEq)]
83enum ArtifactProgress {
84    Created,
85    Downloaded,
86    ChecksumVerified(ArtifactChecksumRecord),
87    Durable(ArtifactChecksumRecord),
88}
89impl ArtifactProgress {
90    const fn state(&self) -> ArtifactStateRecord {
91        match self {
92            Self::Created => ArtifactStateRecord::Created,
93            Self::Downloaded => ArtifactStateRecord::Downloaded,
94            Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
95            Self::Durable(_) => ArtifactStateRecord::Durable,
96        }
97    }
98    const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
99        match self {
100            Self::ChecksumVerified(checksum) | Self::Durable(checksum) => Some(checksum),
101            Self::Created | Self::Downloaded => None,
102        }
103    }
104}
105impl Serialize for ArtifactProgress {
106    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
107        use serde::ser::SerializeStruct;
108        let mut fields = serializer.serialize_struct("ArtifactProgress", 2)?;
109        fields.serialize_field("state", &self.state())?;
110        fields.serialize_field("checksum", &self.checksum())?;
111        fields.end()
112    }
113}
114
115#[derive(Deserialize)]
116#[serde(deny_unknown_fields)]
117struct ArtifactFields {
118    canister_id: String,
119    snapshot_id: String,
120    snapshot_taken_at_timestamp: u64,
121    snapshot_total_size_bytes: u64,
122    staging_path: String,
123    artifact_path: String,
124    state: ArtifactStateRecord,
125    #[serde(deserialize_with = "crate::model::required_option::deserialize")]
126    checksum: Option<ArtifactChecksumRecord>,
127}
128
129impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
130    type Error = DownloadJournalRecordError;
131    fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
132        let mut record = Self::new(DownloadArtifactRequest {
133            canister_id: fields.canister_id,
134            snapshot_id: fields.snapshot_id,
135            snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
136            snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
137        })?;
138        // Persisted paths must agree with normalized identity, never with a label.
139        if fields.staging_path != record.staging_path
140            || fields.artifact_path != record.artifact_path
141        {
142            return Err(DownloadJournalRecordError::ArtifactPathMismatch);
143        }
144        record.progress = match (fields.state, fields.checksum) {
145            (ArtifactStateRecord::Created, None) => ArtifactProgress::Created,
146            (ArtifactStateRecord::Downloaded, None) => ArtifactProgress::Downloaded,
147            (ArtifactStateRecord::ChecksumVerified, Some(checksum)) => {
148                ArtifactProgress::ChecksumVerified(checksum)
149            }
150            (ArtifactStateRecord::Durable, Some(checksum)) => ArtifactProgress::Durable(checksum),
151            (state, _) => return Err(DownloadJournalRecordError::InvalidChecksumState(state)),
152        };
153        Ok(record)
154    }
155}
156
157impl DownloadArtifactRecord {
158    fn new(request: DownloadArtifactRequest) -> Result<Self, DownloadJournalRecordError> {
159        let canister_id = normalize_principal(&request.canister_id)?;
160        if request.snapshot_id.is_empty()
161            || request.snapshot_id.len() > MAX_SNAPSHOT_ID_BYTES
162            || !request
163                .snapshot_id
164                .bytes()
165                .all(|byte| byte.is_ascii_graphic())
166        {
167            return Err(DownloadJournalRecordError::InvalidSnapshotId);
168        }
169        Ok(Self {
170            staging_path: format!("artifacts/{canister_id}.tmp"),
171            artifact_path: format!("artifacts/{canister_id}"),
172            canister_id,
173            snapshot_id: request.snapshot_id,
174            snapshot_taken_at_timestamp: request.snapshot_taken_at_timestamp,
175            snapshot_total_size_bytes: request.snapshot_total_size_bytes,
176            progress: ArtifactProgress::Created,
177        })
178    }
179
180    /// Return the normalized exact source principal.
181    #[must_use]
182    pub fn canister_id(&self) -> &str {
183        &self.canister_id
184    }
185    /// Return the exact backend snapshot token.
186    #[must_use]
187    pub fn snapshot_id(&self) -> &str {
188        &self.snapshot_id
189    }
190    /// Return the observed snapshot timestamp in nanoseconds.
191    #[must_use]
192    pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
193        self.snapshot_taken_at_timestamp
194    }
195    /// Return the observed snapshot size; encoded directory size may differ.
196    #[must_use]
197    pub const fn snapshot_total_size_bytes(&self) -> u64 {
198        self.snapshot_total_size_bytes
199    }
200    /// Return the fixed relative staging directory for this principal.
201    #[must_use]
202    pub fn staging_path(&self) -> &str {
203        &self.staging_path
204    }
205    /// Return the fixed relative canonical directory for this principal.
206    #[must_use]
207    pub fn artifact_path(&self) -> &str {
208        &self.artifact_path
209    }
210    /// Return retained local lifecycle state.
211    #[must_use]
212    pub const fn state(&self) -> ArtifactStateRecord {
213        self.progress.state()
214    }
215    /// Return the retained exact checksum when verification has completed.
216    #[must_use]
217    pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
218        self.progress.checksum()
219    }
220}
221
222/// Maintained v1 local artifact journal bound to a caller-supplied intent digest.
223///
224/// This record neither authenticates that intent nor proves current remote authority.
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(try_from = "JournalFields")]
227pub struct DownloadJournalRecord {
228    version: u16,
229    intent: String,
230    artifacts: Vec<DownloadArtifactRecord>,
231}
232
233#[derive(Deserialize)]
234#[serde(deny_unknown_fields)]
235struct JournalFields {
236    version: u16,
237    intent: String,
238    #[serde(deserialize_with = "read_bounded_artifacts")]
239    artifacts: Vec<DownloadArtifactRecord>,
240}
241
242impl TryFrom<JournalFields> for DownloadJournalRecord {
243    type Error = DownloadJournalRecordError;
244    fn try_from(mut fields: JournalFields) -> Result<Self, Self::Error> {
245        if fields.version != 1 {
246            return Err(DownloadJournalRecordError::UnsupportedVersion(
247                fields.version,
248            ));
249        }
250        validate_entries(&mut fields.artifacts)?;
251        Ok(Self {
252            version: 1,
253            intent: canonical_hash(&fields.intent)?,
254            artifacts: fields.artifacts,
255        })
256    }
257}
258
259impl DownloadJournalRecord {
260    /// Admit a nonempty exact physical set before any local transfer completion.
261    ///
262    /// # Errors
263    /// Rejects invalid identities/digests, duplicate principals and exceeded count bounds.
264    pub fn new(
265        intent: &str,
266        requests: Vec<DownloadArtifactRequest>,
267    ) -> Result<Self, DownloadJournalRecordError> {
268        if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
269            return Err(DownloadJournalRecordError::TooManyArtifacts);
270        }
271        let artifacts = requests
272            .into_iter()
273            .map(DownloadArtifactRecord::new)
274            .collect::<Result<Vec<_>, _>>()?;
275        JournalFields {
276            version: 1,
277            intent: intent.to_owned(),
278            artifacts,
279        }
280        .try_into()
281    }
282
283    /// Return the canonical caller-supplied immutable intent digest.
284    #[must_use]
285    pub fn intent(&self) -> &str {
286        &self.intent
287    }
288    /// Read entries in canonical principal-text order.
289    #[must_use]
290    pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
291        &self.artifacts
292    }
293
294    /// Hash canonical original intent, exact snapshot metadata, paths and local evidence.
295    ///
296    /// The NUL-terminated v1 domain precedes 64 ASCII intent bytes and a big-endian
297    /// u64 entry count. Each canonical entry has four u32-length-prefixed UTF-8
298    /// strings (principal, snapshot token, staging path, artifact path), two u64
299    /// metadata values, a state byte (Created=0 through Durable=3), then a checksum
300    /// presence byte and its 64 ASCII hash when present. Integers are big-endian.
301    /// This grants no transfer, snapshot authenticity or terminal proof.
302    #[must_use]
303    pub fn digest(&self) -> ArtifactChecksumRecord {
304        let mut bytes = EvidenceDigest::new(b"ic-backup/download-journal/v1\0");
305        bytes.append(self.intent.as_bytes());
306        bytes.u64_be(self.artifacts.len() as u64);
307        for entry in &self.artifacts {
308            for value in [
309                entry.canister_id(),
310                entry.snapshot_id(),
311                entry.staging_path(),
312                entry.artifact_path(),
313            ] {
314                bytes.bytes_u32(value.as_bytes());
315            }
316            bytes.u64_be(entry.snapshot_taken_at_timestamp);
317            bytes.u64_be(entry.snapshot_total_size_bytes);
318            bytes.byte(match entry.state() {
319                ArtifactStateRecord::Created => 0,
320                ArtifactStateRecord::Downloaded => 1,
321                ArtifactStateRecord::ChecksumVerified => 2,
322                ArtifactStateRecord::Durable => 3,
323            });
324            bytes.byte(u8::from(entry.checksum().is_some()));
325            if let Some(checksum) = entry.checksum() {
326                bytes.checksum(checksum);
327            }
328        }
329        bytes.finish()
330    }
331
332    pub(crate) fn artifact(
333        &self,
334        canister: &str,
335        snapshot: &str,
336    ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
337        Ok(&self.artifacts[self.artifact_index(canister, snapshot)?])
338    }
339
340    fn artifact_index(
341        &self,
342        canister: &str,
343        snapshot: &str,
344    ) -> Result<usize, DownloadJournalRecordError> {
345        let canister = normalize_principal(canister)?;
346        let index = self
347            .artifacts
348            .binary_search_by(|entry| entry.canister_id.cmp(&canister))
349            .map_err(|_| DownloadJournalRecordError::UnknownArtifact)?;
350        if self.artifacts[index].snapshot_id != snapshot {
351            return Err(DownloadJournalRecordError::SnapshotMismatch);
352        }
353        Ok(index)
354    }
355
356    pub(crate) fn advance(
357        &mut self,
358        canister: &str,
359        snapshot: &str,
360        transition: ArtifactTransition,
361    ) -> Result<(), DownloadJournalRecordError> {
362        let index = self.artifact_index(canister, snapshot)?;
363        let entry = &mut self.artifacts[index];
364        entry.progress = match (&entry.progress, transition) {
365            (ArtifactProgress::Created, ArtifactTransition::Downloaded) => {
366                ArtifactProgress::Downloaded
367            }
368            (ArtifactProgress::Downloaded, ArtifactTransition::ChecksumVerified(checksum)) => {
369                ArtifactProgress::ChecksumVerified(checksum)
370            }
371            (ArtifactProgress::ChecksumVerified(checksum), ArtifactTransition::Durable) => {
372                ArtifactProgress::Durable(checksum.clone())
373            }
374            (_, transition) => {
375                return Err(DownloadJournalRecordError::InvalidStateTransition {
376                    from: entry.state(),
377                    to: transition.state(),
378                });
379            }
380        };
381        Ok(())
382    }
383}
384
385fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
386    super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
387}
388
389fn validate_entries(
390    entries: &mut [DownloadArtifactRecord],
391) -> Result<(), DownloadJournalRecordError> {
392    if entries.is_empty() {
393        return Err(DownloadJournalRecordError::EmptyArtifacts);
394    }
395    if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
396        return Err(DownloadJournalRecordError::TooManyArtifacts);
397    }
398    entries.sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
399    if entries
400        .windows(2)
401        .any(|pair| pair[0].canister_id == pair[1].canister_id)
402    {
403        return Err(DownloadJournalRecordError::DuplicateCanister);
404    }
405    Ok(())
406}
407
408fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
409    deserializer: D,
410) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
411    bounded_sequence::deserialize::<_, _, MAX_DOWNLOAD_ARTIFACTS>(
412        deserializer,
413        "a bounded artifact list",
414        DownloadJournalRecordError::TooManyArtifacts,
415    )
416}
417
418/// Typed local journal identity, schema or transition rejection.
419#[derive(Debug, Error)]
420pub enum DownloadJournalRecordError {
421    /// Only v1 records are maintained.
422    #[error("unsupported download journal version {0}")]
423    UnsupportedVersion(u16),
424    /// An empty physical set cannot claim completion.
425    #[error("download journal artifacts must not be empty")]
426    EmptyArtifacts,
427    /// The bounded artifact count was exceeded.
428    #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
429    TooManyArtifacts,
430    /// More than one snapshot was selected for the same physical canister.
431    #[error("duplicate download canister identity")]
432    DuplicateCanister,
433    /// The supplied principal failed canonical admission.
434    #[error("invalid download canister principal")]
435    InvalidPrincipal,
436    /// The exact snapshot token is empty, excessive or contains nongraphic bytes.
437    #[error("invalid download snapshot token")]
438    InvalidSnapshotId,
439    /// A persisted path differs from its immutable identity-derived location.
440    #[error("download artifact path does not match source identity")]
441    ArtifactPathMismatch,
442    /// The requested physical target is not retained.
443    #[error("unknown download artifact")]
444    UnknownArtifact,
445    /// The supplied snapshot differs from the retained exact token.
446    #[error("download snapshot identity mismatch")]
447    SnapshotMismatch,
448    /// Progress cannot be reset, repeated or advanced over an intermediate state.
449    #[error("invalid download state transition from {from:?} to {to:?}")]
450    InvalidStateTransition {
451        /// Retained state.
452        from: ArtifactStateRecord,
453        /// Rejected next state.
454        to: ArtifactStateRecord,
455    },
456    /// Checksum evidence disagrees with lifecycle state.
457    #[error("invalid checksum evidence for download state {0:?}")]
458    InvalidChecksumState(ArtifactStateRecord),
459    /// The immutable intent or checksum is malformed.
460    #[error(transparent)]
461    Checksum(#[from] ChecksumError),
462}
463
464#[cfg(test)]
465mod tests;