ic_backup/model/download_journal/
mod.rs1mod 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
11pub const MAX_DOWNLOAD_ARTIFACTS: usize = 1024;
13pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
15pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
17
18#[derive(Clone, Debug)]
22pub struct DownloadArtifactRequest {
23 pub canister_id: String,
25 pub snapshot_id: String,
27 pub snapshot_taken_at_timestamp: u64,
29 pub snapshot_total_size_bytes: u64,
31}
32
33#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub enum ArtifactStateRecord {
36 Created,
38 Downloaded,
40 ChecksumVerified,
42 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#[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 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 #[must_use]
147 pub fn canister_id(&self) -> &str {
148 &self.canister_id
149 }
150 #[must_use]
152 pub fn snapshot_id(&self) -> &str {
153 &self.snapshot_id
154 }
155 #[must_use]
157 pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
158 self.snapshot_taken_at_timestamp
159 }
160 #[must_use]
162 pub const fn snapshot_total_size_bytes(&self) -> u64 {
163 self.snapshot_total_size_bytes
164 }
165 #[must_use]
167 pub fn staging_path(&self) -> &str {
168 &self.staging_path
169 }
170 #[must_use]
172 pub fn artifact_path(&self) -> &str {
173 &self.artifact_path
174 }
175 #[must_use]
177 pub const fn state(&self) -> ArtifactStateRecord {
178 self.state
179 }
180 #[must_use]
182 pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
183 self.checksum.as_ref()
184 }
185}
186
187#[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 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 #[must_use]
255 pub fn intent(&self) -> &str {
256 &self.intent
257 }
258 #[must_use]
260 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
261 &self.artifacts
262 }
263
264 #[must_use]
273 #[expect(
274 clippy::cast_possible_truncation,
275 reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
276 )]
277 pub fn digest(&self) -> ArtifactChecksumRecord {
278 let mut bytes = b"ic-backup/download-journal/v1\0".to_vec();
279 bytes.extend_from_slice(self.intent.as_bytes());
280 bytes.extend_from_slice(&(self.artifacts.len() as u64).to_be_bytes());
281 for entry in &self.artifacts {
282 for value in [
283 entry.canister_id(),
284 entry.snapshot_id(),
285 entry.staging_path(),
286 entry.artifact_path(),
287 ] {
288 bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
289 bytes.extend_from_slice(value.as_bytes());
290 }
291 bytes.extend_from_slice(&entry.snapshot_taken_at_timestamp.to_be_bytes());
292 bytes.extend_from_slice(&entry.snapshot_total_size_bytes.to_be_bytes());
293 bytes.push(match entry.state {
294 ArtifactStateRecord::Created => 0,
295 ArtifactStateRecord::Downloaded => 1,
296 ArtifactStateRecord::ChecksumVerified => 2,
297 ArtifactStateRecord::Durable => 3,
298 });
299 bytes.push(u8::from(entry.checksum.is_some()));
300 if let Some(checksum) = &entry.checksum {
301 bytes.extend_from_slice(checksum.hash().as_bytes());
302 }
303 }
304 ArtifactChecksumRecord::from_bytes(&bytes)
305 }
306
307 pub(crate) fn artifact(
308 &self,
309 canister: &str,
310 snapshot: &str,
311 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
312 let canister = normalize_principal(canister)?;
313 let entry = self
314 .artifacts
315 .iter()
316 .find(|entry| entry.canister_id == canister)
317 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
318 if entry.snapshot_id != snapshot {
319 return Err(DownloadJournalRecordError::SnapshotMismatch);
320 }
321 Ok(entry)
322 }
323
324 pub(crate) fn advance(
325 &mut self,
326 canister: &str,
327 snapshot: &str,
328 next: ArtifactStateRecord,
329 checksum: Option<ArtifactChecksumRecord>,
330 ) -> Result<(), DownloadJournalRecordError> {
331 let entry = self.artifact(canister, snapshot)?;
332 let from = entry.state;
333 if !from.can_advance_to(next) {
334 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
335 }
336 let checksum = match next {
337 ArtifactStateRecord::Downloaded if checksum.is_none() => None,
338 ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
339 ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
340 _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
341 };
342 let canister = entry.canister_id.clone();
343 let entry = self
344 .artifacts
345 .iter_mut()
346 .find(|entry| entry.canister_id == canister)
347 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
348 entry.state = next;
349 entry.checksum = checksum;
350 Ok(())
351 }
352}
353
354fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
355 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
356}
357
358fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
359 if entries.is_empty() {
360 return Err(DownloadJournalRecordError::EmptyArtifacts);
361 }
362 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
363 return Err(DownloadJournalRecordError::TooManyArtifacts);
364 }
365 let mut identities = BTreeSet::new();
366 for entry in entries {
367 if !identities.insert(entry.canister_id()) {
368 return Err(DownloadJournalRecordError::DuplicateCanister);
369 }
370 }
371 Ok(())
372}
373
374fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
375 deserializer: D,
376) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
377 struct ArtifactsVisitor;
378 impl<'de> de::Visitor<'de> for ArtifactsVisitor {
379 type Value = Vec<DownloadArtifactRecord>;
380 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381 formatter.write_str("a bounded artifact list")
382 }
383 fn visit_seq<A: de::SeqAccess<'de>>(
384 self,
385 mut sequence: A,
386 ) -> Result<Self::Value, A::Error> {
387 let mut entries = Vec::new();
388 while entries.len() < MAX_DOWNLOAD_ARTIFACTS {
389 match sequence.next_element()? {
390 Some(entry) => entries.push(entry),
391 None => return Ok(entries),
392 }
393 }
394 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
395 return Err(de::Error::custom(
396 DownloadJournalRecordError::TooManyArtifacts,
397 ));
398 }
399 Ok(entries)
400 }
401 }
402 deserializer.deserialize_seq(ArtifactsVisitor)
403}
404
405#[derive(Debug, Error)]
407pub enum DownloadJournalRecordError {
408 #[error("unsupported download journal version {0}")]
410 UnsupportedVersion(u16),
411 #[error("download journal artifacts must not be empty")]
413 EmptyArtifacts,
414 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
416 TooManyArtifacts,
417 #[error("duplicate download canister identity")]
419 DuplicateCanister,
420 #[error("invalid download canister principal")]
422 InvalidPrincipal,
423 #[error("invalid download snapshot token")]
425 InvalidSnapshotId,
426 #[error("download artifact path does not match source identity")]
428 ArtifactPathMismatch,
429 #[error("unknown download artifact")]
431 UnknownArtifact,
432 #[error("download snapshot identity mismatch")]
434 SnapshotMismatch,
435 #[error("invalid download state transition from {from:?} to {to:?}")]
437 InvalidStateTransition {
438 from: ArtifactStateRecord,
440 to: ArtifactStateRecord,
442 },
443 #[error("invalid checksum evidence for download state {0:?}")]
445 InvalidChecksumState(ArtifactStateRecord),
446 #[error(transparent)]
448 Checksum(#[from] ChecksumError),
449}
450
451#[cfg(test)]
452mod tests;