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