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 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
41/// Exclusive ephemeral writer for one original journal artifact and exact IC metadata.
42///
43/// Each successful append retains decoded bytes in private descriptor-opened files.
44/// It reuses the model's coverage owner and keeps only three hash states plus at most
45/// 1,024 chunk checksums. It borrows the original layout/journal for its whole lifetime.
46/// Appends consume the writer: any rejection or partial IO failure closes it and retains
47/// the partial tree, without a completion transition or permission to repeat reads.
48/// There is no partial-transfer reconstruction or automatic cleanup.
49///
50/// The integration must qualify the generic token's association with the raw IC ID,
51/// authentic capture/metadata/data, original per-call spending, fresh read permission,
52/// complete backend transfer and stable noncooperating byte/command custody. This writer
53/// performs no provider calls and grants no upload/load/start, settlement or release.
54pub 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    /// Create private staging for an existing Created artifact and exact decoded metadata.
67    ///
68    /// The exact generic backend token remains unchanged and is not parsed as a raw IC ID.
69    /// The integration supplies their authoritative association. Target and timestamp must
70    /// match the retained entry; its independently observed total snapshot size is preserved,
71    /// never inferred from a sum of metadata regions. Exact original metadata wire bytes and
72    /// request arguments are retained alongside fixed region/chunk files in a distinct v1
73    /// artifact layout. Existing staging/canonical destinations reject without adoption.
74    /// # Errors
75    /// Rejects wrong identity/state/raw metadata, occupied or unsafe paths and IO failure.
76    /// Failure retains partial bytes and leaves the original journal unchanged.
77    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    /// Join this existing private writer to an exact original transfer stage.
151    /// This reuses custody admission; no new byte fence or spending owner follows.
152    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    /// Read the original declared coverage without changing it.
166    #[must_use]
167    pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
168        &self.coverage
169    }
170
171    /// Read the fixed private staging path; stable external custody remains required.
172    #[must_use]
173    pub fn path(&self) -> &Path {
174        &self.path
175    }
176
177    /// Append one exact admitted reply without retaining its memory buffer.
178    ///
179    /// Regions may interleave; each region must remain contiguous from zero. Chunk files
180    /// use exact metadata hash names and exclusive creation. Host synchronizes the
181    /// staging directory after each new file; content synchronization and journal
182    /// transition wait for explicit finish. Any error consumes this owner and retains
183    /// all bytes.
184    /// Before writing, all region names must still select the held regular files at
185    /// their exact already-covered lengths. This sequential check is not a byte fence.
186    /// # Errors
187    /// Rejects changed custody, coverage conflicts and filesystem failures.
188    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    /// Verify complete local bytes, retain their exact checksum and durably publish them.
211    ///
212    /// Complete declared coverage is required. The fixed closed tree must match hashes
213    /// computed from the actual admitted bytes, including original metadata and request.
214    /// Existing model transitions derive `Downloaded` and `ChecksumVerified` together; their
215    /// exact checksum is persisted atomically before the existing durable publisher runs.
216    /// On interruption, reopen the journal and use its ordinary `ChecksumVerified` publication
217    /// recovery without another read or transfer. Created partial trees cannot be resumed
218    /// through this writer; preserve them for operator-owned disposition. Ordinary Durable
219    /// replay reads only retained progress, without fresh byte verification.
220    ///
221    /// The caller still owns authentic backend completeness, token/raw-ID association and
222    /// stable custody. A returned checksum is local evidence, not a terminal/effect permit.
223    /// # Errors
224    /// Rejects incomplete coverage, extra/changed/unsafe bytes, replaced custody and failed
225    /// persistence/publication. Failure can leave a verified or published tree; retain it.
226    /// Closing directory-custody rejection can follow a retained `Durable` transition.
227    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
361// Both fresh verification and upload reads hold the same descriptor scope through
362// their closing namespace checks. Callers retain original-plan checks on each side.
363fn 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/// Typed local artifact failures; no wire payload or raw snapshot identity is retained.
405#[derive(Debug, Error)]
406pub enum IcSnapshotArtifactError {
407    /// Original journal state, timestamp or raw metadata differs.
408    #[error("IC snapshot artifact original evidence mismatch")]
409    OriginalMismatch,
410    /// Complete declared data coverage has not been retained.
411    #[error("IC snapshot artifact coverage is incomplete")]
412    IncompleteCoverage,
413    /// The original artifact parent or staging directory has been replaced.
414    #[error("IC snapshot artifact directory custody changed")]
415    CustodyChanged,
416    /// The fixed closed artifact tree contains missing or additional entries.
417    #[error("IC snapshot artifact tree entries differ")]
418    UnexpectedEntry,
419    /// A retained direct child differs in regular-file identity or bounded length.
420    #[error("IC snapshot artifact file type or length differs")]
421    FileShape,
422    /// Retained original plan, complete durable selection or journal admission failed.
423    #[error(transparent)]
424    Integrity(#[from] super::DownloadIntegrityError),
425    /// Exact metadata/range/chunk coverage admission failed.
426    #[error(transparent)]
427    Coverage(#[from] IcSnapshotDataCoverageError),
428    /// Local expected admitted bytes differ from the current tree.
429    #[error(transparent)]
430    Checksum(#[from] ChecksumError),
431    /// Original download journal or durable publication failed.
432    #[error(transparent)]
433    Journal(#[from] DownloadJournalError),
434    /// Descriptor-based artifact traversal failed.
435    #[error(transparent)]
436    Artifact(#[from] ArtifactError),
437    /// Private descriptor creation or byte IO failed.
438    #[error(transparent)]
439    Io(#[from] io::Error),
440}
441
442#[cfg(test)]
443mod tests;