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