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 verification;
4
5use super::{DownloadJournalError, DownloadJournalGuard};
6use crate::{
7    hash::hex_bytes,
8    model::{
9        artifacts::{ArtifactChecksumRecord, ChecksumError},
10        download_journal::ArtifactStateRecord,
11        ic_snapshot_coverage::{IcSnapshotDataCoverage, IcSnapshotDataCoverageError},
12        ic_snapshot_data::IcSnapshotDataReply,
13        ic_snapshot_metadata::{IcSnapshotMetadataReply, MAX_IC_SNAPSHOT_METADATA_BYTES},
14    },
15    ops::{
16        artifacts::{ArtifactError, checksum_directory, checksum_relative_files},
17        persistence::write_json_durable,
18    },
19};
20use ic_management_canister_types::SnapshotDataKind;
21use rustix::fs::{self as unix_fs, Dir, Mode, OFlags};
22use sha2::{Digest, Sha256};
23use std::{
24    collections::BTreeSet,
25    fmt,
26    fs::{self, File},
27    io::{self, Write},
28    os::unix::fs::MetadataExt,
29    path::{Path, PathBuf},
30};
31use thiserror::Error;
32
33const FORMAT: &[u8] = b"ic-backup/ic-snapshot-artifact/v1\n";
34const REGIONS: [&str; 3] = ["wasm-module.bin", "wasm-memory.bin", "stable-memory.bin"];
35
36/// Exclusive ephemeral writer for one original journal artifact and exact IC metadata.
37///
38/// Each successful append retains decoded bytes in private descriptor-opened files.
39/// It reuses the model's coverage owner and keeps only three hash states plus at most
40/// 1,024 chunk checksums. It borrows the original layout/journal for its whole lifetime.
41/// Appends consume the writer: any rejection or partial IO failure closes it and retains
42/// the partial tree, without a completion transition or permission to repeat reads.
43/// There is no partial-transfer reconstruction or automatic cleanup.
44///
45/// The integration must qualify the generic token's association with the raw IC ID,
46/// authentic capture/metadata/data, original per-call spending, fresh read permission,
47/// complete backend transfer and stable noncooperating byte/command custody. This writer
48/// performs no provider calls and grants no upload/load/start, settlement or release.
49pub struct IcSnapshotArtifactWriter<'journal, 'layout, 'metadata> {
50    journal: &'journal mut DownloadJournalGuard<'layout>,
51    coverage: IcSnapshotDataCoverage<'metadata>,
52    snapshot: String,
53    path: PathBuf,
54    parent: File,
55    directory: File,
56    regions: [File; 3],
57    hashes: [Sha256; 3],
58    checksums: Vec<(PathBuf, ArtifactChecksumRecord)>,
59}
60
61impl<'layout> DownloadJournalGuard<'layout> {
62    /// Create private staging for an existing Created artifact and exact decoded metadata.
63    ///
64    /// The exact generic backend token remains unchanged and is not parsed as a raw IC ID.
65    /// The integration supplies their authoritative association. Target and timestamp must
66    /// match the retained entry; its independently observed total snapshot size is preserved,
67    /// never inferred from a sum of metadata regions. Exact original metadata wire bytes and
68    /// request arguments are retained alongside fixed region/chunk files in a distinct v1
69    /// artifact layout. Existing staging/canonical destinations reject without adoption.
70    /// # Errors
71    /// Rejects wrong identity/state/raw metadata, occupied or unsafe paths and IO failure.
72    /// Failure retains partial bytes and leaves the original journal unchanged.
73    pub fn stage_ic_snapshot_artifact<'journal, 'metadata>(
74        &'journal mut self,
75        snapshot: &str,
76        metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
77        raw_metadata: &[u8],
78    ) -> Result<IcSnapshotArtifactWriter<'journal, 'layout, 'metadata>, IcSnapshotArtifactError>
79    {
80        self.check_usable()?;
81        let entry = self
82            .record
83            .artifact(metadata.request().target(), snapshot)
84            .map_err(DownloadJournalError::from)?;
85        if entry.state() != ArtifactStateRecord::Created
86            || entry.snapshot_taken_at_timestamp() != metadata.metadata().taken_at_timestamp
87            || raw_metadata.len() > MAX_IC_SNAPSHOT_METADATA_BYTES
88            || ArtifactChecksumRecord::from_bytes(raw_metadata) != *metadata.payload_checksum()
89        {
90            return Err(IcSnapshotArtifactError::OriginalMismatch);
91        }
92        self.check_artifact_parent()?;
93        let path = self.layout.root().join(entry.staging_path());
94        let canonical = self.layout.root().join(entry.artifact_path());
95        match fs::symlink_metadata(canonical) {
96            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
97            Err(error) => return Err(error.into()),
98            Ok(_) => return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()),
99        }
100        let parent = open_directory(&self.layout.root().join("artifacts"))?;
101        let name = path
102            .file_name()
103            .ok_or(IcSnapshotArtifactError::CustodyChanged)?;
104        unix_fs::mkdirat(&parent, name, Mode::from_bits_truncate(0o700)).map_err(errno_to_io)?;
105        let directory = File::from(
106            unix_fs::openat(
107                &parent,
108                name,
109                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
110                Mode::empty(),
111            )
112            .map_err(errno_to_io)?,
113        );
114        let mut checksums = Vec::new();
115        for (name, bytes) in [
116            ("format", FORMAT),
117            ("metadata.candid", raw_metadata),
118            ("metadata-arguments.candid", metadata.request().arguments()),
119        ] {
120            create_file(&directory, name)?.write_all(bytes)?;
121            checksums.push((name.into(), ArtifactChecksumRecord::from_bytes(bytes)));
122        }
123        let regions = [
124            create_file(&directory, REGIONS[0])?,
125            create_file(&directory, REGIONS[1])?,
126            create_file(&directory, REGIONS[2])?,
127        ];
128        let writer = IcSnapshotArtifactWriter {
129            journal: self,
130            coverage: IcSnapshotDataCoverage::new(metadata),
131            snapshot: snapshot.to_owned(),
132            path,
133            parent,
134            directory,
135            regions,
136            hashes: std::array::from_fn(|_| Sha256::new()),
137            checksums,
138        };
139        writer.check_custody()?;
140        Ok(writer)
141    }
142}
143
144impl<'layout> IcSnapshotArtifactWriter<'_, 'layout, '_> {
145    /// Read the original declared coverage without changing it.
146    #[must_use]
147    pub const fn coverage(&self) -> &IcSnapshotDataCoverage<'_> {
148        &self.coverage
149    }
150
151    /// Read the fixed private staging path; stable external custody remains required.
152    #[must_use]
153    pub fn path(&self) -> &Path {
154        &self.path
155    }
156
157    /// Append one exact admitted reply without retaining its memory buffer.
158    ///
159    /// Regions may interleave; each region must remain contiguous from zero. Chunk files
160    /// use exact metadata hash names and exclusive creation. No fsync or journal transition
161    /// occurs until explicit finish. Any error consumes this owner and retains all bytes.
162    /// # Errors
163    /// Rejects changed custody, coverage conflicts and filesystem failures.
164    pub fn append(
165        mut self,
166        reply: &IcSnapshotDataReply<'_, '_>,
167    ) -> Result<Self, IcSnapshotArtifactError> {
168        self.check_custody()?;
169        self.coverage.admit(reply)?;
170        let index = match reply.request().kind() {
171            SnapshotDataKind::WasmModule { .. } => 0,
172            SnapshotDataKind::WasmMemory { .. } => 1,
173            SnapshotDataKind::StableMemory { .. } => 2,
174            SnapshotDataKind::WasmChunk { hash } => {
175                let name = format!("chunk-{}.bin", hex_bytes(hash));
176                create_file(&self.directory, &name)?.write_all(reply.chunk())?;
177                self.checksums
178                    .push((name.into(), reply.chunk_checksum().clone()));
179                self.check_custody()?;
180                return Ok(self);
181            }
182        };
183        self.regions[index].write_all(reply.chunk())?;
184        self.hashes[index].update(reply.chunk());
185        self.check_custody()?;
186        Ok(self)
187    }
188
189    /// Verify complete local bytes, retain their exact checksum and durably publish them.
190    ///
191    /// Complete declared coverage is required. The fixed closed tree must match hashes
192    /// computed from the actual admitted bytes, including original metadata and request.
193    /// Existing model transitions derive `Downloaded` and `ChecksumVerified` together; their
194    /// exact checksum is persisted atomically before the existing durable publisher runs.
195    /// On interruption, reopen the journal and use its ordinary `ChecksumVerified` publication
196    /// recovery without another read or transfer. Created partial trees cannot be resumed
197    /// through this writer; preserve them for operator-owned disposition. Ordinary Durable
198    /// replay reads only retained progress, without fresh byte verification.
199    ///
200    /// The caller still owns authentic backend completeness, token/raw-ID association and
201    /// stable custody. A returned checksum is local evidence, not a terminal/effect permit.
202    /// # Errors
203    /// Rejects incomplete coverage, extra/changed/unsafe bytes, replaced custody and failed
204    /// persistence/publication. Failure can leave a verified or published tree; retain it.
205    /// Closing directory-custody rejection can follow a retained `Durable` transition.
206    pub fn finish(self) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
207        self.finish_with(DownloadJournalGuard::finalize_artifact)
208    }
209
210    fn finish_with(
211        mut self,
212        publish: impl FnOnce(
213            &mut DownloadJournalGuard<'layout>,
214            &str,
215            &str,
216        ) -> Result<(), DownloadJournalError>,
217    ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
218        if self.coverage.complete().is_none() {
219            return Err(IcSnapshotArtifactError::IncompleteCoverage);
220        }
221        self.check_custody()?;
222        for (name, hash) in REGIONS.into_iter().zip(self.hashes.iter()) {
223            self.checksums.push((
224                name.into(),
225                ArtifactChecksumRecord::from_digest(hash.clone().finalize().into()),
226            ));
227        }
228        self.check_closed_tree()?;
229        let expected = checksum_relative_files(std::mem::take(&mut self.checksums));
230        checksum_directory(&self.path)?.verify(expected.hash())?;
231        self.check_custody()?;
232        let target = self.coverage.metadata().request().target();
233        let mut next = self.journal.next(
234            target,
235            &self.snapshot,
236            ArtifactStateRecord::Downloaded,
237            None,
238        )?;
239        next.advance(
240            target,
241            &self.snapshot,
242            ArtifactStateRecord::ChecksumVerified,
243            Some(expected.clone()),
244        )
245        .map_err(DownloadJournalError::from)?;
246        self.journal.store(next, write_json_durable)?;
247        publish(self.journal, target, &self.snapshot)?;
248        self.journal.check_usable()?;
249        check_directory_identity(
250            self.path
251                .parent()
252                .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
253            &self.parent,
254        )?;
255        let entry = self
256            .journal
257            .record
258            .artifact(target, &self.snapshot)
259            .map_err(DownloadJournalError::from)?;
260        check_directory_identity(
261            &self.journal.layout.root().join(entry.artifact_path()),
262            &self.directory,
263        )?;
264        Ok(expected)
265    }
266
267    fn check_custody(&self) -> Result<(), IcSnapshotArtifactError> {
268        self.journal.check_usable()?;
269        for (path, held) in [
270            (
271                self.path
272                    .parent()
273                    .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
274                &self.parent,
275            ),
276            (self.path.as_path(), &self.directory),
277        ] {
278            check_directory_identity(path, held)?;
279        }
280        Ok(())
281    }
282
283    fn check_closed_tree(&self) -> Result<(), IcSnapshotArtifactError> {
284        check_closed_tree(&self.directory, &self.checksums)
285    }
286}
287
288fn check_closed_tree(
289    directory: &File,
290    checksums: &[(PathBuf, ArtifactChecksumRecord)],
291) -> Result<(), IcSnapshotArtifactError> {
292    let mut expected = checksums
293        .iter()
294        .map(|(path, _)| path.as_os_str().as_encoded_bytes().to_vec())
295        .collect::<BTreeSet<_>>();
296    let mut directory = Dir::read_from(directory).map_err(errno_to_io)?;
297    while let Some(entry) = directory.read() {
298        let entry = entry.map_err(errno_to_io)?;
299        let name = entry.file_name().to_bytes();
300        if matches!(name, b"." | b"..") {
301            continue;
302        }
303        if !expected.remove(name) {
304            return Err(IcSnapshotArtifactError::UnexpectedEntry);
305        }
306    }
307    if !expected.is_empty() {
308        return Err(IcSnapshotArtifactError::UnexpectedEntry);
309    }
310    Ok(())
311}
312
313impl fmt::Debug for IcSnapshotArtifactWriter<'_, '_, '_> {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.debug_struct("IcSnapshotArtifactWriter")
316            .field("coverage", &self.coverage)
317            .finish_non_exhaustive()
318    }
319}
320
321fn open_directory(path: &Path) -> io::Result<File> {
322    unix_fs::open(
323        path,
324        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
325        Mode::empty(),
326    )
327    .map(File::from)
328    .map_err(errno_to_io)
329}
330
331fn check_directory_identity(path: &Path, held: &File) -> Result<(), IcSnapshotArtifactError> {
332    let current = fs::symlink_metadata(path)?;
333    let original = held.metadata()?;
334    if !current.is_dir() || current.dev() != original.dev() || current.ino() != original.ino() {
335        return Err(IcSnapshotArtifactError::CustodyChanged);
336    }
337    Ok(())
338}
339
340fn create_file(directory: &File, name: &str) -> io::Result<File> {
341    unix_fs::openat(
342        directory,
343        name,
344        OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
345        Mode::from_bits_truncate(0o600),
346    )
347    .map(File::from)
348    .map_err(errno_to_io)
349}
350
351fn errno_to_io(error: rustix::io::Errno) -> io::Error {
352    io::Error::from_raw_os_error(error.raw_os_error())
353}
354
355/// Typed local artifact failures; no wire payload or raw snapshot identity is retained.
356#[derive(Debug, Error)]
357pub enum IcSnapshotArtifactError {
358    /// Original journal state, timestamp or raw metadata differs.
359    #[error("IC snapshot artifact original evidence mismatch")]
360    OriginalMismatch,
361    /// Complete declared data coverage has not been retained.
362    #[error("IC snapshot artifact coverage is incomplete")]
363    IncompleteCoverage,
364    /// The original artifact parent or staging directory has been replaced.
365    #[error("IC snapshot artifact directory custody changed")]
366    CustodyChanged,
367    /// The fixed closed artifact tree contains missing or additional entries.
368    #[error("IC snapshot artifact tree entries differ")]
369    UnexpectedEntry,
370    /// A retained direct child is not a regular file of the declared bounded size.
371    #[error("IC snapshot artifact file type or length differs")]
372    FileShape,
373    /// Retained original plan, complete durable selection or journal admission failed.
374    #[error(transparent)]
375    Integrity(#[from] super::DownloadIntegrityError),
376    /// Exact metadata/range/chunk coverage admission failed.
377    #[error(transparent)]
378    Coverage(#[from] IcSnapshotDataCoverageError),
379    /// Local expected admitted bytes differ from the current tree.
380    #[error(transparent)]
381    Checksum(#[from] ChecksumError),
382    /// Original download journal or durable publication failed.
383    #[error(transparent)]
384    Journal(#[from] DownloadJournalError),
385    /// Descriptor-based artifact traversal failed.
386    #[error(transparent)]
387    Artifact(#[from] ArtifactError),
388    /// Private descriptor creation or byte IO failed.
389    #[error(transparent)]
390    Io(#[from] io::Error),
391}
392
393#[cfg(test)]
394mod tests;