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 pub(crate) fn artifact(
265 &self,
266 canister: &str,
267 snapshot: &str,
268 ) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
269 let canister = normalize_principal(canister)?;
270 let entry = self
271 .artifacts
272 .iter()
273 .find(|entry| entry.canister_id == canister)
274 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
275 if entry.snapshot_id != snapshot {
276 return Err(DownloadJournalRecordError::SnapshotMismatch);
277 }
278 Ok(entry)
279 }
280
281 pub(crate) fn advance(
282 &mut self,
283 canister: &str,
284 snapshot: &str,
285 next: ArtifactStateRecord,
286 checksum: Option<ArtifactChecksumRecord>,
287 ) -> Result<(), DownloadJournalRecordError> {
288 let entry = self.artifact(canister, snapshot)?;
289 let from = entry.state;
290 if !from.can_advance_to(next) {
291 return Err(DownloadJournalRecordError::InvalidStateTransition { from, to: next });
292 }
293 let checksum = match next {
294 ArtifactStateRecord::Downloaded if checksum.is_none() => None,
295 ArtifactStateRecord::ChecksumVerified if checksum.is_some() => checksum,
296 ArtifactStateRecord::Durable if checksum.is_none() => entry.checksum.clone(),
297 _ => return Err(DownloadJournalRecordError::InvalidChecksumState(next)),
298 };
299 let canister = entry.canister_id.clone();
300 let entry = self
301 .artifacts
302 .iter_mut()
303 .find(|entry| entry.canister_id == canister)
304 .ok_or(DownloadJournalRecordError::UnknownArtifact)?;
305 entry.state = next;
306 entry.checksum = checksum;
307 Ok(())
308 }
309}
310
311fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
312 super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
313}
314
315fn validate_entries(entries: &[DownloadArtifactRecord]) -> Result<(), DownloadJournalRecordError> {
316 if entries.is_empty() {
317 return Err(DownloadJournalRecordError::EmptyArtifacts);
318 }
319 if entries.len() > MAX_DOWNLOAD_ARTIFACTS {
320 return Err(DownloadJournalRecordError::TooManyArtifacts);
321 }
322 let mut identities = BTreeSet::new();
323 for entry in entries {
324 if !identities.insert(entry.canister_id()) {
325 return Err(DownloadJournalRecordError::DuplicateCanister);
326 }
327 }
328 Ok(())
329}
330
331fn read_bounded_artifacts<'de, D: Deserializer<'de>>(
332 deserializer: D,
333) -> Result<Vec<DownloadArtifactRecord>, D::Error> {
334 struct ArtifactsVisitor;
335 impl<'de> de::Visitor<'de> for ArtifactsVisitor {
336 type Value = Vec<DownloadArtifactRecord>;
337 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338 formatter.write_str("a bounded artifact list")
339 }
340 fn visit_seq<A: de::SeqAccess<'de>>(
341 self,
342 mut sequence: A,
343 ) -> Result<Self::Value, A::Error> {
344 let mut entries = Vec::new();
345 while entries.len() < MAX_DOWNLOAD_ARTIFACTS {
346 match sequence.next_element()? {
347 Some(entry) => entries.push(entry),
348 None => return Ok(entries),
349 }
350 }
351 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
352 return Err(de::Error::custom(
353 DownloadJournalRecordError::TooManyArtifacts,
354 ));
355 }
356 Ok(entries)
357 }
358 }
359 deserializer.deserialize_seq(ArtifactsVisitor)
360}
361
362#[derive(Debug, Error)]
364pub enum DownloadJournalRecordError {
365 #[error("unsupported download journal version {0}")]
367 UnsupportedVersion(u16),
368 #[error("download journal artifacts must not be empty")]
370 EmptyArtifacts,
371 #[error("download journal artifact count exceeds {MAX_DOWNLOAD_ARTIFACTS}")]
373 TooManyArtifacts,
374 #[error("duplicate download canister identity")]
376 DuplicateCanister,
377 #[error("invalid download canister principal")]
379 InvalidPrincipal,
380 #[error("invalid download snapshot token")]
382 InvalidSnapshotId,
383 #[error("download artifact path does not match source identity")]
385 ArtifactPathMismatch,
386 #[error("unknown download artifact")]
388 UnknownArtifact,
389 #[error("download snapshot identity mismatch")]
391 SnapshotMismatch,
392 #[error("invalid download state transition from {from:?} to {to:?}")]
394 InvalidStateTransition {
395 from: ArtifactStateRecord,
397 to: ArtifactStateRecord,
399 },
400 #[error("invalid checksum evidence for download state {0:?}")]
402 InvalidChecksumState(ArtifactStateRecord),
403 #[error(transparent)]
405 Checksum(#[from] ChecksumError),
406}
407
408#[cfg(test)]
409mod tests;