ic_backup/model/download_journal/
mod.rs1mod view;
4pub use view::{DownloadArtifactView, DownloadJournalView, ResumeAction};
5
6use super::artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash};
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: canonical_hash(&fields.intent)?,
222 artifacts: fields.artifacts,
223 })
224 }
225}
226
227impl DownloadJournalRecord {
228 pub fn new(
233 intent: &str,
234 requests: Vec<DownloadArtifactRequest>,
235 ) -> Result<Self, DownloadJournalRecordError> {
236 if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
237 return Err(DownloadJournalRecordError::TooManyArtifacts);
238 }
239 let artifacts = requests
240 .into_iter()
241 .map(DownloadArtifactRecord::new)
242 .collect::<Result<Vec<_>, _>>()?;
243 JournalFields {
244 version: 1,
245 intent: intent.to_owned(),
246 artifacts,
247 }
248 .try_into()
249 }
250
251 #[must_use]
253 pub fn intent(&self) -> &str {
254 &self.intent
255 }
256 #[must_use]
258 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
259 &self.artifacts
260 }
261
262 #[must_use]
271 #[expect(
272 clippy::cast_possible_truncation,
273 reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
274 )]
275 pub fn digest(&self) -> ArtifactChecksumRecord {
276 let mut bytes = b"ic-backup/download-journal/v1\0".to_vec();
277 bytes.extend_from_slice(self.intent.as_bytes());
278 bytes.extend_from_slice(&(self.artifacts.len() as u64).to_be_bytes());
279 for entry in &self.artifacts {
280 for value in [
281 entry.canister_id(),
282 entry.snapshot_id(),
283 entry.staging_path(),
284 entry.artifact_path(),
285 ] {
286 bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
287 bytes.extend_from_slice(value.as_bytes());
288 }
289 bytes.extend_from_slice(&entry.snapshot_taken_at_timestamp.to_be_bytes());
290 bytes.extend_from_slice(&entry.snapshot_total_size_bytes.to_be_bytes());
291 bytes.push(match entry.state {
292 ArtifactStateRecord::Created => 0,
293 ArtifactStateRecord::Downloaded => 1,
294 ArtifactStateRecord::ChecksumVerified => 2,
295 ArtifactStateRecord::Durable => 3,
296 });
297 bytes.push(u8::from(entry.checksum.is_some()));
298 if let Some(checksum) = &entry.checksum {
299 bytes.extend_from_slice(checksum.hash().as_bytes());
300 }
301 }
302 ArtifactChecksumRecord::from_bytes(&bytes)
303 }
304
305 pub(crate) fn artifact(
306 &self,
307 canister: &str,
308 snapshot: &str,
309 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
310 let canister = normalize_principal(canister)?;
311 let entry = self
312 .artifacts
313 .iter()
314 .find(|entry| entry.canister_id == canister)
315 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
316 if entry.snapshot_id != snapshot {
317 return Err(DownloadJournalRecordError::SnapshotMismatch);
318 }
319 Ok(entry)
320 }
321
322 pub(crate) fn advance(
323 &mut self,
324 canister: &str,
325 snapshot: &str,
326 next: ArtifactStateRecord,
327 checksum: Option<ArtifactChecksumRecord>,
328 ) -> Result<(), DownloadJournalRecordError> {
329 let entry = self.artifact(canister, snapshot)?;
330 let from = entry.state;
331 if !from.can_advance_to(next) {
332 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
333 }
334 let checksum = match next {
335 ArtifactStateRecord::Downloaded if checksum.is_none() => None,
336 ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
337 ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
338 _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
339 };
340 let canister = entry.canister_id.clone();
341 let entry = self
342 .artifacts
343 .iter_mut()
344 .find(|entry| entry.canister_id == canister)
345 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
346 entry.state = next;
347 entry.checksum = checksum;
348 Ok(())
349 }
350}
351
352fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
353 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
354}
355
356fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
357 if entries.is_empty() {
358 return Err(DownloadJournalRecordError::EmptyArtifacts);
359 }
360 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
361 return Err(DownloadJournalRecordError::TooManyArtifacts);
362 }
363 let mut identities = BTreeSet::new();
364 for entry in entries {
365 if !identities.insert(entry.canister_id()) {
366 return Err(DownloadJournalRecordError::DuplicateCanister);
367 }
368 }
369 Ok(())
370}
371
372fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
373 deserializer: D,
374) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
375 struct ArtifactsVisitor;
376 impl<'de> de::Visitor<'de> for ArtifactsVisitor {
377 type Value = Vec<DownloadArtifactRecord>;
378 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
379 formatter.write_str("a bounded artifact list")
380 }
381 fn visit_seq<A: de::SeqAccess<'de>>(
382 self,
383 mut sequence: A,
384 ) -> Result<Self::Value, A::Error> {
385 let mut entries = Vec::new();
386 while entries.len() < MAX_DOWNLOAD_ARTIFACTS {
387 match sequence.next_element()? {
388 Some(entry) => entries.push(entry),
389 None => return Ok(entries),
390 }
391 }
392 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
393 return Err(de::Error::custom(
394 DownloadJournalRecordError::TooManyArtifacts,
395 ));
396 }
397 Ok(entries)
398 }
399 }
400 deserializer.deserialize_seq(ArtifactsVisitor)
401}
402
403#[derive(Debug, Error)]
405pub enum DownloadJournalRecordError {
406 #[error("unsupported download journal version {0}")]
408 UnsupportedVersion(u16),
409 #[error("download journal artifacts must not be empty")]
411 EmptyArtifacts,
412 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
414 TooManyArtifacts,
415 #[error("duplicate download canister identity")]
417 DuplicateCanister,
418 #[error("invalid download canister principal")]
420 InvalidPrincipal,
421 #[error("invalid download snapshot token")]
423 InvalidSnapshotId,
424 #[error("download artifact path does not match source identity")]
426 ArtifactPathMismatch,
427 #[error("unknown download artifact")]
429 UnknownArtifact,
430 #[error("download snapshot identity mismatch")]
432 SnapshotMismatch,
433 #[error("invalid download state transition from {from:?} to {to:?}")]
435 InvalidStateTransition {
436 from: ArtifactStateRecord,
438 to: ArtifactStateRecord,
440 },
441 #[error("invalid checksum evidence for download state {0:?}")]
443 InvalidChecksumState(ArtifactStateRecord),
444 #[error(transparent)]
446 Checksum(#[from] ChecksumError),
447}
448
449#[cfg(test)]
450mod tests;