ic_backup/model/download_journal/
mod.rs1mod view;
4pub use view::{DownloadArtifactView, DownloadJournalView, ResumeAction};
5
6use super::{
7 artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash},
8 bounded_sequence,
9};
10use serde::{Deserialize, Deserializer, Serialize};
11use std::collections::BTreeSet;
12use thiserror::Error;
13
14pub const MAX_DOWNLOAD_ARTIFACTS: usize = 1024;
16pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
18pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
20
21#[derive(Clone, Debug)]
25pub struct DownloadArtifactRequest {
26 pub canister_id: String,
28 pub snapshot_id: String,
30 pub snapshot_taken_at_timestamp: u64,
32 pub snapshot_total_size_bytes: u64,
34}
35
36#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
38pub enum ArtifactStateRecord {
39 Created,
41 Downloaded,
43 ChecksumVerified,
45 Durable,
47}
48
49impl ArtifactStateRecord {
50 const fn can_advance_to(self, next: Self) -> bool {
51 matches!(
52 (self, next),
53 (Self::Created, Self::Downloaded)
54 | (Self::Downloaded, Self::ChecksumVerified)
55 | (Self::ChecksumVerified, Self::Durable)
56 )
57 }
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(try_from = "ArtifactFields")]
63pub struct DownloadArtifactRecord {
64 canister_id: String,
65 snapshot_id: String,
66 snapshot_taken_at_timestamp: u64,
67 snapshot_total_size_bytes: u64,
68 staging_path: String,
69 artifact_path: String,
70 state: ArtifactStateRecord,
71 checksum: Option<ArtifactChecksumRecord>,
72}
73
74#[derive(Deserialize)]
75#[serde(deny_unknown_fields)]
76struct ArtifactFields {
77 canister_id: String,
78 snapshot_id: String,
79 snapshot_taken_at_timestamp: u64,
80 snapshot_total_size_bytes: u64,
81 staging_path: String,
82 artifact_path: String,
83 state: ArtifactStateRecord,
84 #[serde(deserialize_with = "required_checksum")]
85 checksum: Option<ArtifactChecksumRecord>,
86}
87
88fn required_checksum<'de, D: Deserializer<'de>>(
89 deserializer: D,
90) -> Result<Option<ArtifactChecksumRecord>, D::Error> {
91 Option::deserialize(deserializer)
92}
93
94impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
95 type Error = DownloadJournalRecordError;
96 fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
97 let mut record = Self::new(DownloadArtifactRequest {
98 canister_id: fields.canister_id,
99 snapshot_id: fields.snapshot_id,
100 snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
101 snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
102 })?;
103 if fields.staging_path != record.staging_path
105 || fields.artifact_path != record.artifact_path
106 {
107 return Err(DownloadJournalRecordError::ArtifactPathMismatch);
108 }
109 let requires_checksum = matches!(
110 fields.state,
111 ArtifactStateRecord::ChecksumVerified | ArtifactStateRecord::Durable
112 );
113 if requires_checksum != fields.checksum.is_some() {
114 return Err(DownloadJournalRecordError::InvalidChecksumState(
115 fields.state,
116 ));
117 }
118 record.state = fields.state;
119 record.checksum = fields.checksum;
120 Ok(record)
121 }
122}
123
124impl DownloadArtifactRecord {
125 fn new(request: DownloadArtifactRequest) -> Result<Self, DownloadJournalRecordError> {
126 let canister_id = normalize_principal(&request.canister_id)?;
127 if request.snapshot_id.is_empty()
128 || request.snapshot_id.len() > MAX_SNAPSHOT_ID_BYTES
129 || !request
130 .snapshot_id
131 .bytes()
132 .all(|byte| byte.is_ascii_graphic())
133 {
134 return Err(DownloadJournalRecordError::InvalidSnapshotId);
135 }
136 Ok(Self {
137 staging_path: format!("artifacts/{canister_id}.tmp"),
138 artifact_path: format!("artifacts/{canister_id}"),
139 canister_id,
140 snapshot_id: request.snapshot_id,
141 snapshot_taken_at_timestamp: request.snapshot_taken_at_timestamp,
142 snapshot_total_size_bytes: request.snapshot_total_size_bytes,
143 state: ArtifactStateRecord::Created,
144 checksum: None,
145 })
146 }
147
148 #[must_use]
150 pub fn canister_id(&self) -> &str {
151 &self.canister_id
152 }
153 #[must_use]
155 pub fn snapshot_id(&self) -> &str {
156 &self.snapshot_id
157 }
158 #[must_use]
160 pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
161 self.snapshot_taken_at_timestamp
162 }
163 #[must_use]
165 pub const fn snapshot_total_size_bytes(&self) -> u64 {
166 self.snapshot_total_size_bytes
167 }
168 #[must_use]
170 pub fn staging_path(&self) -> &str {
171 &self.staging_path
172 }
173 #[must_use]
175 pub fn artifact_path(&self) -> &str {
176 &self.artifact_path
177 }
178 #[must_use]
180 pub const fn state(&self) -> ArtifactStateRecord {
181 self.state
182 }
183 #[must_use]
185 pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
186 self.checksum.as_ref()
187 }
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
194#[serde(try_from = "JournalFields")]
195pub struct DownloadJournalRecord {
196 version: u16,
197 intent: String,
198 artifacts: Vec<DownloadArtifactRecord>,
199}
200
201#[derive(Deserialize)]
202#[serde(deny_unknown_fields)]
203struct JournalFields {
204 version: u16,
205 intent: String,
206 #[serde(deserialize_with = "read_bounded_artifacts")]
207 artifacts: Vec<DownloadArtifactRecord>,
208}
209
210impl TryFrom<JournalFields> for DownloadJournalRecord {
211 type Error = DownloadJournalRecordError;
212 fn try_from(mut fields: JournalFields) -> Result<Self, Self::Error> {
213 if fields.version != 1 {
214 return Err(DownloadJournalRecordError::UnsupportedVersion(
215 fields.version,
216 ));
217 }
218 validate_entries(&fields.artifacts)?;
219 fields
220 .artifacts
221 .sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
222 Ok(Self {
223 version: 1,
224 intent: canonical_hash(&fields.intent)?,
225 artifacts: fields.artifacts,
226 })
227 }
228}
229
230impl DownloadJournalRecord {
231 pub fn new(
236 intent: &str,
237 requests: Vec<DownloadArtifactRequest>,
238 ) -> Result<Self, DownloadJournalRecordError> {
239 if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
240 return Err(DownloadJournalRecordError::TooManyArtifacts);
241 }
242 let artifacts = requests
243 .into_iter()
244 .map(DownloadArtifactRecord::new)
245 .collect::<Result<Vec<_>, _>>()?;
246 JournalFields {
247 version: 1,
248 intent: intent.to_owned(),
249 artifacts,
250 }
251 .try_into()
252 }
253
254 #[must_use]
256 pub fn intent(&self) -> &str {
257 &self.intent
258 }
259 #[must_use]
261 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
262 &self.artifacts
263 }
264
265 #[must_use]
274 #[expect(
275 clippy::cast_possible_truncation,
276 reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
277 )]
278 pub fn digest(&self) -> ArtifactChecksumRecord {
279 let mut bytes = b"ic-backup/download-journal/v1\0".to_vec();
280 bytes.extend_from_slice(self.intent.as_bytes());
281 bytes.extend_from_slice(&(self.artifacts.len() as u64).to_be_bytes());
282 for entry in &self.artifacts {
283 for value in [
284 entry.canister_id(),
285 entry.snapshot_id(),
286 entry.staging_path(),
287 entry.artifact_path(),
288 ] {
289 bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
290 bytes.extend_from_slice(value.as_bytes());
291 }
292 bytes.extend_from_slice(&entry.snapshot_taken_at_timestamp.to_be_bytes());
293 bytes.extend_from_slice(&entry.snapshot_total_size_bytes.to_be_bytes());
294 bytes.push(match entry.state {
295 ArtifactStateRecord::Created => 0,
296 ArtifactStateRecord::Downloaded => 1,
297 ArtifactStateRecord::ChecksumVerified => 2,
298 ArtifactStateRecord::Durable => 3,
299 });
300 bytes.push(u8::from(entry.checksum.is_some()));
301 if let Some(checksum) = &entry.checksum {
302 bytes.extend_from_slice(checksum.hash().as_bytes());
303 }
304 }
305 ArtifactChecksumRecord::from_bytes(&bytes)
306 }
307
308 pub(crate) fn artifact(
309 &self,
310 canister: &str,
311 snapshot: &str,
312 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
313 let canister = normalize_principal(canister)?;
314 let entry = self
315 .artifacts
316 .iter()
317 .find(|entry| entry.canister_id == canister)
318 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
319 if entry.snapshot_id != snapshot {
320 return Err(DownloadJournalRecordError::SnapshotMismatch);
321 }
322 Ok(entry)
323 }
324
325 pub(crate) fn advance(
326 &mut self,
327 canister: &str,
328 snapshot: &str,
329 next: ArtifactStateRecord,
330 checksum: Option<ArtifactChecksumRecord>,
331 ) -> Result<(), DownloadJournalRecordError> {
332 let entry = self.artifact(canister, snapshot)?;
333 let from = entry.state;
334 if !from.can_advance_to(next) {
335 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
336 }
337 let checksum = match next {
338 ArtifactStateRecord::Downloaded if checksum.is_none() => None,
339 ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
340 ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
341 _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
342 };
343 let canister = entry.canister_id.clone();
344 let entry = self
345 .artifacts
346 .iter_mut()
347 .find(|entry| entry.canister_id == canister)
348 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
349 entry.state = next;
350 entry.checksum = checksum;
351 Ok(())
352 }
353}
354
355fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
356 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
357}
358
359fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
360 if entries.is_empty() {
361 return Err(DownloadJournalRecordError::EmptyArtifacts);
362 }
363 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
364 return Err(DownloadJournalRecordError::TooManyArtifacts);
365 }
366 let mut identities = BTreeSet::new();
367 for entry in entries {
368 if !identities.insert(entry.canister_id()) {
369 return Err(DownloadJournalRecordError::DuplicateCanister);
370 }
371 }
372 Ok(())
373}
374
375fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
376 deserializer: D,
377) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
378 bounded_sequence::deserialize::<_, _, MAX_DOWNLOAD_ARTIFACTS>(
379 deserializer,
380 "a bounded artifact list",
381 DownloadJournalRecordError::TooManyArtifacts,
382 )
383}
384
385#[derive(Debug, Error)]
387pub enum DownloadJournalRecordError {
388 #[error("unsupported download journal version {0}")]
390 UnsupportedVersion(u16),
391 #[error("download journal artifacts must not be empty")]
393 EmptyArtifacts,
394 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
396 TooManyArtifacts,
397 #[error("duplicate download canister identity")]
399 DuplicateCanister,
400 #[error("invalid download canister principal")]
402 InvalidPrincipal,
403 #[error("invalid download snapshot token")]
405 InvalidSnapshotId,
406 #[error("download artifact path does not match source identity")]
408 ArtifactPathMismatch,
409 #[error("unknown download artifact")]
411 UnknownArtifact,
412 #[error("download snapshot identity mismatch")]
414 SnapshotMismatch,
415 #[error("invalid download state transition from {from:?} to {to:?}")]
417 InvalidStateTransition {
418 from: ArtifactStateRecord,
420 to: ArtifactStateRecord,
422 },
423 #[error("invalid checksum evidence for download state {0:?}")]
425 InvalidChecksumState(ArtifactStateRecord),
426 #[error(transparent)]
428 Checksum(#[from] ChecksumError),
429}
430
431#[cfg(test)]
432mod tests;