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
61pub(crate) enum ArtifactTransition {
64 Downloaded,
65 ChecksumVerified(ArtifactChecksumRecord),
66 Durable,
67}
68
69impl ArtifactTransition {
70 const fn state(&self) -> ArtifactStateRecord {
71 match self {
72 Self::Downloaded => ArtifactStateRecord::Downloaded,
73 Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
74 Self::Durable => ArtifactStateRecord::Durable,
75 }
76 }
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(try_from = "ArtifactFields")]
82pub struct DownloadArtifactRecord {
83 canister_id: String,
84 snapshot_id: String,
85 snapshot_taken_at_timestamp: u64,
86 snapshot_total_size_bytes: u64,
87 staging_path: String,
88 artifact_path: String,
89 state: ArtifactStateRecord,
90 checksum: Option<ArtifactChecksumRecord>,
91}
92
93#[derive(Deserialize)]
94#[serde(deny_unknown_fields)]
95struct ArtifactFields {
96 canister_id: String,
97 snapshot_id: String,
98 snapshot_taken_at_timestamp: u64,
99 snapshot_total_size_bytes: u64,
100 staging_path: String,
101 artifact_path: String,
102 state: ArtifactStateRecord,
103 #[serde(deserialize_with = "required_checksum")]
104 checksum: Option<ArtifactChecksumRecord>,
105}
106
107fn required_checksum<'de, D: Deserializer<'de>>(
108 deserializer: D,
109) -> Result<Option<ArtifactChecksumRecord>, D::Error> {
110 Option::deserialize(deserializer)
111}
112
113impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
114 type Error = DownloadJournalRecordError;
115 fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
116 let mut record = Self::new(DownloadArtifactRequest {
117 canister_id: fields.canister_id,
118 snapshot_id: fields.snapshot_id,
119 snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
120 snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
121 })?;
122 if fields.staging_path != record.staging_path
124 || fields.artifact_path != record.artifact_path
125 {
126 return Err(DownloadJournalRecordError::ArtifactPathMismatch);
127 }
128 let requires_checksum = matches!(
129 fields.state,
130 ArtifactStateRecord::ChecksumVerified | ArtifactStateRecord::Durable
131 );
132 if requires_checksum != fields.checksum.is_some() {
133 return Err(DownloadJournalRecordError::InvalidChecksumState(
134 fields.state,
135 ));
136 }
137 record.state = fields.state;
138 record.checksum = fields.checksum;
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 state: ArtifactStateRecord::Created,
163 checksum: None,
164 })
165 }
166
167 #[must_use]
169 pub fn canister_id(&self) -> &str {
170 &self.canister_id
171 }
172 #[must_use]
174 pub fn snapshot_id(&self) -> &str {
175 &self.snapshot_id
176 }
177 #[must_use]
179 pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
180 self.snapshot_taken_at_timestamp
181 }
182 #[must_use]
184 pub const fn snapshot_total_size_bytes(&self) -> u64 {
185 self.snapshot_total_size_bytes
186 }
187 #[must_use]
189 pub fn staging_path(&self) -> &str {
190 &self.staging_path
191 }
192 #[must_use]
194 pub fn artifact_path(&self) -> &str {
195 &self.artifact_path
196 }
197 #[must_use]
199 pub const fn state(&self) -> ArtifactStateRecord {
200 self.state
201 }
202 #[must_use]
204 pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
205 self.checksum.as_ref()
206 }
207}
208
209#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
213#[serde(try_from = "JournalFields")]
214pub struct DownloadJournalRecord {
215 version: u16,
216 intent: String,
217 artifacts: Vec<DownloadArtifactRecord>,
218}
219
220#[derive(Deserialize)]
221#[serde(deny_unknown_fields)]
222struct JournalFields {
223 version: u16,
224 intent: String,
225 #[serde(deserialize_with = "read_bounded_artifacts")]
226 artifacts: Vec<DownloadArtifactRecord>,
227}
228
229impl TryFrom<JournalFields> for DownloadJournalRecord {
230 type Error = DownloadJournalRecordError;
231 fn try_from(mut fields: JournalFields) -> Result<Self, Self::Error> {
232 if fields.version != 1 {
233 return Err(DownloadJournalRecordError::UnsupportedVersion(
234 fields.version,
235 ));
236 }
237 validate_entries(&fields.artifacts)?;
238 fields
239 .artifacts
240 .sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
241 Ok(Self {
242 version: 1,
243 intent: canonical_hash(&fields.intent)?,
244 artifacts: fields.artifacts,
245 })
246 }
247}
248
249impl DownloadJournalRecord {
250 pub fn new(
255 intent: &str,
256 requests: Vec<DownloadArtifactRequest>,
257 ) -> Result<Self, DownloadJournalRecordError> {
258 if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
259 return Err(DownloadJournalRecordError::TooManyArtifacts);
260 }
261 let artifacts = requests
262 .into_iter()
263 .map(DownloadArtifactRecord::new)
264 .collect::<Result<Vec<_>, _>>()?;
265 JournalFields {
266 version: 1,
267 intent: intent.to_owned(),
268 artifacts,
269 }
270 .try_into()
271 }
272
273 #[must_use]
275 pub fn intent(&self) -> &str {
276 &self.intent
277 }
278 #[must_use]
280 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
281 &self.artifacts
282 }
283
284 #[must_use]
293 #[expect(
294 clippy::cast_possible_truncation,
295 reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
296 )]
297 pub fn digest(&self) -> ArtifactChecksumRecord {
298 let mut bytes = EvidenceDigest::new(b"ic-backup/download-journal/v1\0");
299 bytes.append(self.intent.as_bytes());
300 bytes.u64_be(self.artifacts.len() as u64);
301 for entry in &self.artifacts {
302 for value in [
303 entry.canister_id(),
304 entry.snapshot_id(),
305 entry.staging_path(),
306 entry.artifact_path(),
307 ] {
308 bytes.u32_be(value.len() as u32);
309 bytes.append(value.as_bytes());
310 }
311 bytes.u64_be(entry.snapshot_taken_at_timestamp);
312 bytes.u64_be(entry.snapshot_total_size_bytes);
313 bytes.byte(match entry.state {
314 ArtifactStateRecord::Created => 0,
315 ArtifactStateRecord::Downloaded => 1,
316 ArtifactStateRecord::ChecksumVerified => 2,
317 ArtifactStateRecord::Durable => 3,
318 });
319 bytes.byte(u8::from(entry.checksum.is_some()));
320 if let Some(checksum) = &entry.checksum {
321 bytes.checksum(checksum);
322 }
323 }
324 bytes.finish()
325 }
326
327 pub(crate) fn artifact(
328 &self,
329 canister: &str,
330 snapshot: &str,
331 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
332 Ok(&self.artifacts[self.artifact_index(canister, snapshot)?])
333 }
334
335 fn artifact_index(
336 &self,
337 canister: &str,
338 snapshot: &str,
339 ) -> Result<usize, DownloadJournalRecordError> {
340 let canister = normalize_principal(canister)?;
341 let index = self
342 .artifacts
343 .binary_search_by(|entry| entry.canister_id.cmp(&canister))
344 .map_err(|_| DownloadJournalRecordError::UnknownArtifact)?;
345 if self.artifacts[index].snapshot_id != snapshot {
346 return Err(DownloadJournalRecordError::SnapshotMismatch);
347 }
348 Ok(index)
349 }
350
351 pub(crate) fn advance(
352 &mut self,
353 canister: &str,
354 snapshot: &str,
355 transition: ArtifactTransition,
356 ) -> Result<(), DownloadJournalRecordError> {
357 let index = self.artifact_index(canister, snapshot)?;
358 let entry = &mut self.artifacts[index];
359 let from = entry.state;
360 let next = transition.state();
361 if !from.can_advance_to(next) {
362 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
363 }
364 let checksum = match transition {
365 ArtifactTransition::Downloaded => None,
366 ArtifactTransition::ChecksumVerified(checksum) => Some(checksum),
367 ArtifactTransition::Durable => entry.checksum.clone(),
368 };
369 entry.state = next;
370 entry.checksum = checksum;
371 Ok(())
372 }
373}
374
375fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
376 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
377}
378
379fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
380 if entries.is_empty() {
381 return Err(DownloadJournalRecordError::EmptyArtifacts);
382 }
383 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
384 return Err(DownloadJournalRecordError::TooManyArtifacts);
385 }
386 let mut identities = BTreeSet::new();
387 for entry in entries {
388 if !identities.insert(entry.canister_id()) {
389 return Err(DownloadJournalRecordError::DuplicateCanister);
390 }
391 }
392 Ok(())
393}
394
395fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
396 deserializer: D,
397) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
398 bounded_sequence::deserialize::<_, _, MAX_DOWNLOAD_ARTIFACTS>(
399 deserializer,
400 "a bounded artifact list",
401 DownloadJournalRecordError::TooManyArtifacts,
402 )
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;