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