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 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
60pub(crate) enum ArtifactTransition {
63 Downloaded,
64 ChecksumVerified(ArtifactChecksumRecord),
65 Durable,
66}
67
68impl ArtifactTransition {
69 const fn state(&self) -> ArtifactStateRecord {
70 match self {
71 Self::Downloaded => ArtifactStateRecord::Downloaded,
72 Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
73 Self::Durable => ArtifactStateRecord::Durable,
74 }
75 }
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(try_from = "ArtifactFields")]
81pub struct DownloadArtifactRecord {
82 canister_id: String,
83 snapshot_id: String,
84 snapshot_taken_at_timestamp: u64,
85 snapshot_total_size_bytes: u64,
86 staging_path: String,
87 artifact_path: String,
88 state: ArtifactStateRecord,
89 checksum: Option<ArtifactChecksumRecord>,
90}
91
92#[derive(Deserialize)]
93#[serde(deny_unknown_fields)]
94struct ArtifactFields {
95 canister_id: String,
96 snapshot_id: String,
97 snapshot_taken_at_timestamp: u64,
98 snapshot_total_size_bytes: u64,
99 staging_path: String,
100 artifact_path: String,
101 state: ArtifactStateRecord,
102 #[serde(deserialize_with = "crate::model::required_option::deserialize")]
103 checksum: Option<ArtifactChecksumRecord>,
104}
105
106impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
107 type Error = DownloadJournalRecordError;
108 fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
109 let mut record = Self::new(DownloadArtifactRequest {
110 canister_id: fields.canister_id,
111 snapshot_id: fields.snapshot_id,
112 snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
113 snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
114 })?;
115 if fields.staging_path != record.staging_path
117 || fields.artifact_path != record.artifact_path
118 {
119 return Err(DownloadJournalRecordError::ArtifactPathMismatch);
120 }
121 let requires_checksum = matches!(
122 fields.state,
123 ArtifactStateRecord::ChecksumVerified | ArtifactStateRecord::Durable
124 );
125 if requires_checksum != fields.checksum.is_some() {
126 return Err(DownloadJournalRecordError::InvalidChecksumState(
127 fields.state,
128 ));
129 }
130 record.state = fields.state;
131 record.checksum = fields.checksum;
132 Ok(record)
133 }
134}
135
136impl DownloadArtifactRecord {
137 fn new(request: DownloadArtifactRequest) -> Result<Self, DownloadJournalRecordError> {
138 let canister_id = normalize_principal(&request.canister_id)?;
139 if request.snapshot_id.is_empty()
140 || request.snapshot_id.len() > MAX_SNAPSHOT_ID_BYTES
141 || !request
142 .snapshot_id
143 .bytes()
144 .all(|byte| byte.is_ascii_graphic())
145 {
146 return Err(DownloadJournalRecordError::InvalidSnapshotId);
147 }
148 Ok(Self {
149 staging_path: format!("artifacts/{canister_id}.tmp"),
150 artifact_path: format!("artifacts/{canister_id}"),
151 canister_id,
152 snapshot_id: request.snapshot_id,
153 snapshot_taken_at_timestamp: request.snapshot_taken_at_timestamp,
154 snapshot_total_size_bytes: request.snapshot_total_size_bytes,
155 state: ArtifactStateRecord::Created,
156 checksum: None,
157 })
158 }
159
160 #[must_use]
162 pub fn canister_id(&self) -> &str {
163 &self.canister_id
164 }
165 #[must_use]
167 pub fn snapshot_id(&self) -> &str {
168 &self.snapshot_id
169 }
170 #[must_use]
172 pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
173 self.snapshot_taken_at_timestamp
174 }
175 #[must_use]
177 pub const fn snapshot_total_size_bytes(&self) -> u64 {
178 self.snapshot_total_size_bytes
179 }
180 #[must_use]
182 pub fn staging_path(&self) -> &str {
183 &self.staging_path
184 }
185 #[must_use]
187 pub fn artifact_path(&self) -> &str {
188 &self.artifact_path
189 }
190 #[must_use]
192 pub const fn state(&self) -> ArtifactStateRecord {
193 self.state
194 }
195 #[must_use]
197 pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
198 self.checksum.as_ref()
199 }
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(try_from = "JournalFields")]
207pub struct DownloadJournalRecord {
208 version: u16,
209 intent: String,
210 artifacts: Vec<DownloadArtifactRecord>,
211}
212
213#[derive(Deserialize)]
214#[serde(deny_unknown_fields)]
215struct JournalFields {
216 version: u16,
217 intent: String,
218 #[serde(deserialize_with = "read_bounded_artifacts")]
219 artifacts: Vec<DownloadArtifactRecord>,
220}
221
222impl TryFrom<JournalFields> for DownloadJournalRecord {
223 type Error = DownloadJournalRecordError;
224 fn try_from(mut fields: JournalFields) -> Result<Self, Self::Error> {
225 if fields.version != 1 {
226 return Err(DownloadJournalRecordError::UnsupportedVersion(
227 fields.version,
228 ));
229 }
230 validate_entries(&mut fields.artifacts)?;
231 Ok(Self {
232 version: 1,
233 intent: canonical_hash(&fields.intent)?,
234 artifacts: fields.artifacts,
235 })
236 }
237}
238
239impl DownloadJournalRecord {
240 pub fn new(
245 intent: &str,
246 requests: Vec<DownloadArtifactRequest>,
247 ) -> Result<Self, DownloadJournalRecordError> {
248 if requests.len() > MAX_DOWNLOAD_ARTIFACTS {
249 return Err(DownloadJournalRecordError::TooManyArtifacts);
250 }
251 let artifacts = requests
252 .into_iter()
253 .map(DownloadArtifactRecord::new)
254 .collect::<Result<Vec<_>, _>>()?;
255 JournalFields {
256 version: 1,
257 intent: intent.to_owned(),
258 artifacts,
259 }
260 .try_into()
261 }
262
263 #[must_use]
265 pub fn intent(&self) -> &str {
266 &self.intent
267 }
268 #[must_use]
270 pub fn artifacts(&self) -> &[DownloadArtifactRecord] {
271 &self.artifacts
272 }
273
274 #[must_use]
283 #[expect(
284 clippy::cast_possible_truncation,
285 reason = "record admission bounds principals, snapshot tokens and identity-derived paths below u32"
286 )]
287 pub fn digest(&self) -> ArtifactChecksumRecord {
288 let mut bytes = EvidenceDigest::new(b"ic-backup/download-journal/v1\0");
289 bytes.append(self.intent.as_bytes());
290 bytes.u64_be(self.artifacts.len() as u64);
291 for entry in &self.artifacts {
292 for value in [
293 entry.canister_id(),
294 entry.snapshot_id(),
295 entry.staging_path(),
296 entry.artifact_path(),
297 ] {
298 bytes.u32_be(value.len() as u32);
299 bytes.append(value.as_bytes());
300 }
301 bytes.u64_be(entry.snapshot_taken_at_timestamp);
302 bytes.u64_be(entry.snapshot_total_size_bytes);
303 bytes.byte(match entry.state {
304 ArtifactStateRecord::Created => 0,
305 ArtifactStateRecord::Downloaded => 1,
306 ArtifactStateRecord::ChecksumVerified => 2,
307 ArtifactStateRecord::Durable => 3,
308 });
309 bytes.byte(u8::from(entry.checksum.is_some()));
310 if let Some(checksum) = &entry.checksum {
311 bytes.checksum(checksum);
312 }
313 }
314 bytes.finish()
315 }
316
317 pub(crate) fn artifact(
318 &self,
319 canister: &str,
320 snapshot: &str,
321 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
322 Ok(&self.artifacts[self.artifact_index(canister, snapshot)?])
323 }
324
325 fn artifact_index(
326 &self,
327 canister: &str,
328 snapshot: &str,
329 ) -> Result<usize, DownloadJournalRecordError> {
330 let canister = normalize_principal(canister)?;
331 let index = self
332 .artifacts
333 .binary_search_by(|entry| entry.canister_id.cmp(&canister))
334 .map_err(|_| DownloadJournalRecordError::UnknownArtifact)?;
335 if self.artifacts[index].snapshot_id != snapshot {
336 return Err(DownloadJournalRecordError::SnapshotMismatch);
337 }
338 Ok(index)
339 }
340
341 pub(crate) fn advance(
342 &mut self,
343 canister: &str,
344 snapshot: &str,
345 transition: ArtifactTransition,
346 ) -> Result<(), DownloadJournalRecordError> {
347 let index = self.artifact_index(canister, snapshot)?;
348 let entry = &mut self.artifacts[index];
349 let from = entry.state;
350 let next = transition.state();
351 if !from.can_advance_to(next) {
352 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
353 }
354 let checksum = match transition {
355 ArtifactTransition::Downloaded => None,
356 ArtifactTransition::ChecksumVerified(checksum) => Some(checksum),
357 ArtifactTransition::Durable => entry.checksum.clone(),
358 };
359 entry.state = next;
360 entry.checksum = checksum;
361 Ok(())
362 }
363}
364
365fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
366 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
367}
368
369fn validate_entries(
370 entries: &mut [DownloadArtifactRecord],
371) -> Result<(), DownloadJournalRecordError> {
372 if entries.is_empty() {
373 return Err(DownloadJournalRecordError::EmptyArtifacts);
374 }
375 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
376 return Err(DownloadJournalRecordError::TooManyArtifacts);
377 }
378 entries.sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
379 if entries
380 .windows(2)
381 .any(|pair| pair[0].canister_id == pair[1].canister_id)
382 {
383 return Err(DownloadJournalRecordError::DuplicateCanister);
384 }
385 Ok(())
386}
387
388fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
389 deserializer: D,
390) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
391 bounded_sequence::deserialize::<_, _, MAX_DOWNLOAD_ARTIFACTS>(
392 deserializer,
393 "a bounded artifact list",
394 DownloadJournalRecordError::TooManyArtifacts,
395 )
396}
397
398#[derive(Debug, Error)]
400pub enum DownloadJournalRecordError {
401 #[error("unsupported download journal version {0}")]
403 UnsupportedVersion(u16),
404 #[error("download journal artifacts must not be empty")]
406 EmptyArtifacts,
407 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
409 TooManyArtifacts,
410 #[error("duplicate download canister identity")]
412 DuplicateCanister,
413 #[error("invalid download canister principal")]
415 InvalidPrincipal,
416 #[error("invalid download snapshot token")]
418 InvalidSnapshotId,
419 #[error("download artifact path does not match source identity")]
421 ArtifactPathMismatch,
422 #[error("unknown download artifact")]
424 UnknownArtifact,
425 #[error("download snapshot identity mismatch")]
427 SnapshotMismatch,
428 #[error("invalid download state transition from {from:?} to {to:?}")]
430 InvalidStateTransition {
431 from: ArtifactStateRecord,
433 to: ArtifactStateRecord,
435 },
436 #[error("invalid checksum evidence for download state {0:?}")]
438 InvalidChecksumState(ArtifactStateRecord),
439 #[error(transparent)]
441 Checksum(#[from] ChecksumError),
442}
443
444#[cfg(test)]
445mod tests;