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