1mod member;
4#[cfg(test)]
5mod read_count;
6mod upload;
7mod verification;
8pub use upload::{IcSnapshotDataUploadPreparationError, IcSnapshotUploadArtifactError};
9
10use super::{DownloadJournalError, DownloadJournalGuard};
11use crate::{
12 model::{
13 artifacts::{ArtifactChecksumRecord, ChecksumError},
14 download_journal::{ArtifactStateRecord, ArtifactTransition},
15 ic_snapshot_coverage::{IcSnapshotDataCoverage, IcSnapshotDataCoverageError},
16 ic_snapshot_data::IcSnapshotDataReply,
17 ic_snapshot_metadata::{IcSnapshotMetadataReply, MAX_IC_SNAPSHOT_METADATA_BYTES},
18 },
19 ops::{
20 artifacts::{ArtifactError, checksum_directory, checksum_relative_files},
21 persistence::json::write_encoded,
22 },
23};
24use ic_host_artifacts::artifact::HashingWriter;
25use ic_host_fs::read::open_directory_no_follow;
26use rustix::fs::{self as unix_fs, AtFlags, Dir, FileType, Mode, OFlags};
27use std::{
28 collections::BTreeSet,
29 ffi::OsStr,
30 fmt,
31 fs::{self, File},
32 io::{self, Write},
33 os::fd::AsFd,
34 path::{Path, PathBuf},
35};
36use thiserror::Error;
37
38const FORMAT: &[u8] = b"ic-backup/ic-snapshot-artifact/v1\n";
39const REGIONS: [&str; 3] = ["wasm-module.bin", "wasm-memory.bin", "stable-memory.bin"];
40
41pub struct IcSnapshotArtifactWriter<'journal, 'layout, 'metadata> {
55 journal: &'journal mut DownloadJournalGuard<'layout>,
56 coverage: IcSnapshotDataCoverage<'metadata>,
57 snapshot: String,
58 path: PathBuf,
59 parent: File,
60 directory: File,
61 regions: [HashingWriter<File>; 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_no_follow(&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 .map(|file| HashingWriter::new(file, u64::MAX));
134 let writer = IcSnapshotArtifactWriter {
135 journal: self,
136 coverage: IcSnapshotDataCoverage::new(metadata),
137 snapshot: snapshot.to_owned(),
138 path,
139 parent,
140 directory,
141 regions,
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 member::select(reply.request().kind())? {
196 member::Member::Region { region, .. } => region.index(),
197 member::Member::Chunk { name } => {
198 create_file(&self.directory, &name)?.write_all(reply.chunk())?;
199 self.checksums
200 .push((name.into(), reply.chunk_checksum().clone()));
201 self.check_custody()?;
202 return Ok(self);
203 }
204 };
205 self.regions[index].write_all(reply.chunk())?;
206 self.check_custody()?;
207 Ok(self)
208 }
209
210 pub fn finish(self) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
228 self.finish_with(DownloadJournalGuard::finalize_artifact)
229 }
230
231 fn finish_with(
232 mut self,
233 publish: impl FnOnce(
234 &mut DownloadJournalGuard<'layout>,
235 &str,
236 &str,
237 ) -> Result<(), DownloadJournalError>,
238 ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
239 if self.coverage.complete().is_none() {
240 return Err(IcSnapshotArtifactError::IncompleteCoverage);
241 }
242 self.check_custody()?;
243 for (name, region) in REGIONS.into_iter().zip(&self.regions) {
244 self.checksums.push((
245 name.into(),
246 ArtifactChecksumRecord::from_digest(*region.identity().sha256.as_bytes()),
247 ));
248 }
249 self.check_closed_tree()?;
250 let expected = checksum_relative_files(std::mem::take(&mut self.checksums))
251 .map_err(ArtifactError::from)?;
252 checksum_directory(&self.path)?.verify(expected.hash())?;
253 self.check_custody()?;
254 let target = self.coverage.metadata().request().target();
255 let mut next = self
256 .journal
257 .next(target, &self.snapshot, ArtifactTransition::Downloaded)?;
258 next.advance(
259 target,
260 &self.snapshot,
261 ArtifactTransition::ChecksumVerified(expected.clone()),
262 )
263 .map_err(DownloadJournalError::from)?;
264 self.journal.store(next, write_encoded)?;
265 publish(self.journal, target, &self.snapshot)?;
266 self.journal.check_usable()?;
267 check_directory_identity(
268 self.path
269 .parent()
270 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
271 &self.parent,
272 )?;
273 let entry = self
274 .journal
275 .record
276 .artifact(target, &self.snapshot)
277 .map_err(DownloadJournalError::from)?;
278 check_directory_identity(
279 &self.journal.layout.root().join(entry.artifact_path()),
280 &self.directory,
281 )?;
282 Ok(expected)
283 }
284
285 fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
286 self.journal.check_usable()?;
287 for (path, held) in [
288 (
289 self.path
290 .parent()
291 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
292 &self.parent,
293 ),
294 (self.path.as_path(), &self.directory),
295 ] {
296 check_directory_identity(path, held)?;
297 }
298 Ok(())
299 }
300
301 fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
302 check_closed_tree(
303 &self.directory,
304 self.checksums.iter().map(|(path, _)| path.as_os_str()),
305 )
306 }
307
308 fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
309 for ((name, file), expected) in REGIONS
310 .iter()
311 .zip(&self.regions)
312 .zip(self.coverage.covered_region_bytes())
313 {
314 let held = unix_fs::fstat(file.get_ref()).map_err(io::Error::from)?;
315 let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
316 .map_err(io::Error::from)?;
317 let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
318 let exact_extent = current.st_size == held.st_size
319 && u64::try_from(held.st_size).ok() == Some(expected);
320 if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
321 return Err(IcSnapshotArtifactError::FileShape);
322 }
323 }
324 Ok(())
325 }
326}
327
328fn check_closed_tree<'a>(
329 directory: &File,
330 names: impl IntoIterator<Item = &'a std::ffi::OsStr>,
331) -> Result<(), IcSnapshotArtifactError> {
332 let mut expected = names
333 .into_iter()
334 .map(|name| name.as_encoded_bytes().to_vec())
335 .collect::<BTreeSet<_>>();
336 let mut directory = Dir::read_from(directory).map_err(io::Error::from)?;
337 while let Some(entry) = directory.read() {
338 let entry = entry.map_err(io::Error::from)?;
339 let name = entry.file_name().to_bytes();
340 if matches!(name, b"." | b"..") {
341 continue;
342 }
343 if !expected.remove(name) {
344 return Err(IcSnapshotArtifactError::UnexpectedEntry);
345 }
346 }
347 if !expected.is_empty() {
348 return Err(IcSnapshotArtifactError::UnexpectedEntry);
349 }
350 Ok(())
351}
352
353impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
354 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355 f.debug_struct("IcSnapshotArtifactWriter")
356 .field("coverage", &self.coverage)
357 .finish_non_exhaustive()
358 }
359}
360
361fn with_artifact_directory<T, E: From<IcSnapshotArtifactError>>(
364 root: &Path,
365 artifact: &str,
366 read: impl FnOnce(&File) -> Result<T, E>,
367 finish: impl FnOnce() -> Result<(), E>,
368) -> Result<T, E> {
369 let parent_path = root.join("artifacts");
370 let parent = open_directory_no_follow(&parent_path).map_err(IcSnapshotArtifactError::from)?;
371 let path = root.join(artifact);
372 let directory = File::from(
373 unix_fs::openat(
374 &parent,
375 path.file_name()
376 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
377 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
378 Mode::empty(),
379 )
380 .map_err(io::Error::from)
381 .map_err(IcSnapshotArtifactError::from)?,
382 );
383 let result = read(&directory)?;
384 check_directory_identity(&parent_path, &parent)?;
385 check_directory_identity(&path, &directory)?;
386 finish()?;
387 Ok(result)
388}
389
390fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
391 match ic_host_fs::durable::admit_directory(path, held) {
392 Ok(()) => Ok(()),
393 Err(error) if error.kind() == io::ErrorKind::InvalidInput => {
394 Err(IcSnapshotArtifactError::CustodyChanged)
395 }
396 Err(error) => Err(error.into()),
397 }
398}
399
400fn create_file(directory: &File, name: &str) -> io::Result<File> {
401 ic_host_fs::durable::create_retained_at(directory.as_fd(), OsStr::new(name), 0o600)
402}
403
404#[derive(Debug, Error)]
406pub enum IcSnapshotArtifactError {
407 #[error("IC snapshot artifact original evidence mismatch")]
409 OriginalMismatch,
410 #[error("IC snapshot artifact coverage is incomplete")]
412 IncompleteCoverage,
413 #[error("IC snapshot artifact directory custody changed")]
415 CustodyChanged,
416 #[error("IC snapshot artifact tree entries differ")]
418 UnexpectedEntry,
419 #[error("IC snapshot artifact file type or length differs")]
421 FileShape,
422 #[error(transparent)]
424 Integrity(#[from] super::DownloadIntegrityError),
425 #[error(transparent)]
427 Coverage(#[from] IcSnapshotDataCoverageError),
428 #[error(transparent)]
430 Checksum(#[from] ChecksumError),
431 #[error(transparent)]
433 Journal(#[from] DownloadJournalError),
434 #[error(transparent)]
436 Artifact(#[from] ArtifactError),
437 #[error(transparent)]
439 Io(#[from] io::Error),
440}
441
442#[cfg(test)]
443mod tests;