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;
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,
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
39/// Exclusive ephemeral writer for one original journal artifact and exact IC metadata.
40///
41/// Each successful append retains decoded bytes in private descriptor-opened files.
42/// It reuses the model's coverage owner and keeps only three hash states plus at most
43/// 1,024 chunk checksums. It borrows the original layout/journal for its whole lifetime.
44/// Appends consume the writer: any rejection or partial IO failure closes it and retains
45/// the partial tree, without a completion transition or permission to repeat reads.
46/// There is no partial-transfer reconstruction or automatic cleanup.
47///
48/// The integration must qualify the generic token's association with the raw IC ID,
49/// authentic capture/metadata/data, original per-call spending, fresh read permission,
50/// complete backend transfer and stable noncooperating byte/command custody. This writer
51/// performs no provider calls and grants no upload/load/start, settlement or release.
52pub 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    /// 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_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    /// Join this existing private writer to an exact original transfer stage.
149    /// This reuses custody admission; no new byte fence or spending owner follows.
150    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    /// Read the original declared coverage without changing it.
164    #[must_use]
165    pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
166        &self.coverage
167    }
168
169    /// Read the fixed private staging path; stable external custody remains required.
170    #[must_use]
171    pub fn path(&self) -> &Path {
172        &self.path
173    }
174
175    /// Append one exact admitted reply without retaining its memory buffer.
176    ///
177    /// Regions may interleave; each region must remain contiguous from zero. Chunk files
178    /// use exact metadata hash names and exclusive creation. Host synchronizes the
179    /// staging directory after each new file; content synchronization and journal
180    /// transition wait for explicit finish. Any error consumes this owner and retains
181    /// all bytes.
182    /// Before writing, all region names must still select the held regular files at
183    /// their exact already-covered lengths. This sequential check is not a byte fence.
184    /// # Errors
185    /// Rejects changed custody, coverage conflicts and filesystem failures.
186    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    /// Verify complete local bytes, retain their exact checksum and durably publish them.
209    ///
210    /// Complete declared coverage is required. The fixed closed tree must match hashes
211    /// computed from the actual admitted bytes, including original metadata and request.
212    /// Existing model transitions derive `Downloaded` and `ChecksumVerified` together; their
213    /// exact checksum is persisted atomically before the existing durable publisher runs.
214    /// On interruption, reopen the journal and use its ordinary `ChecksumVerified` publication
215    /// recovery without another read or transfer. Created partial trees cannot be resumed
216    /// through this writer; preserve them for operator-owned disposition. Ordinary Durable
217    /// replay reads only retained progress, without fresh byte verification.
218    ///
219    /// The caller still owns authentic backend completeness, token/raw-ID association and
220    /// stable custody. A returned checksum is local evidence, not a terminal/effect permit.
221    /// # Errors
222    /// Rejects incomplete coverage, extra/changed/unsafe bytes, replaced custody and failed
223    /// persistence/publication. Failure can leave a verified or published tree; retain it.
224    /// Closing directory-custody rejection can follow a retained `Durable` transition.
225    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.journal.next(
254            target,
255            &self.snapshot,
256            ArtifactStateRecord::Downloaded,
257            None,
258        )?;
259        next.advance(
260            target,
261            &self.snapshot,
262            ArtifactStateRecord::ChecksumVerified,
263            Some(expected.clone()),
264        )
265        .map_err(DownloadJournalError::from)?;
266        self.journal.store(next, write_encoded)?;
267        publish(self.journal, target, &self.snapshot)?;
268        self.journal.check_usable()?;
269        check_directory_identity(
270            self.path
271                .parent()
272                .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
273            &self.parent,
274        )?;
275        let entry = self
276            .journal
277            .record
278            .artifact(target, &self.snapshot)
279            .map_err(DownloadJournalError::from)?;
280        check_directory_identity(
281            &self.journal.layout.root().join(entry.artifact_path()),
282            &self.directory,
283        )?;
284        Ok(expected)
285    }
286
287    fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
288        self.journal.check_usable()?;
289        for (path, held) in [
290            (
291                self.path
292                    .parent()
293                    .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
294                &self.parent,
295            ),
296            (self.path.as_path(), &self.directory),
297        ] {
298            check_directory_identity(path, held)?;
299        }
300        Ok(())
301    }
302
303    fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
304        check_closed_tree(
305            &self.directory,
306            self.checksums.iter().map(|(path, _)| path.as_os_str()),
307        )
308    }
309
310    fn check_region_custody(&self) -> Result<(), IcSnapshotArtifactError> {
311        for ((name, file), expected) in REGIONS
312            .iter()
313            .zip(&self.regions)
314            .zip(self.coverage.covered_region_bytes())
315        {
316            let held = unix_fs::fstat(file.get_ref()).map_err(io::Error::from)?;
317            let current = unix_fs::statat(&self.directory, *name, AtFlags::SYMLINK_NOFOLLOW)
318                .map_err(io::Error::from)?;
319            let same_file = (current.st_dev, current.st_ino) == (held.st_dev, held.st_ino);
320            let exact_extent = current.st_size == held.st_size
321                && u64::try_from(held.st_size).ok() == Some(expected);
322            if !FileType::from_raw_mode(current.st_mode).is_file() || !same_file || !exact_extent {
323                return Err(IcSnapshotArtifactError::FileShape);
324            }
325        }
326        Ok(())
327    }
328}
329
330fn check_closed_tree<'a>(
331    directory: &File,
332    names: impl IntoIterator<Item = &'a std::ffi::OsStr>,
333) -> Result<(), IcSnapshotArtifactError> {
334    let mut expected = names
335        .into_iter()
336        .map(|name| name.as_encoded_bytes().to_vec())
337        .collect::<BTreeSet<_>>();
338    let mut directory = Dir::read_from(directory).map_err(io::Error::from)?;
339    while let Some(entry) = directory.read() {
340        let entry = entry.map_err(io::Error::from)?;
341        let name = entry.file_name().to_bytes();
342        if matches!(name, b"." | b"..") {
343            continue;
344        }
345        if !expected.remove(name) {
346            return Err(IcSnapshotArtifactError::UnexpectedEntry);
347        }
348    }
349    if !expected.is_empty() {
350        return Err(IcSnapshotArtifactError::UnexpectedEntry);
351    }
352    Ok(())
353}
354
355impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        f.debug_struct("IcSnapshotArtifactWriter")
358            .field("coverage", &self.coverage)
359            .finish_non_exhaustive()
360    }
361}
362
363// Both fresh verification and upload reads hold the same descriptor scope through
364// their closing namespace checks. Callers retain original-plan checks on each side.
365fn with_artifact_directory<T, E: From<IcSnapshotArtifactError>>(
366    root: &Path,
367    artifact: &str,
368    read: impl FnOnce(&File) -> Result<T, E>,
369    finish: impl FnOnce() -> Result<(), E>,
370) -> Result<T, E> {
371    let parent_path = root.join("artifacts");
372    let parent = open_directory_no_follow(&parent_path).map_err(IcSnapshotArtifactError::from)?;
373    let path = root.join(artifact);
374    let directory = File::from(
375        unix_fs::openat(
376            &parent,
377            path.file_name()
378                .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
379            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
380            Mode::empty(),
381        )
382        .map_err(io::Error::from)
383        .map_err(IcSnapshotArtifactError::from)?,
384    );
385    let result = read(&directory)?;
386    check_directory_identity(&parent_path, &parent)?;
387    check_directory_identity(&path, &directory)?;
388    finish()?;
389    Ok(result)
390}
391
392fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
393    match ic_host_fs::durable::admit_directory(path, held) {
394        Ok(()) => Ok(()),
395        Err(error) if error.kind() == io::ErrorKind::InvalidInput => {
396            Err(IcSnapshotArtifactError::CustodyChanged)
397        }
398        Err(error) => Err(error.into()),
399    }
400}
401
402fn create_file(directory: &File, name: &str) -> io::Result<File> {
403    ic_host_fs::durable::create_retained_at(directory.as_fd(), OsStr::new(name), 0o600)
404}
405
406/// Typed local artifact failures; no wire payload or raw snapshot identity is retained.
407#[derive(Debug, Error)]
408pub enum IcSnapshotArtifactError {
409    /// Original journal state, timestamp or raw metadata differs.
410    #[error("IC snapshot artifact original evidence mismatch")]
411    OriginalMismatch,
412    /// Complete declared data coverage has not been retained.
413    #[error("IC snapshot artifact coverage is incomplete")]
414    IncompleteCoverage,
415    /// The original artifact parent or staging directory has been replaced.
416    #[error("IC snapshot artifact directory custody changed")]
417    CustodyChanged,
418    /// The fixed closed artifact tree contains missing or additional entries.
419    #[error("IC snapshot artifact tree entries differ")]
420    UnexpectedEntry,
421    /// A retained direct child differs in regular-file identity or bounded length.
422    #[error("IC snapshot artifact file type or length differs")]
423    FileShape,
424    /// Retained original plan, complete durable selection or journal admission failed.
425    #[error(transparent)]
426    Integrity(#[from] super::DownloadIntegrityError),
427    /// Exact metadata/range/chunk coverage admission failed.
428    #[error(transparent)]
429    Coverage(#[from] IcSnapshotDataCoverageError),
430    /// Local expected admitted bytes differ from the current tree.
431    #[error(transparent)]
432    Checksum(#[from] ChecksumError),
433    /// Original download journal or durable publication failed.
434    #[error(transparent)]
435    Journal(#[from] DownloadJournalError),
436    /// Descriptor-based artifact traversal failed.
437    #[error(transparent)]
438    Artifact(#[from] ArtifactError),
439    /// Private descriptor creation or byte IO failed.
440    #[error(transparent)]
441    Io(#[from] io::Error),
442}
443
444#[cfg(test)]
445mod tests;