Skip to main content

ic_backup/model/download_journal/
mod.rs

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