1mod upload;
4mod verification;
5pub use upload::{IcSnapshotDataUploadPreparationError, IcSnapshotUploadArtifactError};
6
7use super::{DownloadJournalError, DownloadJournalGuard};
8use crate::ops::persistence::directory_custody::{open_directory, same_directory};
9use crate::{
10 hash::hex_bytes,
11 model::{
12 artifacts::{ArtifactChecksumRecord, ChecksumError},
13 download_journal::ArtifactStateRecord,
14 ic_snapshot_coverage::{IcSnapshotDataCoverage, IcSnapshotDataCoverageError},
15 ic_snapshot_data::IcSnapshotDataReply,
16 ic_snapshot_metadata::{IcSnapshotMetadataReply, MAX_IC_SNAPSHOT_METADATA_BYTES},
17 },
18 ops::{
19 artifacts::{ArtifactError, checksum_directory, checksum_relative_files},
20 persistence::write_json_durable,
21 },
22};
23use ic_management_canister_types::SnapshotDataKind;
24use rustix::fs::{self as unix_fs, AtFlags, Dir, FileType, Mode, OFlags};
25use sha2::{Digest, Sha256};
26use std::{
27 collections::BTreeSet,
28 ffi::OsStr,
29 fmt,
30 fs::{self, File},
31 io::{self, Write},
32 os::fd::AsFd,
33 path::{Path, PathBuf},
34};
35use thiserror::Error;
36
37const FORMAT: &[u8] = b"ic-backup/ic-snapshot-artifact/v1\n";
38const REGIONS: [&str; 3] = ["wasm-module.bin", "wasm-memory.bin", "stable-memory.bin"];
39
40pub struct IcSnapshotArtifactWriter<'journal, 'layout, 'metadata> {
54 journal: &'journal mut DownloadJournalGuard<'layout>,
55 coverage: IcSnapshotDataCoverage<'metadata>,
56 snapshot: String,
57 path: PathBuf,
58 parent: File,
59 directory: File,
60 regions: [File; 3],
61 hashes: [Sha256; 3],
62 checksums: Vec<(PathBuf, ArtifactChecksumRecord)>,
63}
64
65impl<'layout> DownloadJournalGuard<'layout> {
66 pub fn stage_ic_snapshot_artifact<'journal, 'metadata>(
78 &'journal mut self,
79 snapshot: &str,
80 metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
81 raw_metadata: &[u8],
82 ) -> Result<IcSnapshotArtifactWriter<'journal, 'layout, 'metadata>, IcSnapshotArtifactError>
83 {
84 self.check_usable()?;
85 let entry = self
86 .record
87 .artifact(metadata.request().target(), snapshot)
88 .map_err(DownloadJournalError::from)?;
89 if entry.state() != ArtifactStateRecord::Created
90 || entry.snapshot_taken_at_timestamp() != metadata.metadata().taken_at_timestamp
91 || raw_metadata.len() > MAX_IC_SNAPSHOT_METADATA_BYTES
92 || ArtifactChecksumRecord::from_bytes(raw_metadata) != *metadata.payload_checksum()
93 {
94 return Err(IcSnapshotArtifactError::OriginalMismatch);
95 }
96 self.check_artifact_parent()?;
97 let path = self.layout.root().join(entry.staging_path());
98 let canonical = self.layout.root().join(entry.artifact_path());
99 match fs::symlink_metadata(canonical) {
100 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
101 Err(error) => return Err(error.into()),
102 Ok(_) => return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()),
103 }
104 let parent = open_directory(&self.layout.root().join("artifacts"))?;
105 let name = path
106 .file_name()
107 .ok_or(IcSnapshotArtifactError::CustodyChanged)?;
108 unix_fs::mkdirat(&parent, name, Mode::from_bits_truncate(0o700))
109 .map_err(io::Error::from)?;
110 let directory = File::from(
111 unix_fs::openat(
112 &parent,
113 name,
114 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
115 Mode::empty(),
116 )
117 .map_err(io::Error::from)?,
118 );
119 let mut checksums = Vec::new();
120 for (name, bytes) in [
121 ("format", FORMAT),
122 ("metadata.candid", raw_metadata),
123 ("metadata-arguments.candid", metadata.request().arguments()),
124 ] {
125 create_file(&directory, name)?.write_all(bytes)?;
126 checksums.push((name.into(), ArtifactChecksumRecord::from_bytes(bytes)));
127 }
128 let regions = [
129 create_file(&directory, REGIONS[0])?,
130 create_file(&directory, REGIONS[1])?,
131 create_file(&directory, REGIONS[2])?,
132 ];
133 let writer = IcSnapshotArtifactWriter {
134 journal: self,
135 coverage: IcSnapshotDataCoverage::new(metadata),
136 snapshot: snapshot.to_owned(),
137 path,
138 parent,
139 directory,
140 regions,
141 hashes: std::array::from_fn(|_| Sha256::new()),
142 checksums,
143 };
144 writer.check_custody()?;
145 Ok(writer)
146 }
147}
148
149impl<'layout> IcSnapshotArtifactWriter<'_, 'layout, '_> {
150 pub(crate) fn validate_transfer_origin(
153 &self,
154 root: &Path,
155 intent: &str,
156 ) -> Result<(), IcSnapshotArtifactError> {
157 self.check_custody()?;
158 self.check_region_custody()?;
159 if self.journal.layout.root() != root || self.journal.record()?.intent() != intent {
160 return Err(IcSnapshotArtifactError::OriginalMismatch);
161 }
162 Ok(())
163 }
164
165 #[must_use]
167 pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
168 &self.coverage
169 }
170
171 #[must_use]
173 pub fn path(&self) -> &Path {
174 &self.path
175 }
176
177 pub fn append(
189 mut self,
190 reply: &IcSnapshotDataReply<'_, '_>,
191 ) -> Result<Self, IcSnapshotArtifactError> {
192 self.check_custody()?;
193 self.check_region_custody()?;
194 self.coverage.admit(reply)?;
195 let index = match reply.request().kind() {
196 SnapshotDataKind::WasmModule { .. } => 0,
197 SnapshotDataKind::WasmMemory { .. } => 1,
198 SnapshotDataKind::StableMemory { .. } => 2,
199 SnapshotDataKind::WasmChunk { hash } => {
200 let name = format!("chunk-{}.bin", hex_bytes(hash));
201 create_file(&self.directory, &name)?.write_all(reply.chunk())?;
202 self.checksums
203 .push((name.into(), reply.chunk_checksum().clone()));
204 self.check_custody()?;
205 return Ok(self);
206 }
207 };
208 self.regions[index].write_all(reply.chunk())?;
209 self.hashes[index].update(reply.chunk());
210 self.check_custody()?;
211 Ok(self)
212 }
213
214 pub fn finish(self) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
232 self.finish_with(DownloadJournalGuard::finalize_artifact)
233 }
234
235 fn finish_with(
236 mut self,
237 publish: impl FnOnce(
238 &mut DownloadJournalGuard<'layout>,
239 &str,
240 &str,
241 ) -> Result<(), DownloadJournalError>,
242 ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
243 if self.coverage.complete().is_none() {
244 return Err(IcSnapshotArtifactError::IncompleteCoverage);
245 }
246 self.check_custody()?;
247 for (name, hash) in REGIONS.into_iter().zip(self.hashes.iter()) {
248 self.checksums.push((
249 name.into(),
250 ArtifactChecksumRecord::from_digest(hash.clone().finalize().into()),
251 ));
252 }
253 self.check_closed_tree()?;
254 let expected = checksum_relative_files(std::mem::take(&mut self.checksums))
255 .map_err(ArtifactError::from)?;
256 checksum_directory(&self.path)?.verify(expected.hash())?;
257 self.check_custody()?;
258 let target = self.coverage.metadata().request().target();
259 let mut next = self.journal.next(
260 target,
261 &self.snapshot,
262 ArtifactStateRecord::Downloaded,
263 None,
264 )?;
265 next.advance(
266 target,
267 &self.snapshot,
268 ArtifactStateRecord::ChecksumVerified,
269 Some(expected.clone()),
270 )
271 .map_err(DownloadJournalError::from)?;
272 self.journal.store(next, write_json_durable)?;
273 publish(self.journal, target, &self.snapshot)?;
274 self.journal.check_usable()?;
275 check_directory_identity(
276 self.path
277 .parent()
278 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
279 &self.parent,
280 )?;
281 let entry = self
282 .journal
283 .record
284 .artifact(target, &self.snapshot)
285 .map_err(DownloadJournalError::from)?;
286 check_directory_identity(
287 &self.journal.layout.root().join(entry.artifact_path()),
288 &self.directory,
289 )?;
290 Ok(expected)
291 }
292
293 fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
294 self.journal.check_usable()?;
295 for (path, held) in [
296 (
297 self.path
298 .parent()
299 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
300 &self.parent,
301 ),
302 (self.path.as_path(), &self.directory),
303 ] {
304 check_directory_identity(path, held)?;
305 }
306 Ok(())
307 }
308
309 fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
310 check_closed_tree(&self.directory, &self.checksums)
311 }
312
313 fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
314 for ((name, file), expected) in REGIONS
315 .iter()
316 .zip(&self.regions)
317 .zip(self.coverage.covered_region_bytes())
318 {
319 let held = unix_fs::fstat(file).map_err(io::Error::from)?;
320 let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
321 .map_err(io::Error::from)?;
322 let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
323 let exact_extent = current.st_size == held.st_size
324 && u64::try_from(held.st_size).ok() == Some(expected);
325 if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
326 return Err(IcSnapshotArtifactError::FileShape);
327 }
328 }
329 Ok(())
330 }
331}
332
333fn check_closed_tree(
334 directory: &File,
335 checksums: &[(PathBuf, ArtifactChecksumRecord)],
336) -> Result<(), IcSnapshotArtifactError> {
337 let mut expected = checksums
338 .iter()
339 .map(|(path, _)| path.as_os_str().as_encoded_bytes().to_vec())
340 .collect::<BTreeSet<_>>();
341 let mut directory = Dir::read_from(directory).map_err(io::Error::from)?;
342 while let Some(entry) = directory.read() {
343 let entry = entry.map_err(io::Error::from)?;
344 let name = entry.file_name().to_bytes();
345 if matches!(name, b"." | b"..") {
346 continue;
347 }
348 if !expected.remove(name) {
349 return Err(IcSnapshotArtifactError::UnexpectedEntry);
350 }
351 }
352 if !expected.is_empty() {
353 return Err(IcSnapshotArtifactError::UnexpectedEntry);
354 }
355 Ok(())
356}
357
358impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 f.debug_struct("IcSnapshotArtifactWriter")
361 .field("coverage", &self.coverage)
362 .finish_non_exhaustive()
363 }
364}
365
366fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
367 if !same_directory(path, held)? {
368 return Err(IcSnapshotArtifactError::CustodyChanged);
369 }
370 Ok(())
371}
372
373fn create_file(directory: &File, name: &str) -> io::Result<File> {
374 ic_host_fs::durable::create_retained_at(directory.as_fd(), OsStr::new(name), 0o600)
375}
376
377#[derive(Debug, Error)]
379pub enum IcSnapshotArtifactError {
380 #[error("IC snapshot artifact original evidence mismatch")]
382 OriginalMismatch,
383 #[error("IC snapshot artifact coverage is incomplete")]
385 IncompleteCoverage,
386 #[error("IC snapshot artifact directory custody changed")]
388 CustodyChanged,
389 #[error("IC snapshot artifact tree entries differ")]
391 UnexpectedEntry,
392 #[error("IC snapshot artifact file type or length differs")]
394 FileShape,
395 #[error(transparent)]
397 Integrity(#[from] super::DownloadIntegrityError),
398 #[error(transparent)]
400 Coverage(#[from] IcSnapshotDataCoverageError),
401 #[error(transparent)]
403 Checksum(#[from] ChecksumError),
404 #[error(transparent)]
406 Journal(#[from] DownloadJournalError),
407 #[error(transparent)]
409 Artifact(#[from] ArtifactError),
410 #[error(transparent)]
412 Io(#[from] io::Error),
413}
414
415#[cfg(test)]
416mod tests;