ic_backup/model/download_journal/
mod.rs1use 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
15pub const MAX_DOWNLOAD_ARTIFACTS: usize = 1024;
17pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
19pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
21
22#[derive(Clone, Debug)]
26pub struct DownloadArtifactRequest {
27 pub canister_id: String,
29 pub snapshot_id: String,
31 pub snapshot_taken_at_timestamp: u64,
33 pub snapshot_total_size_bytes: u64,
35}
36
37#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub enum ArtifactStateRecord {
40 Created,
42 Downloaded,
44 ChecksumVerified,
46 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#[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 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 #[must_use]
151 pub fn canister_id(&self) -> &str {
152 &self.canister_id
153 }
154 #[must_use]
156 pub fn snapshot_id(&self) -> &str {
157 &self.snapshot_id
158 }
159 #[must_use]
161 pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
162 self.snapshot_taken_at_timestamp
163 }
164 #[must_use]
166 pub const fn snapshot_total_size_bytes(&self) -> u64 {
167 self.snapshot_total_size_bytes
168 }
169 #[must_use]
171 pub fn staging_path(&self) -> &str {
172 &self.staging_path
173 }
174 #[must_use]
176 pub fn artifact_path(&self) -> &str {
177 &self.artifact_path
178 }
179 #[must_use]
181 pub const fn state(&self) -> ArtifactStateRecord {
182 self.state
183 }
184 #[must_use]
186 pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
187 self.checksum.as_ref()
188 }
189}
190
191#[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 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 #[must_use]
257 pub fn intent(&self) -> &str {
258 &self.intent
259 }
260 #[must_use]
262 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
263 &self.artifacts
264 }
265
266 #[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#[derive(Debug, Error)]
388pub enum DownloadJournalRecordError {
389 #[error("unsupported download journal version {0}")]
391 UnsupportedVersion(u16),
392 #[error("download journal artifacts must not be empty")]
394 EmptyArtifacts,
395 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
397 TooManyArtifacts,
398 #[error("duplicate download canister identity")]
400 DuplicateCanister,
401 #[error("invalid download canister principal")]
403 InvalidPrincipal,
404 #[error("invalid download snapshot token")]
406 InvalidSnapshotId,
407 #[error("download artifact path does not match source identity")]
409 ArtifactPathMismatch,
410 #[error("unknown download artifact")]
412 UnknownArtifact,
413 #[error("download snapshot identity mismatch")]
415 SnapshotMismatch,
416 #[error("invalid download state transition from {from:?} to {to:?}")]
418 InvalidStateTransition {
419 from: ArtifactStateRecord,
421 to: ArtifactStateRecord,
423 },
424 #[error("invalid checksum evidence for download state {0:?}")]
426 InvalidChecksumState(ArtifactStateRecord),
427 #[error(transparent)]
429 Checksum(#[from] ChecksumError),
430}
431
432#[cfg(test)]
433mod tests;