Skip to main content

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

1//! Explicit fresh local checks of retained opt-in IC trees; no upload permission.
2
3use super::super::metrics::LocalOperation;
4use super::{
5    ArtifactChecksumRecord, DownloadJournalGuard, FORMAT, File, IcSnapshotArtifactError,
6    IcSnapshotMetadataReply, MAX_IC_SNAPSHOT_METADATA_BYTES, Mode, OFlags, REGIONS,
7    check_closed_tree, check_directory_identity, checksum_relative_files, hex_bytes,
8    open_directory, unix_fs,
9};
10use crate::{
11    model::{
12        ic_snapshot_data::MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES, operation_plan::OperationPlanRecord,
13    },
14    ops::{
15        artifacts::ArtifactError,
16        persistence::{DownloadIntegrityError, read_operation_plan},
17    },
18    policy::download_integrity::validate,
19};
20use ic_host_artifacts::artifact::{ArtifactError as InputError, hash_reader};
21use std::time::Instant;
22use std::{
23    io::{self, Read},
24    os::unix::fs::MetadataExt,
25};
26
27impl DownloadJournalGuard<'_> {
28    /// Explicitly verify one published IC tree against exact original metadata and intent.
29    ///
30    /// Requires the retained full plan, unchanged held journal and complete Durable
31    /// selected set. Only this target's artifact bytes are read. The fixed format,
32    /// original metadata/request hashes, region lengths, known bounded chunk hashes
33    /// and closed direct-child tree must match the retained checksum. No metadata
34    /// defaults, generic-token/raw-ID inference or progress reconstruction occurs.
35    ///
36    /// Reads use no-follow descriptors and a bounded checksum buffer; journal and
37    /// directory identities are rechecked at closing. These sequential observations
38    /// cannot fence noncooperating writers or hold fresh byte custody after return.
39    /// The returned checksum is passive local evidence. Integrations still qualify
40    /// authentic complete transfer, token association, fresh permission/accounting,
41    /// stable byte/command custody and upload/load/start safety. Nothing is written,
42    /// spent, settled or released; ordinary resume never invokes this check.
43    ///
44    /// # Errors
45    /// Rejects wrong original evidence, incomplete selection, changed records/custody,
46    /// missing/unsafe/extra children, wrong bounded lengths/hashes and IO failure.
47    pub fn verify_ic_snapshot_artifact(
48        &self,
49        plan: &OperationPlanRecord,
50        snapshot: &str,
51        metadata: &IcSnapshotMetadataReply<'_>,
52    ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
53        let started = Instant::now();
54        let result = (|| {
55            self.admit_ic_artifact_original(plan)?;
56            let entry = self
57                .record
58                .artifact(metadata.request().target(), snapshot)
59                .map_err(super::DownloadJournalError::from)?;
60            if entry.snapshot_taken_at_timestamp() != metadata.metadata().taken_at_timestamp {
61                return Err(IcSnapshotArtifactError::OriginalMismatch);
62            }
63            let expected = entry
64                .checksum()
65                .ok_or(IcSnapshotArtifactError::OriginalMismatch)?;
66            self.check_artifact_parent()?;
67            let parent_path = self.layout.root().join("artifacts");
68            let parent = open_directory(&parent_path)?;
69            let path = self.layout.root().join(entry.artifact_path());
70            let directory = File::from(
71                unix_fs::openat(
72                    &parent,
73                    path.file_name()
74                        .ok_or(IcSnapshotArtifactError::CustodyChanged)?,
75                    OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
76                    Mode::empty(),
77                )
78                .map_err(io::Error::from)?,
79            );
80            checksum_ic_tree(&directory, metadata)?.verify(expected.hash())?;
81            check_directory_identity(&parent_path, &parent)?;
82            check_directory_identity(&path, &directory)?;
83            self.admit_ic_artifact_original(plan)?;
84            Ok(expected.clone())
85        })();
86        self.record_ic_snapshot_metrics(
87            LocalOperation::Verification,
88            started,
89            result.is_ok(),
90            None,
91        );
92        result
93    }
94
95    fn admit_ic_artifact_original(
96        &self,
97        plan: &OperationPlanRecord,
98    ) -> Result<(), DownloadIntegrityError> {
99        self.check_usable()?;
100        read_operation_plan(self.layout, &plan.digest())?;
101        self.require_unchanged_integrity_journal()?;
102        validate(plan, &self.record)?;
103        Ok(())
104    }
105}
106
107fn checksum_ic_tree(
108    directory: &File,
109    metadata: &IcSnapshotMetadataReply<'_>,
110) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
111    let mut checksums = vec![
112        ("format".into(), ArtifactChecksumRecord::from_bytes(FORMAT)),
113        (
114            "metadata.candid".into(),
115            metadata.payload_checksum().clone(),
116        ),
117        (
118            "metadata-arguments.candid".into(),
119            ArtifactChecksumRecord::from_bytes(metadata.request().arguments()),
120        ),
121    ];
122    let values = metadata.metadata();
123    let sizes = [
124        values.wasm_module_size,
125        values.wasm_memory_size,
126        values.stable_memory_size,
127    ];
128    // Empty expected digests here name the closed tree; the actual region hashes
129    // below are admitted by the existing retained whole-tree checksum owner.
130    let empty = ArtifactChecksumRecord::from_bytes(&[]);
131    checksums.extend(REGIONS.map(|name| (name.into(), empty.clone())));
132    for chunk in &values.wasm_chunk_store {
133        checksums.push((
134            format!("chunk-{}.bin", hex_bytes(&chunk.hash)).into(),
135            ArtifactChecksumRecord::from_digest(
136                chunk
137                    .hash
138                    .as_slice()
139                    .try_into()
140                    .map_err(|_| IcSnapshotArtifactError::OriginalMismatch)?,
141            ),
142        ));
143    }
144    check_closed_tree(directory, &checksums)?;
145    for (index, (name, checksum)) in checksums.iter_mut().enumerate() {
146        let (length, maximum) = match index {
147            0 => (Some(FORMAT.len() as u64), FORMAT.len() as u64),
148            1 => (None, MAX_IC_SNAPSHOT_METADATA_BYTES as u64),
149            2 => {
150                let length = metadata.request().arguments().len() as u64;
151                (Some(length), length)
152            }
153            3..=5 => (Some(sizes[index - 3]), sizes[index - 3]),
154            _ => (None, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES as u64),
155        };
156        let actual = checksum_child(
157            directory,
158            name.to_str()
159                .ok_or(IcSnapshotArtifactError::UnexpectedEntry)?,
160            length,
161            maximum,
162        )?;
163        if !(3..=5).contains(&index) {
164            actual.verify(checksum.hash())?;
165        }
166        *checksum = actual;
167    }
168    check_closed_tree(directory, &checksums)?;
169    checksum_relative_files(checksums)
170        .map_err(ArtifactError::from)
171        .map_err(IcSnapshotArtifactError::from)
172}
173
174fn checksum_child(
175    directory: &File,
176    name: &str,
177    length: Option<u64>,
178    maximum: u64,
179) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
180    let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
181    let (mut file, original) = open_regular_child(directory, name, length, maximum)?;
182    let checksum = checksum_exact_reader(&mut file, original.len())?;
183    let held = file.metadata()?;
184    let current = File::from(
185        unix_fs::openat(directory, name, flags, Mode::empty()).map_err(io::Error::from)?,
186    )
187    .metadata()?;
188    if !current.is_file()
189        || original.dev() != current.dev()
190        || original.ino() != current.ino()
191        || original.len() != current.len()
192        || original.len() != held.len()
193    {
194        return Err(IcSnapshotArtifactError::CustodyChanged);
195    }
196    Ok(checksum)
197}
198
199fn checksum_exact_reader(
200    reader: impl Read,
201    length: u64,
202) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
203    // The shared stream owner probes at most one excess byte. Exact length remains
204    // a local artifact requirement, including rejection of premature EOF.
205    let identity = hash_reader(reader, length).map_err(|error| match error {
206        InputError::LimitExceeded { .. } => IcSnapshotArtifactError::FileShape,
207        error => ArtifactError::Io(error.into()).into(),
208    })?;
209    if identity.bytes != length {
210        return Err(IcSnapshotArtifactError::FileShape);
211    }
212    Ok(ArtifactChecksumRecord::from_digest(
213        *identity.sha256.as_bytes(),
214    ))
215}
216
217pub(super) fn open_regular_child(
218    directory: &File,
219    name: &str,
220    length: Option<u64>,
221    maximum: u64,
222) -> Result<(File, std::fs::Metadata), IcSnapshotArtifactError> {
223    let file = File::from(
224        unix_fs::openat(
225            directory,
226            name,
227            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
228            Mode::empty(),
229        )
230        .map_err(io::Error::from)?,
231    );
232    let metadata = file.metadata()?;
233    if !metadata.is_file()
234        || metadata.len() > maximum
235        || length.is_some_and(|length| length != metadata.len())
236    {
237        return Err(IcSnapshotArtifactError::FileShape);
238    }
239    Ok((file, metadata))
240}
241
242#[cfg(test)]
243mod tests;