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