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