1mod member;
4mod upload;
5mod verification;
6pub use upload::{IcSnapshotDataUploadPreparationError, IcSnapshotUploadArtifactError};
7
8use super::{DownloadJournalError, DownloadJournalGuard};
9use crate::{
10 model::{
11 artifacts::{ArtifactChecksumRecord, ChecksumError},
12 download_journal::{ArtifactStateRecord, ArtifactTransition},
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::json::write_encoded,
20 },
21};
22use ic_host_artifacts::artifact::HashingWriter;
23use ic_host_fs::read::open_directory_no_follow;
24use rustix::fs::{self as unix_fs, AtFlags, Dir, FileType, Mode, OFlags};
25use std::{
26 collections::BTreeSet,
27 ffi::OsStr,
28 fmt,
29 fs::{self, File},
30 io::{self, Write},
31 os::fd::AsFd,
32 path::{Path, PathBuf},
33};
34use thiserror::Error;
35
36const FORMAT: &[u8] = b"ic-backup/ic-snapshot-artifact/v1\n";
37const REGIONS: [&str; 3] = ["wasm-module.bin", "wasm-memory.bin", "stable-memory.bin"];
38
39pub struct IcSnapshotArtifactWriter<'journal, 'layout, 'metadata> {
53 journal: &'journal mut DownloadJournalGuard<'layout>,
54 coverage: IcSnapshotDataCoverage<'metadata>,
55 snapshot: String,
56 path: PathBuf,
57 parent: File,
58 directory: File,
59 regions: [HashingWriter<File>; 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_no_follow(&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))
107 .map_err(io::Error::from)?;
108 let directory = File::from(
109 unix_fs::openat(
110 &parent,
111 name,
112 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
113 Mode::empty(),
114 )
115 .map_err(io::Error::from)?,
116 );
117 let mut checksums = Vec::new();
118 for (name, bytes) in [
119 ("format", FORMAT),
120 ("metadata.candid", raw_metadata),
121 ("metadata-arguments.candid", metadata.request().arguments()),
122 ] {
123 create_file(&directory, name)?.write_all(bytes)?;
124 checksums.push((name.into(), ArtifactChecksumRecord::from_bytes(bytes)));
125 }
126 let regions = [
127 create_file(&directory, REGIONS[0])?,
128 create_file(&directory, REGIONS[1])?,
129 create_file(&directory, REGIONS[2])?,
130 ]
131 .map(|file| HashingWriter::new(file, u64::MAX));
132 let writer = IcSnapshotArtifactWriter {
133 journal: self,
134 coverage: IcSnapshotDataCoverage::new(metadata),
135 snapshot: snapshot.to_owned(),
136 path,
137 parent,
138 directory,
139 regions,
140 checksums,
141 };
142 writer.check_custody()?;
143 Ok(writer)
144 }
145}
146
147impl<'layout> IcSnapshotArtifactWriter<'_, 'layout, '_> {
148 pub(crate) fn validate_transfer_origin(
151 &self,
152 root: &Path,
153 intent: &str,
154 ) -> Result<(), IcSnapshotArtifactError> {
155 self.check_custody()?;
156 self.check_region_custody()?;
157 if self.journal.layout.root() != root || self.journal.record()?.intent() != intent {
158 return Err(IcSnapshotArtifactError::OriginalMismatch);
159 }
160 Ok(())
161 }
162
163 #[must_use]
165 pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
166 &self.coverage
167 }
168
169 #[must_use]
171 pub fn path(&self) -> &Path {
172 &self.path
173 }
174
175 pub fn append(
187 mut self,
188 reply: &IcSnapshotDataReply<'_, '_>,
189 ) -> Result<Self, IcSnapshotArtifactError> {
190 self.check_custody()?;
191 self.check_region_custody()?;
192 self.coverage.admit(reply)?;
193 let index = match member::select(reply.request().kind())? {
194 member::Member::Region { index, .. } => index,
195 member::Member::Chunk { name } => {
196 create_file(&self.directory, &name)?.write_all(reply.chunk())?;
197 self.checksums
198 .push((name.into(), reply.chunk_checksum().clone()));
199 self.check_custody()?;
200 return Ok(self);
201 }
202 };
203 self.regions[index].write_all(reply.chunk())?;
204 self.check_custody()?;
205 Ok(self)
206 }
207
208 pub fn finish(self) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
226 self.finish_with(DownloadJournalGuard::finalize_artifact)
227 }
228
229 fn finish_with(
230 mut self,
231 publish: impl FnOnce(
232 &mut DownloadJournalGuard<'layout>,
233 &str,
234 &str,
235 ) -> Result<(), DownloadJournalError>,
236 ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
237 if self.coverage.complete().is_none() {
238 return Err(IcSnapshotArtifactError::IncompleteCoverage);
239 }
240 self.check_custody()?;
241 for (name, region) in REGIONS.into_iter().zip(&self.regions) {
242 self.checksums.push((
243 name.into(),
244 ArtifactChecksumRecord::from_digest(*region.identity().sha256.as_bytes()),
245 ));
246 }
247 self.check_closed_tree()?;
248 let expected = checksum_relative_files(std::mem::take(&mut self.checksums))
249 .map_err(ArtifactError::from)?;
250 checksum_directory(&self.path)?.verify(expected.hash())?;
251 self.check_custody()?;
252 let target = self.coverage.metadata().request().target();
253 let mut next = self
254 .journal
255 .next(target, &self.snapshot, ArtifactTransition::Downloaded)?;
256 next.advance(
257 target,
258 &self.snapshot,
259 ArtifactTransition::ChecksumVerified(expected.clone()),
260 )
261 .map_err(DownloadJournalError::from)?;
262 self.journal.store(next, write_encoded)?;
263 publish(self.journal, target, &self.snapshot)?;
264 self.journal.check_usable()?;
265 check_directory_identity(
266 self.path
267 .parent()
268 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
269 &self.parent,
270 )?;
271 let entry = self
272 .journal
273 .record
274 .artifact(target, &self.snapshot)
275 .map_err(DownloadJournalError::from)?;
276 check_directory_identity(
277 &self.journal.layout.root().join(entry.artifact_path()),
278 &self.directory,
279 )?;
280 Ok(expected)
281 }
282
283 fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
284 self.journal.check_usable()?;
285 for (path, held) in [
286 (
287 self.path
288 .parent()
289 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
290 &self.parent,
291 ),
292 (self.path.as_path(), &self.directory),
293 ] {
294 check_directory_identity(path, held)?;
295 }
296 Ok(())
297 }
298
299 fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
300 check_closed_tree(
301 &self.directory,
302 self.checksums.iter().map(|(path, _)| path.as_os_str()),
303 )
304 }
305
306 fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
307 for ((name, file), expected) in REGIONS
308 .iter()
309 .zip(&self.regions)
310 .zip(self.coverage.covered_region_bytes())
311 {
312 let held = unix_fs::fstat(file.get_ref()).map_err(io::Error::from)?;
313 let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
314 .map_err(io::Error::from)?;
315 let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
316 let exact_extent = current.st_size == held.st_size
317 && u64::try_from(held.st_size).ok() == Some(expected);
318 if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
319 return Err(IcSnapshotArtifactError::FileShape);
320 }
321 }
322 Ok(())
323 }
324}
325
326fn check_closed_tree<'a>(
327 directory: &File,
328 names: impl IntoIterator<Item = &'a std::ffi::OsStr>,
329) -> Result<(), IcSnapshotArtifactError> {
330 let mut expected = names
331 .into_iter()
332 .map(|name| name.as_encoded_bytes().to_vec())
333 .collect::<BTreeSet<_>>();
334 let mut directory = Dir::read_from(directory).map_err(io::Error::from)?;
335 while let Some(entry) = directory.read() {
336 let entry = entry.map_err(io::Error::from)?;
337 let name = entry.file_name().to_bytes();
338 if matches!(name, b"." | b"..") {
339 continue;
340 }
341 if !expected.remove(name) {
342 return Err(IcSnapshotArtifactError::UnexpectedEntry);
343 }
344 }
345 if !expected.is_empty() {
346 return Err(IcSnapshotArtifactError::UnexpectedEntry);
347 }
348 Ok(())
349}
350
351impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 f.debug_struct("IcSnapshotArtifactWriter")
354 .field("coverage", &self.coverage)
355 .finish_non_exhaustive()
356 }
357}
358
359fn with_artifact_directory<T, E: From<IcSnapshotArtifactError>>(
362 root: &Path,
363 artifact: &str,
364 read: impl FnOnce(&File) -> Result<T, E>,
365 finish: impl FnOnce() -> Result<(), E>,
366) -> Result<T, E> {
367 let parent_path = root.join("artifacts");
368 let parent = open_directory_no_follow(&parent_path).map_err(IcSnapshotArtifactError::from)?;
369 let path = root.join(artifact);
370 let directory = File::from(
371 unix_fs::openat(
372 &parent,
373 path.file_name()
374 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
375 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
376 Mode::empty(),
377 )
378 .map_err(io::Error::from)
379 .map_err(IcSnapshotArtifactError::from)?,
380 );
381 let result = read(&directory)?;
382 check_directory_identity(&parent_path, &parent)?;
383 check_directory_identity(&path, &directory)?;
384 finish()?;
385 Ok(result)
386}
387
388fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
389 match ic_host_fs::durable::admit_directory(path, held) {
390 Ok(()) => Ok(()),
391 Err(error) if error.kind() == io::ErrorKind::InvalidInput => {
392 Err(IcSnapshotArtifactError::CustodyChanged)
393 }
394 Err(error) => Err(error.into()),
395 }
396}
397
398fn create_file(directory: &File, name: &str) -> io::Result<File> {
399 ic_host_fs::durable::create_retained_at(directory.as_fd(), OsStr::new(name), 0o600)
400}
401
402#[derive(Debug, Error)]
404pub enum IcSnapshotArtifactError {
405 #[error("IC snapshot artifact original evidence mismatch")]
407 OriginalMismatch,
408 #[error("IC snapshot artifact coverage is incomplete")]
410 IncompleteCoverage,
411 #[error("IC snapshot artifact directory custody changed")]
413 CustodyChanged,
414 #[error("IC snapshot artifact tree entries differ")]
416 UnexpectedEntry,
417 #[error("IC snapshot artifact file type or length differs")]
419 FileShape,
420 #[error(transparent)]
422 Integrity(#[from] super::DownloadIntegrityError),
423 #[error(transparent)]
425 Coverage(#[from] IcSnapshotDataCoverageError),
426 #[error(transparent)]
428 Checksum(#[from] ChecksumError),
429 #[error(transparent)]
431 Journal(#[from] DownloadJournalError),
432 #[error(transparent)]
434 Artifact(#[from] ArtifactError),
435 #[error(transparent)]
437 Io(#[from] io::Error),
438}
439
440#[cfg(test)]
441mod tests;