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    pub(crate) fn artifact(
265        &self,
266        canister: &str,
267        snapshot: &str,
268    ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
269        let canister = normalize_principal(canister)?;
270        let entry = self
271            .artifacts
272            .iter()
273            .find(|entry| entry.canister_id == canister)
274            .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
275        if entry.snapshot_id != snapshot {
276            return Err(DownloadJournalRecordError::SnapshotMismatch);
277        }
278        Ok(entry)
279    }
280
281    pub(crate) fn advance(
282        &mut self,
283        canister: &str,
284        snapshot: &str,
285        next: ArtifactStateRecord,
286        checksum: Option<ArtifactChecksumRecord>,
287    ) -> Result<(), DownloadJournalRecordError> {
288        let entry = self.artifact(canister, snapshot)?;
289        let from = entry.state;
290        if !from.can_advance_to(next) {
291            return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
292        }
293        let checksum = match next {
294            ArtifactStateRecord::Downloaded if checksum.is_none() => None,
295            ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
296            ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
297            _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
298        };
299        let canister = entry.canister_id.clone();
300        let entry = self
301            .artifacts
302            .iter_mut()
303            .find(|entry| entry.canister_id == canister)
304            .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
305        entry.state = next;
306        entry.checksum = checksum;
307        Ok(())
308    }
309}
310
311fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
312    super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
313}
314
315fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
316    if entries.is_empty() {
317        return Err(DownloadJournalRecordError::EmptyArtifacts);
318    }
319    if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
320        return Err(DownloadJournalRecordError::TooManyArtifacts);
321    }
322    let mut identities = BTreeSet::new();
323    for entry in entries {
324        if !identities.insert(entry.canister_id()) {
325            return Err(DownloadJournalRecordError::DuplicateCanister);
326        }
327    }
328    Ok(())
329}
330
331fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
332    deserializer: D,
333) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
334    struct ArtifactsVisitor;
335    impl<'de> de::Visitor<'de> for ArtifactsVisitor {
336        type Value = Vec<DownloadArtifactRecord>;
337        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338            formatter.write_str("a bounded artifact list")
339        }
340        fn visit_seq<A: de::SeqAccess<'de>>(
341            self,
342            mut sequence: A,
343        ) -> Result<Self::Value, A::Error> {
344            let mut entries = Vec::new();
345            while entries.len() < MAX_DOWNLOAD_ARTIFACTS {
346                match sequence.next_element()? {
347                    Some(entry) => entries.push(entry),
348                    None => return Ok(entries),
349                }
350            }
351            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
352                return Err(de::Error::custom(
353                    DownloadJournalRecordError::TooManyArtifacts,
354                ));
355            }
356            Ok(entries)
357        }
358    }
359    deserializer.deserialize_seq(ArtifactsVisitor)
360}
361
362/// Typed local journal identity, schema or transition rejection.
363#[derive(Debug, Error)]
364pub enum DownloadJournalRecordError {
365    /// Only v1 records are maintained.
366    #[error("unsupported download journal version {0}")]
367    UnsupportedVersion(u16),
368    /// An empty physical set cannot claim completion.
369    #[error("download journal artifacts must not be empty")]
370    EmptyArtifacts,
371    /// The bounded artifact count was exceeded.
372    #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
373    TooManyArtifacts,
374    /// More than one snapshot was selected for the same physical canister.
375    #[error("duplicate download canister identity")]
376    DuplicateCanister,
377    /// The supplied principal failed canonical admission.
378    #[error("invalid download canister principal")]
379    InvalidPrincipal,
380    /// The exact snapshot token is empty, excessive or contains nongraphic bytes.
381    #[error("invalid download snapshot token")]
382    InvalidSnapshotId,
383    /// A persisted path differs from its immutable identity-derived location.
384    #[error("download artifact path does not match source identity")]
385    ArtifactPathMismatch,
386    /// The requested physical target is not retained.
387    #[error("unknown download artifact")]
388    UnknownArtifact,
389    /// The supplied snapshot differs from the retained exact token.
390    #[error("download snapshot identity mismatch")]
391    SnapshotMismatch,
392    /// Progress cannot be reset, repeated or advanced over an intermediate state.
393    #[error("invalid download state transition from {from:?} to {to:?}")]
394    InvalidStateTransition {
395        /// Retained state.
396        from: ArtifactStateRecord,
397        /// Rejected next state.
398        to: ArtifactStateRecord,
399    },
400    /// Checksum evidence disagrees with lifecycle state.
401    #[error("invalid checksum evidence for download state {0:?}")]
402    InvalidChecksumState(ArtifactStateRecord),
403    /// The immutable intent or checksum is malformed.
404    #[error(transparent)]
405    Checksum(#[from] ChecksumError),
406}
407
408#[cfg(test)]
409mod tests;