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