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