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