Skip to main content

ic_backup/model/download_journal/
mod.rs

1//! One IC snapshot artifact per original transfer stage, with model-owned publication.
2
3use crate::model::evidence_digest::EvidenceDigest;
4
5use super::artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Maximum encoded journal bytes admitted by persistence operations.
10pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
11/// Maximum ASCII bytes in an opaque backend snapshot identifier.
12pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
13
14/// Snapshot identity admitted by the caller after authoritative capture observation.
15///
16/// This passive request grants no remote authority and contains no signing material.
17#[derive(Clone, Debug)]
18pub struct DownloadArtifactRequest {
19    /// Exact source canister principal, normalized on admission.
20    pub canister_id: String,
21    /// Exact backend snapshot token; no case folding or inferred identity.
22    pub snapshot_id: String,
23    /// Observed snapshot timestamp in nanoseconds.
24    pub snapshot_taken_at_timestamp: u64,
25    /// Observed snapshot size, not the encoded artifact directory's byte length.
26    pub snapshot_total_size_bytes: u64,
27}
28
29/// Local IC artifact publication states; partial transfers retain Created.
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31pub enum ArtifactStateRecord {
32    /// Exact snapshot identity retained before local download completion.
33    Created,
34    /// Local bytes verified and their canonical checksum retained.
35    ChecksumVerified,
36    /// Verified bytes durably published at the canonical location.
37    Durable,
38}
39
40// Internal transition inputs cannot pair a state with inappropriate checksum
41// evidence. Persisted fields retain their independent decoding/corruption checks.
42pub(crate) enum ArtifactTransition {
43    ChecksumVerified(ArtifactChecksumRecord),
44    Durable,
45}
46
47impl ArtifactTransition {
48    const fn state(&self) -> ArtifactStateRecord {
49        match self {
50            Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
51            Self::Durable => ArtifactStateRecord::Durable,
52        }
53    }
54}
55
56/// Immutable exact snapshot identity with validated local progress evidence.
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(try_from = "ArtifactFields")]
59pub struct DownloadArtifactRecord {
60    canister_id: String,
61    snapshot_id: String,
62    snapshot_taken_at_timestamp: u64,
63    snapshot_total_size_bytes: u64,
64    staging_path: String,
65    artifact_path: String,
66    #[serde(flatten)]
67    progress: ArtifactProgress,
68}
69
70// Keep verified evidence in the state that requires it; only boundary fields are independent.
71#[derive(Clone, Debug, Eq, PartialEq)]
72enum ArtifactProgress {
73    Created,
74    ChecksumVerified(ArtifactChecksumRecord),
75    Durable(ArtifactChecksumRecord),
76}
77impl ArtifactProgress {
78    const fn state(&self) -> ArtifactStateRecord {
79        match self {
80            Self::Created => ArtifactStateRecord::Created,
81            Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
82            Self::Durable(_) => ArtifactStateRecord::Durable,
83        }
84    }
85    const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
86        match self {
87            Self::ChecksumVerified(checksum) | Self::Durable(checksum) => Some(checksum),
88            Self::Created => None,
89        }
90    }
91}
92impl Serialize for ArtifactProgress {
93    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94        use serde::ser::SerializeStruct;
95        let mut fields = serializer.serialize_struct("ArtifactProgress", 2)?;
96        fields.serialize_field("state", &self.state())?;
97        fields.serialize_field("checksum", &self.checksum())?;
98        fields.end()
99    }
100}
101
102#[derive(Deserialize)]
103#[serde(deny_unknown_fields)]
104struct ArtifactFields {
105    canister_id: String,
106    snapshot_id: String,
107    snapshot_taken_at_timestamp: u64,
108    snapshot_total_size_bytes: u64,
109    staging_path: String,
110    artifact_path: String,
111    state: ArtifactStateRecord,
112    #[serde(deserialize_with = "crate::model::required_option::deserialize")]
113    checksum: Option<ArtifactChecksumRecord>,
114}
115
116impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
117    type Error = DownloadJournalRecordError;
118    fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
119        let mut record = Self::new(DownloadArtifactRequest {
120            canister_id: fields.canister_id,
121            snapshot_id: fields.snapshot_id,
122            snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
123            snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
124        })?;
125        // Persisted paths must agree with normalized identity, never with a label.
126        if fields.staging_path != record.staging_path
127            || fields.artifact_path != record.artifact_path
128        {
129            return Err(DownloadJournalRecordError::ArtifactPathMismatch);
130        }
131        record.progress = match (fields.state, fields.checksum) {
132            (ArtifactStateRecord::Created, None) => ArtifactProgress::Created,
133            (ArtifactStateRecord::ChecksumVerified, Some(checksum)) => {
134                ArtifactProgress::ChecksumVerified(checksum)
135            }
136            (ArtifactStateRecord::Durable, Some(checksum)) => ArtifactProgress::Durable(checksum),
137            (state, _) => return Err(DownloadJournalRecordError::InvalidChecksumState(state)),
138        };
139        Ok(record)
140    }
141}
142
143impl DownloadArtifactRecord {
144    fn new(request: DownloadArtifactRequest) -> Result<Self, DownloadJournalRecordError> {
145        let canister_id = normalize_principal(&request.canister_id)?;
146        if request.snapshot_id.is_empty()
147            || request.snapshot_id.len() > MAX_SNAPSHOT_ID_BYTES
148            || !request
149                .snapshot_id
150                .bytes()
151                .all(|byte| byte.is_ascii_graphic())
152        {
153            return Err(DownloadJournalRecordError::InvalidSnapshotId);
154        }
155        Ok(Self {
156            staging_path: format!("artifacts/{canister_id}.tmp"),
157            artifact_path: format!("artifacts/{canister_id}"),
158            canister_id,
159            snapshot_id: request.snapshot_id,
160            snapshot_taken_at_timestamp: request.snapshot_taken_at_timestamp,
161            snapshot_total_size_bytes: request.snapshot_total_size_bytes,
162            progress: ArtifactProgress::Created,
163        })
164    }
165
166    /// Return the normalized exact source principal.
167    #[must_use]
168    pub fn canister_id(&self) -> &str {
169        &self.canister_id
170    }
171    /// Return the exact backend snapshot token.
172    #[must_use]
173    pub fn snapshot_id(&self) -> &str {
174        &self.snapshot_id
175    }
176    /// Return the observed snapshot timestamp in nanoseconds.
177    #[must_use]
178    pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
179        self.snapshot_taken_at_timestamp
180    }
181    /// Return the observed snapshot size; encoded directory size may differ.
182    #[must_use]
183    pub const fn snapshot_total_size_bytes(&self) -> u64 {
184        self.snapshot_total_size_bytes
185    }
186    /// Return the fixed relative staging directory for this principal.
187    #[must_use]
188    pub fn staging_path(&self) -> &str {
189        &self.staging_path
190    }
191    /// Return the fixed relative canonical directory for this principal.
192    #[must_use]
193    pub fn artifact_path(&self) -> &str {
194        &self.artifact_path
195    }
196    /// Return retained local lifecycle state.
197    #[must_use]
198    pub const fn state(&self) -> ArtifactStateRecord {
199        self.progress.state()
200    }
201    /// Return the retained exact checksum when verification has completed.
202    #[must_use]
203    pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
204        self.progress.checksum()
205    }
206}
207
208/// Maintained v1 local artifact journal bound to a caller-supplied intent digest.
209///
210/// This record neither authenticates that intent nor proves current remote authority.
211#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
212#[serde(try_from = "JournalFields")]
213pub struct DownloadJournalRecord {
214    version: u16,
215    intent: String,
216    artifacts: [DownloadArtifactRecord; 1],
217}
218
219#[derive(Deserialize)]
220#[serde(deny_unknown_fields)]
221struct JournalFields {
222    version: u16,
223    intent: String,
224    artifacts: [DownloadArtifactRecord; 1],
225}
226
227impl TryFrom<JournalFields> for DownloadJournalRecord {
228    type Error = DownloadJournalRecordError;
229    fn try_from(fields: JournalFields) -> Result<Self, Self::Error> {
230        if fields.version != 1 {
231            return Err(DownloadJournalRecordError::UnsupportedVersion(
232                fields.version,
233            ));
234        }
235        Ok(Self {
236            version: 1,
237            intent: canonical_hash(&fields.intent)?,
238            artifacts: fields.artifacts,
239        })
240    }
241}
242
243impl DownloadJournalRecord {
244    /// Admit one snapshot artifact under its original transfer intent.
245    /// # Errors
246    /// Rejects invalid source identities, tokens and intent digests.
247    pub fn new(
248        intent: &str,
249        request: DownloadArtifactRequest,
250    ) -> Result<Self, DownloadJournalRecordError> {
251        JournalFields {
252            version: 1,
253            intent: intent.to_owned(),
254            artifacts: [DownloadArtifactRecord::new(request)?],
255        }
256        .try_into()
257    }
258
259    /// Return the canonical caller-supplied immutable intent digest.
260    #[must_use]
261    pub fn intent(&self) -> &str {
262        &self.intent
263    }
264    /// Read the sole artifact in the retained v1 singleton wire envelope.
265    #[must_use]
266    pub const fn artifacts(&self) -> &[DownloadArtifactRecord; 1] {
267        &self.artifacts
268    }
269
270    /// Hash canonical original intent, exact snapshot metadata, paths and local evidence.
271    ///
272    /// The NUL-terminated v1 domain precedes 64 ASCII intent bytes and a big-endian
273    /// u64 entry count. Each canonical entry has four u32-length-prefixed UTF-8
274    /// strings (principal, snapshot token, staging path, artifact path), two u64
275    /// metadata values, a state byte (Created=0, ChecksumVerified=2, Durable=3), then a checksum
276    /// presence byte and its 64 ASCII hash when present. Integers are big-endian.
277    /// This grants no transfer, snapshot authenticity or terminal proof.
278    #[must_use]
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.u64_be(self.artifacts.len() as u64);
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.bytes_u32(value.as_bytes());
291            }
292            bytes.u64_be(entry.snapshot_taken_at_timestamp);
293            bytes.u64_be(entry.snapshot_total_size_bytes);
294            bytes.byte(match entry.state() {
295                ArtifactStateRecord::Created => 0,
296                ArtifactStateRecord::ChecksumVerified => 2,
297                ArtifactStateRecord::Durable => 3,
298            });
299            bytes.byte(u8::from(entry.checksum().is_some()));
300            if let Some(checksum) = entry.checksum() {
301                bytes.checksum(checksum);
302            }
303        }
304        bytes.finish()
305    }
306
307    pub(crate) fn artifact(
308        &self,
309        canister: &str,
310        snapshot: &str,
311    ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
312        self.admit_identity(canister, snapshot)?;
313        Ok(&self.artifacts[0])
314    }
315
316    fn admit_identity(
317        &self,
318        canister: &str,
319        snapshot: &str,
320    ) -> Result<(), DownloadJournalRecordError> {
321        let canister = normalize_principal(canister)?;
322        if self.artifacts[0].canister_id != canister {
323            return Err(DownloadJournalRecordError::UnknownArtifact);
324        }
325        if self.artifacts[0].snapshot_id != snapshot {
326            return Err(DownloadJournalRecordError::SnapshotMismatch);
327        }
328        Ok(())
329    }
330
331    pub(crate) fn advance(
332        &mut self,
333        canister: &str,
334        snapshot: &str,
335        transition: ArtifactTransition,
336    ) -> Result<(), DownloadJournalRecordError> {
337        self.admit_identity(canister, snapshot)?;
338        let entry = &mut self.artifacts[0];
339        entry.progress = match (&entry.progress, transition) {
340            (ArtifactProgress::Created, ArtifactTransition::ChecksumVerified(checksum)) => {
341                ArtifactProgress::ChecksumVerified(checksum)
342            }
343            (ArtifactProgress::ChecksumVerified(checksum), ArtifactTransition::Durable) => {
344                ArtifactProgress::Durable(checksum.clone())
345            }
346            (_, transition) => {
347                return Err(DownloadJournalRecordError::InvalidStateTransition {
348                    from: entry.state(),
349                    to: transition.state(),
350                });
351            }
352        };
353        Ok(())
354    }
355}
356
357fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
358    super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
359}
360
361/// Typed local journal identity, schema or transition rejection.
362#[derive(Debug, Error)]
363pub enum DownloadJournalRecordError {
364    /// Only v1 records are maintained.
365    #[error("unsupported download journal version {0}")]
366    UnsupportedVersion(u16),
367    /// The supplied principal failed canonical admission.
368    #[error("invalid download canister principal")]
369    InvalidPrincipal,
370    /// The exact snapshot token is empty, excessive or contains nongraphic bytes.
371    #[error("invalid download snapshot token")]
372    InvalidSnapshotId,
373    /// A persisted path differs from its immutable identity-derived location.
374    #[error("download artifact path does not match source identity")]
375    ArtifactPathMismatch,
376    /// The requested physical target is not retained.
377    #[error("unknown download artifact")]
378    UnknownArtifact,
379    /// The supplied snapshot differs from the retained exact token.
380    #[error("download snapshot identity mismatch")]
381    SnapshotMismatch,
382    /// Progress cannot be reset, repeated or advanced over an intermediate state.
383    #[error("invalid download state transition from {from:?} to {to:?}")]
384    InvalidStateTransition {
385        /// Retained state.
386        from: ArtifactStateRecord,
387        /// Rejected next state.
388        to: ArtifactStateRecord,
389    },
390    /// Checksum evidence disagrees with lifecycle state.
391    #[error("invalid checksum evidence for download state {0:?}")]
392    InvalidChecksumState(ArtifactStateRecord),
393    /// The immutable intent or checksum is malformed.
394    #[error(transparent)]
395    Checksum(#[from] ChecksumError),
396}
397
398#[cfg(test)]
399mod tests;