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};
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: ArtifactChecksumRecord::from_hash(&fields.intent)?
222                .hash()
223                .to_owned(),
224            artifacts: fields.artifacts,
225        })
226    }
227}
228
229impl DownloadJournalRecord {
230    /// Admit a nonempty exact physical set before any local transfer completion.
231    ///
232    /// # Errors
233    /// Rejects invalid identities/digests, duplicate principals and exceeded count bounds.
234    pub fn new(
235        intent: &str,
236        requests: Vec<DownloadArtifactRequest>,
237    ) -> Result<Self, DownloadJournalRecordError> {
238        if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
239            return Err(DownloadJournalRecordError::TooManyArtifacts);
240        }
241        let artifacts = requests
242            .into_iter()
243            .map(DownloadArtifactRecord::new)
244            .collect::<Result<Vec<_>, _>>()?;
245        JournalFields {
246            version: 1,
247            intent: intent.to_owned(),
248            artifacts,
249        }
250        .try_into()
251    }
252
253    /// Return the canonical caller-supplied immutable intent digest.
254    #[must_use]
255    pub fn intent(&self) -> &str {
256        &self.intent
257    }
258    /// Read entries in canonical principal-text order.
259    #[must_use]
260    pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
261        &self.artifacts
262    }
263
264    /// Hash canonical original intent, exact snapshot metadata, paths and local evidence.
265    ///
266    /// The NUL-terminated v1 domain precedes 64 ASCII intent bytes and a big-endian
267    /// u64 entry count. Each canonical entry has four u32-length-prefixed UTF-8
268    /// strings (principal, snapshot token, staging path, artifact path), two u64
269    /// metadata values, a state byte (Created=0 through Durable=3), then a checksum
270    /// presence byte and its 64 ASCII hash when present. Integers are big-endian.
271    /// This grants no transfer, snapshot authenticity or terminal proof.
272    #[must_use]
273    #[expect(
274        clippy::cast_possible_truncation,
275        reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
276    )]
277    pub fn digest(&self) -> ArtifactChecksumRecord {
278        let mut bytes = b"ic-backup/download-journal/v1\0".to_vec();
279        bytes.extend_from_slice(self.intent.as_bytes());
280        bytes.extend_from_slice(&(self.artifacts.len() as u64).to_be_bytes());
281        for entry in &self.artifacts {
282            for value in [
283                entry.canister_id(),
284                entry.snapshot_id(),
285                entry.staging_path(),
286                entry.artifact_path(),
287            ] {
288                bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
289                bytes.extend_from_slice(value.as_bytes());
290            }
291            bytes.extend_from_slice(&entry.snapshot_taken_at_timestamp.to_be_bytes());
292            bytes.extend_from_slice(&entry.snapshot_total_size_bytes.to_be_bytes());
293            bytes.push(match entry.state {
294                ArtifactStateRecord::Created => 0,
295                ArtifactStateRecord::Downloaded => 1,
296                ArtifactStateRecord::ChecksumVerified => 2,
297                ArtifactStateRecord::Durable => 3,
298            });
299            bytes.push(u8::from(entry.checksum.is_some()));
300            if let Some(checksum) = &entry.checksum {
301                bytes.extend_from_slice(checksum.hash().as_bytes());
302            }
303        }
304        ArtifactChecksumRecord::from_bytes(&bytes)
305    }
306
307    pub(crate) fn artifact(
308        &self,
309        canister: &str,
310        snapshot: &str,
311    ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
312        let canister = normalize_principal(canister)?;
313        let entry = self
314            .artifacts
315            .iter()
316            .find(|entry| entry.canister_id == canister)
317            .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
318        if entry.snapshot_id != snapshot {
319            return Err(DownloadJournalRecordError::SnapshotMismatch);
320        }
321        Ok(entry)
322    }
323
324    pub(crate) fn advance(
325        &mut self,
326        canister: &str,
327        snapshot: &str,
328        next: ArtifactStateRecord,
329        checksum: Option<ArtifactChecksumRecord>,
330    ) -> Result<(), DownloadJournalRecordError> {
331        let entry = self.artifact(canister, snapshot)?;
332        let from = entry.state;
333        if !from.can_advance_to(next) {
334            return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
335        }
336        let checksum = match next {
337            ArtifactStateRecord::Downloaded if checksum.is_none() => None,
338            ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
339            ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
340            _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
341        };
342        let canister = entry.canister_id.clone();
343        let entry = self
344            .artifacts
345            .iter_mut()
346            .find(|entry| entry.canister_id == canister)
347            .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
348        entry.state = next;
349        entry.checksum = checksum;
350        Ok(())
351    }
352}
353
354fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
355    super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
356}
357
358fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
359    if entries.is_empty() {
360        return Err(DownloadJournalRecordError::EmptyArtifacts);
361    }
362    if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
363        return Err(DownloadJournalRecordError::TooManyArtifacts);
364    }
365    let mut identities = BTreeSet::new();
366    for entry in entries {
367        if !identities.insert(entry.canister_id()) {
368            return Err(DownloadJournalRecordError::DuplicateCanister);
369        }
370    }
371    Ok(())
372}
373
374fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
375    deserializer: D,
376) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
377    struct ArtifactsVisitor;
378    impl<'de> de::Visitor<'de> for ArtifactsVisitor {
379        type Value = Vec<DownloadArtifactRecord>;
380        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381            formatter.write_str("a bounded artifact list")
382        }
383        fn visit_seq<A: de::SeqAccess<'de>>(
384            self,
385            mut sequence: A,
386        ) -> Result<Self::Value, A::Error> {
387            let mut entries = Vec::new();
388            while entries.len() < MAX_DOWNLOAD_ARTIFACTS {
389                match sequence.next_element()? {
390                    Some(entry) => entries.push(entry),
391                    None => return Ok(entries),
392                }
393            }
394            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
395                return Err(de::Error::custom(
396                    DownloadJournalRecordError::TooManyArtifacts,
397                ));
398            }
399            Ok(entries)
400        }
401    }
402    deserializer.deserialize_seq(ArtifactsVisitor)
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;