Skip to main content

ic_backup/ops/persistence/download_journal/ic_snapshot_artifact/
mod.rs

1//! Bounded local snapshot streaming joined to the existing durable download owner.
2
3mod 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
38/// Exclusive ephemeral writer for one original journal artifact and exact IC metadata.
39///
40/// Each successful append retains decoded bytes in private descriptor-opened files.
41/// It reuses the model's coverage owner and keeps only three hash states plus at most
42/// 1,024 chunk checksums. It borrows the original layout/journal for its whole lifetime.
43/// Appends consume the writer: any rejection or partial IO failure closes it and retains
44/// the partial tree, without a completion transition or permission to repeat reads.
45/// There is no partial-transfer reconstruction or automatic cleanup.
46///
47/// The integration must qualify the generic token's association with the raw IC ID,
48/// authentic capture/metadata/data, original per-call spending, fresh read permission,
49/// complete backend transfer and stable noncooperating byte/command custody. This writer
50/// performs no provider calls and grants no upload/load/start, settlement or release.
51pub 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    /// Create private staging for an existing Created artifact and exact decoded metadata.
65    ///
66    /// The exact generic backend token remains unchanged and is not parsed as a raw IC ID.
67    /// The integration supplies their authoritative association. Target and timestamp must
68    /// match the retained entry; its independently observed total snapshot size is preserved,
69    /// never inferred from a sum of metadata regions. Exact original metadata wire bytes and
70    /// request arguments are retained alongside fixed region/chunk files in a distinct v1
71    /// artifact layout. Existing staging/canonical destinations reject without adoption.
72    /// # Errors
73    /// Rejects wrong identity/state/raw metadata, occupied or unsafe paths and IO failure.
74    /// Failure retains partial bytes and leaves the original journal unchanged.
75    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    /// Read the original declared coverage without changing it.
148    #[must_use]
149    pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
150        &self.coverage
151    }
152
153    /// Read the fixed private staging path; stable external custody remains required.
154    #[must_use]
155    pub fn path(&self) -> &Path {
156        &self.path
157    }
158
159    /// Append one exact admitted reply without retaining its memory buffer.
160    ///
161    /// Regions may interleave; each region must remain contiguous from zero. Chunk files
162    /// use exact metadata hash names and exclusive creation. No fsync or journal transition
163    /// occurs until explicit finish. Any error consumes this owner and retains all bytes.
164    /// Before writing, all region names must still select the held regular files at
165    /// their exact already-covered lengths. This sequential check is not a byte fence.
166    /// # Errors
167    /// Rejects changed custody, coverage conflicts and filesystem failures.
168    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    /// Verify complete local bytes, retain their exact checksum and durably publish them.
195    ///
196    /// Complete declared coverage is required. The fixed closed tree must match hashes
197    /// computed from the actual admitted bytes, including original metadata and request.
198    /// Existing model transitions derive `Downloaded` and `ChecksumVerified` together; their
199    /// exact checksum is persisted atomically before the existing durable publisher runs.
200    /// On interruption, reopen the journal and use its ordinary `ChecksumVerified` publication
201    /// recovery without another read or transfer. Created partial trees cannot be resumed
202    /// through this writer; preserve them for operator-owned disposition. Ordinary Durable
203    /// replay reads only retained progress, without fresh byte verification.
204    ///
205    /// The caller still owns authentic backend completeness, token/raw-ID association and
206    /// stable custody. A returned checksum is local evidence, not a terminal/effect permit.
207    /// # Errors
208    /// Rejects incomplete coverage, extra/changed/unsafe bytes, replaced custody and failed
209    /// persistence/publication. Failure can leave a verified or published tree; retain it.
210    /// Closing directory-custody rejection can follow a retained `Durable` transition.
211    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        checksum_directory(&self.path)?.verify(expected.hash())?;
236        self.check_custody()?;
237        let target = self.coverage.metadata().request().target();
238        let mut next = self.journal.next(
239            target,
240            &self.snapshot,
241            ArtifactStateRecord::Downloaded,
242            None,
243        )?;
244        next.advance(
245            target,
246            &self.snapshot,
247            ArtifactStateRecord::ChecksumVerified,
248            Some(expected.clone()),
249        )
250        .map_err(DownloadJournalError::from)?;
251        self.journal.store(next, write_json_durable)?;
252        publish(self.journal, target, &self.snapshot)?;
253        self.journal.check_usable()?;
254        check_directory_identity(
255            self.path
256                .parent()
257                .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
258            &self.parent,
259        )?;
260        let entry = self
261            .journal
262            .record
263            .artifact(target, &self.snapshot)
264            .map_err(DownloadJournalError::from)?;
265        check_directory_identity(
266            &self.journal.layout.root().join(entry.artifact_path()),
267            &self.directory,
268        )?;
269        Ok(expected)
270    }
271
272    fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
273        self.journal.check_usable()?;
274        for (path, held) in [
275            (
276                self.path
277                    .parent()
278                    .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
279                &self.parent,
280            ),
281            (self.path.as_path(), &self.directory),
282        ] {
283            check_directory_identity(path, held)?;
284        }
285        Ok(())
286    }
287
288    fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
289        check_closed_tree(&self.directory, &self.checksums)
290    }
291
292    fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
293        for ((name, file), expected) in REGIONS
294            .iter()
295            .zip(&self.regions)
296            .zip(self.coverage.covered_region_bytes())
297        {
298            let held = unix_fs::fstat(file).map_err(errno_to_io)?;
299            let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
300                .map_err(errno_to_io)?;
301            let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
302            let exact_extent = current.st_size == held.st_size
303                && u64::try_from(held.st_size).ok() == Some(expected);
304            if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
305                return Err(IcSnapshotArtifactError::FileShape);
306            }
307        }
308        Ok(())
309    }
310}
311
312fn check_closed_tree(
313    directory: &File,
314    checksums: &[(PathBuf, ArtifactChecksumRecord)],
315) -> Result<(), IcSnapshotArtifactError> {
316    let mut expected = checksums
317        .iter()
318        .map(|(path, _)| path.as_os_str().as_encoded_bytes().to_vec())
319        .collect::<BTreeSet<_>>();
320    let mut directory = Dir::read_from(directory).map_err(errno_to_io)?;
321    while let Some(entry) = directory.read() {
322        let entry = entry.map_err(errno_to_io)?;
323        let name = entry.file_name().to_bytes();
324        if matches!(name, b"." | b"..") {
325            continue;
326        }
327        if !expected.remove(name) {
328            return Err(IcSnapshotArtifactError::UnexpectedEntry);
329        }
330    }
331    if !expected.is_empty() {
332        return Err(IcSnapshotArtifactError::UnexpectedEntry);
333    }
334    Ok(())
335}
336
337impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.debug_struct("IcSnapshotArtifactWriter")
340            .field("coverage", &self.coverage)
341            .finish_non_exhaustive()
342    }
343}
344
345fn open_directory(path: &Path) -> io::Result<File> {
346    unix_fs::open(
347        path,
348        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
349        Mode::empty(),
350    )
351    .map(File::from)
352    .map_err(errno_to_io)
353}
354
355fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
356    let current = fs::symlink_metadata(path)?;
357    let original = held.metadata()?;
358    if !current.is_dir() || current.dev() != original.dev() || current.ino() != original.ino() {
359        return Err(IcSnapshotArtifactError::CustodyChanged);
360    }
361    Ok(())
362}
363
364fn create_file(directory: &File, name: &str) -> io::Result<File> {
365    unix_fs::openat(
366        directory,
367        name,
368        OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
369        Mode::from_bits_truncate(0o600),
370    )
371    .map(File::from)
372    .map_err(errno_to_io)
373}
374
375fn errno_to_io(error: rustix::io::Errno) -> io::Error {
376    io::Error::from_raw_os_error(error.raw_os_error())
377}
378
379/// Typed local artifact failures; no wire payload or raw snapshot identity is retained.
380#[derive(Debug, Error)]
381pub enum IcSnapshotArtifactError {
382    /// Original journal state, timestamp or raw metadata differs.
383    #[error("IC snapshot artifact original evidence mismatch")]
384    OriginalMismatch,
385    /// Complete declared data coverage has not been retained.
386    #[error("IC snapshot artifact coverage is incomplete")]
387    IncompleteCoverage,
388    /// The original artifact parent or staging directory has been replaced.
389    #[error("IC snapshot artifact directory custody changed")]
390    CustodyChanged,
391    /// The fixed closed artifact tree contains missing or additional entries.
392    #[error("IC snapshot artifact tree entries differ")]
393    UnexpectedEntry,
394    /// A retained direct child differs in regular-file identity or bounded length.
395    #[error("IC snapshot artifact file type or length differs")]
396    FileShape,
397    /// Retained original plan, complete durable selection or journal admission failed.
398    #[error(transparent)]
399    Integrity(#[from] super::DownloadIntegrityError),
400    /// Exact metadata/range/chunk coverage admission failed.
401    #[error(transparent)]
402    Coverage(#[from] IcSnapshotDataCoverageError),
403    /// Local expected admitted bytes differ from the current tree.
404    #[error(transparent)]
405    Checksum(#[from] ChecksumError),
406    /// Original download journal or durable publication failed.
407    #[error(transparent)]
408    Journal(#[from] DownloadJournalError),
409    /// Descriptor-based artifact traversal failed.
410    #[error(transparent)]
411    Artifact(#[from] ArtifactError),
412    /// Private descriptor creation or byte IO failed.
413    #[error(transparent)]
414    Io(#[from] io::Error),
415}
416
417#[cfg(test)]
418mod tests;