1mod member;
4mod upload;
5mod verification;
6pub use upload::{IcSnapshotDataUploadPreparationError, IcSnapshotUploadArtifactError};
7
8use super::{DownloadJournalError, DownloadJournalGuard};
9use crate::ops::persistence::directory_custody::{open_directory, same_directory};
10use crate::{
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 rustix::fs::{self as unix_fs, AtFlags, Dir, FileType, Mode, OFlags};
24use sha2::{Digest, Sha256};
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: [File; 3],
60 hashes: [Sha256; 3],
61 checksums: Vec<(PathBuf, ArtifactChecksumRecord)>,
62}
63
64impl<'layout> DownloadJournalGuard<'layout> {
65 pub fn stage_ic_snapshot_artifact<'journal, 'metadata>(
77 &'journal mut self,
78 snapshot: &str,
79 metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
80 raw_metadata: &[u8],
81 ) -> Result<IcSnapshotArtifactWriter<'journal, 'layout, 'metadata>, IcSnapshotArtifactError>
82 {
83 self.check_usable()?;
84 let entry = self
85 .record
86 .artifact(metadata.request().target(), snapshot)
87 .map_err(DownloadJournalError::from)?;
88 if entry.state() != ArtifactStateRecord::Created
89 || entry.snapshot_taken_at_timestamp() != metadata.metadata().taken_at_timestamp
90 || raw_metadata.len() > MAX_IC_SNAPSHOT_METADATA_BYTES
91 || ArtifactChecksumRecord::from_bytes(raw_metadata) != *metadata.payload_checksum()
92 {
93 return Err(IcSnapshotArtifactError::OriginalMismatch);
94 }
95 self.check_artifact_parent()?;
96 let path = self.layout.root().join(entry.staging_path());
97 let canonical = self.layout.root().join(entry.artifact_path());
98 match fs::symlink_metadata(canonical) {
99 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
100 Err(error) => return Err(error.into()),
101 Ok(_) => return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()),
102 }
103 let parent = open_directory(&self.layout.root().join("artifacts"))?;
104 let name = path
105 .file_name()
106 .ok_or(IcSnapshotArtifactError::CustodyChanged)?;
107 unix_fs::mkdirat(&parent, name, Mode::from_bits_truncate(0o700))
108 .map_err(io::Error::from)?;
109 let directory = File::from(
110 unix_fs::openat(
111 &parent,
112 name,
113 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
114 Mode::empty(),
115 )
116 .map_err(io::Error::from)?,
117 );
118 let mut checksums = Vec::new();
119 for (name, bytes) in [
120 ("format", FORMAT),
121 ("metadata.candid", raw_metadata),
122 ("metadata-arguments.candid", metadata.request().arguments()),
123 ] {
124 create_file(&directory, name)?.write_all(bytes)?;
125 checksums.push((name.into(), ArtifactChecksumRecord::from_bytes(bytes)));
126 }
127 let regions = [
128 create_file(&directory, REGIONS[0])?,
129 create_file(&directory, REGIONS[1])?,
130 create_file(&directory, REGIONS[2])?,
131 ];
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 hashes: std::array::from_fn(|_| Sha256::new()),
141 checksums,
142 };
143 writer.check_custody()?;
144 Ok(writer)
145 }
146}
147
148impl<'layout> IcSnapshotArtifactWriter<'_, 'layout, '_> {
149 pub(crate) fn validate_transfer_origin(
152 &self,
153 root: &Path,
154 intent: &str,
155 ) -> Result<(), IcSnapshotArtifactError> {
156 self.check_custody()?;
157 self.check_region_custody()?;
158 if self.journal.layout.root() != root || self.journal.record()?.intent() != intent {
159 return Err(IcSnapshotArtifactError::OriginalMismatch);
160 }
161 Ok(())
162 }
163
164 #[must_use]
166 pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
167 &self.coverage
168 }
169
170 #[must_use]
172 pub fn path(&self) -> &Path {
173 &self.path
174 }
175
176 pub fn append(
188 mut self,
189 reply: &IcSnapshotDataReply<'_, '_>,
190 ) -> Result<Self, IcSnapshotArtifactError> {
191 self.check_custody()?;
192 self.check_region_custody()?;
193 self.coverage.admit(reply)?;
194 let index = match member::select(reply.request().kind()) {
195 member::Member::Region { index, .. } => index,
196 member::Member::Chunk { name } => {
197 create_file(&self.directory, &name)?.write_all(reply.chunk())?;
198 self.checksums
199 .push((name.into(), reply.chunk_checksum().clone()));
200 self.check_custody()?;
201 return Ok(self);
202 }
203 };
204 self.regions[index].write_all(reply.chunk())?;
205 self.hashes[index].update(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, hash) in REGIONS.into_iter().zip(self.hashes.iter()) {
244 self.checksums.push((
245 name.into(),
246 ArtifactChecksumRecord::from_digest(hash.clone().finalize().into()),
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.journal.next(
256 target,
257 &self.snapshot,
258 ArtifactStateRecord::Downloaded,
259 None,
260 )?;
261 next.advance(
262 target,
263 &self.snapshot,
264 ArtifactStateRecord::ChecksumVerified,
265 Some(expected.clone()),
266 )
267 .map_err(DownloadJournalError::from)?;
268 self.journal.store(next, write_json_durable)?;
269 publish(self.journal, target, &self.snapshot)?;
270 self.journal.check_usable()?;
271 check_directory_identity(
272 self.path
273 .parent()
274 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
275 &self.parent,
276 )?;
277 let entry = self
278 .journal
279 .record
280 .artifact(target, &self.snapshot)
281 .map_err(DownloadJournalError::from)?;
282 check_directory_identity(
283 &self.journal.layout.root().join(entry.artifact_path()),
284 &self.directory,
285 )?;
286 Ok(expected)
287 }
288
289 fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
290 self.journal.check_usable()?;
291 for (path, held) in [
292 (
293 self.path
294 .parent()
295 .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
296 &self.parent,
297 ),
298 (self.path.as_path(), &self.directory),
299 ] {
300 check_directory_identity(path, held)?;
301 }
302 Ok(())
303 }
304
305 fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
306 check_closed_tree(&self.directory, &self.checksums)
307 }
308
309 fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
310 for ((name, file), expected) in REGIONS
311 .iter()
312 .zip(&self.regions)
313 .zip(self.coverage.covered_region_bytes())
314 {
315 let held = unix_fs::fstat(file).map_err(io::Error::from)?;
316 let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
317 .map_err(io::Error::from)?;
318 let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
319 let exact_extent = current.st_size == held.st_size
320 && u64::try_from(held.st_size).ok() == Some(expected);
321 if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
322 return Err(IcSnapshotArtifactError::FileShape);
323 }
324 }
325 Ok(())
326 }
327}
328
329fn check_closed_tree(
330 directory: &File,
331 checksums: &[(PathBuf, ArtifactChecksumRecord)],
332) -> Result<(), IcSnapshotArtifactError> {
333 let mut expected = checksums
334 .iter()
335 .map(|(path, _)| path.as_os_str().as_encoded_bytes().to_vec())
336 .collect::<BTreeSet<_>>();
337 let mut directory = Dir::read_from(directory).map_err(io::Error::from)?;
338 while let Some(entry) = directory.read() {
339 let entry = entry.map_err(io::Error::from)?;
340 let name = entry.file_name().to_bytes();
341 if matches!(name, b"." | b"..") {
342 continue;
343 }
344 if !expected.remove(name) {
345 return Err(IcSnapshotArtifactError::UnexpectedEntry);
346 }
347 }
348 if !expected.is_empty() {
349 return Err(IcSnapshotArtifactError::UnexpectedEntry);
350 }
351 Ok(())
352}
353
354impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 f.debug_struct("IcSnapshotArtifactWriter")
357 .field("coverage", &self.coverage)
358 .finish_non_exhaustive()
359 }
360}
361
362fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
363 if !same_directory(path, held)? {
364 return Err(IcSnapshotArtifactError::CustodyChanged);
365 }
366 Ok(())
367}
368
369fn create_file(directory: &File, name: &str) -> io::Result<File> {
370 ic_host_fs::durable::create_retained_at(directory.as_fd(), OsStr::new(name), 0o600)
371}
372
373#[derive(Debug, Error)]
375pub enum IcSnapshotArtifactError {
376 #[error("IC snapshot artifact original evidence mismatch")]
378 OriginalMismatch,
379 #[error("IC snapshot artifact coverage is incomplete")]
381 IncompleteCoverage,
382 #[error("IC snapshot artifact directory custody changed")]
384 CustodyChanged,
385 #[error("IC snapshot artifact tree entries differ")]
387 UnexpectedEntry,
388 #[error("IC snapshot artifact file type or length differs")]
390 FileShape,
391 #[error(transparent)]
393 Integrity(#[from] super::DownloadIntegrityError),
394 #[error(transparent)]
396 Coverage(#[from] IcSnapshotDataCoverageError),
397 #[error(transparent)]
399 Checksum(#[from] ChecksumError),
400 #[error(transparent)]
402 Journal(#[from] DownloadJournalError),
403 #[error(transparent)]
405 Artifact(#[from] ArtifactError),
406 #[error(transparent)]
408 Io(#[from] io::Error),
409}
410
411#[cfg(test)]
412mod tests;