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::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
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: [File; 3],
60    hashes: [Sha256; 3],
61    checksums: Vec<(PathBuf, ArtifactChecksumRecord)>,
62}
63
64impl<'layout> DownloadJournalGuard<'layout> {
65    /// Create private staging for an existing Created artifact and exact decoded metadata.
66    ///
67    /// The exact generic backend token remains unchanged and is not parsed as a raw IC ID.
68    /// The integration supplies their authoritative association. Target and timestamp must
69    /// match the retained entry; its independently observed total snapshot size is preserved,
70    /// never inferred from a sum of metadata regions. Exact original metadata wire bytes and
71    /// request arguments are retained alongside fixed region/chunk files in a distinct v1
72    /// artifact layout. Existing staging/canonical destinations reject without adoption.
73    /// # Errors
74    /// Rejects wrong identity/state/raw metadata, occupied or unsafe paths and IO failure.
75    /// Failure retains partial bytes and leaves the original journal unchanged.
76    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    /// Join this existing private writer to an exact original transfer stage.
150    /// This reuses custody admission; no new byte fence or spending owner follows.
151    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    /// Read the original declared coverage without changing it.
165    #[must_use]
166    pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
167        &self.coverage
168    }
169
170    /// Read the fixed private staging path; stable external custody remains required.
171    #[must_use]
172    pub fn path(&self) -> &Path {
173        &self.path
174    }
175
176    /// Append one exact admitted reply without retaining its memory buffer.
177    ///
178    /// Regions may interleave; each region must remain contiguous from zero. Chunk files
179    /// use exact metadata hash names and exclusive creation. Host synchronizes the
180    /// staging directory after each new file; content synchronization and journal
181    /// transition wait for explicit finish. Any error consumes this owner and retains
182    /// all bytes.
183    /// Before writing, all region names must still select the held regular files at
184    /// their exact already-covered lengths. This sequential check is not a byte fence.
185    /// # Errors
186    /// Rejects changed custody, coverage conflicts and filesystem failures.
187    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    /// 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, 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/// Typed local artifact failures; no wire payload or raw snapshot identity is retained.
374#[derive(Debug, Error)]
375pub enum IcSnapshotArtifactError {
376    /// Original journal state, timestamp or raw metadata differs.
377    #[error("IC snapshot artifact original evidence mismatch")]
378    OriginalMismatch,
379    /// Complete declared data coverage has not been retained.
380    #[error("IC snapshot artifact coverage is incomplete")]
381    IncompleteCoverage,
382    /// The original artifact parent or staging directory has been replaced.
383    #[error("IC snapshot artifact directory custody changed")]
384    CustodyChanged,
385    /// The fixed closed artifact tree contains missing or additional entries.
386    #[error("IC snapshot artifact tree entries differ")]
387    UnexpectedEntry,
388    /// A retained direct child differs in regular-file identity or bounded length.
389    #[error("IC snapshot artifact file type or length differs")]
390    FileShape,
391    /// Retained original plan, complete durable selection or journal admission failed.
392    #[error(transparent)]
393    Integrity(#[from] super::DownloadIntegrityError),
394    /// Exact metadata/range/chunk coverage admission failed.
395    #[error(transparent)]
396    Coverage(#[from] IcSnapshotDataCoverageError),
397    /// Local expected admitted bytes differ from the current tree.
398    #[error(transparent)]
399    Checksum(#[from] ChecksumError),
400    /// Original download journal or durable publication failed.
401    #[error(transparent)]
402    Journal(#[from] DownloadJournalError),
403    /// Descriptor-based artifact traversal failed.
404    #[error(transparent)]
405    Artifact(#[from] ArtifactError),
406    /// Private descriptor creation or byte IO failed.
407    #[error(transparent)]
408    Io(#[from] io::Error),
409}
410
411#[cfg(test)]
412mod tests;