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    let mut checksums = vec![
100        ("format".into(), ArtifactChecksumRecord::from_bytes(FORMAT)),
101        (
102            "metadata.candid".into(),
103            metadata.payload_checksum().clone(),
104        ),
105        (
106            "metadata-arguments.candid".into(),
107            ArtifactChecksumRecord::from_bytes(metadata.request().arguments()),
108        ),
109    ];
110    let values = metadata.metadata();
111    let sizes = [
112        values.wasm_module_size,
113        values.wasm_memory_size,
114        values.stable_memory_size,
115    ];
116    // Empty expected digests here name the closed tree; the actual region hashes
117    // below are admitted by the existing retained whole-tree checksum owner.
118    let empty = ArtifactChecksumRecord::from_bytes(&[]);
119    checksums.extend(REGIONS.map(|name| (name.into(), empty.clone())));
120    for chunk in &values.wasm_chunk_store {
121        let digest = ic_host_artifacts::artifact::Sha256Digest::from_bytes(
122            chunk
123                .hash
124                .as_slice()
125                .try_into()
126                .map_err(|_| IcSnapshotArtifactError::OriginalMismatch)?,
127        );
128        checksums.push((
129            member::chunk_name(digest).into(),
130            ArtifactChecksumRecord::from_digest(*digest.as_bytes()),
131        ));
132    }
133    check_closed_tree(directory, &checksums)?;
134    for (index, (name, checksum)) in checksums.iter_mut().enumerate() {
135        let (length, maximum) = match index {
136            0 => (Some(FORMAT.len() as u64), FORMAT.len() as u64),
137            1 => (None, MAX_IC_SNAPSHOT_METADATA_BYTES as u64),
138            2 => {
139                let length = metadata.request().arguments().len() as u64;
140                (Some(length), length)
141            }
142            3..=5 => (Some(sizes[index - 3]), sizes[index - 3]),
143            _ => (None, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES as u64),
144        };
145        let actual = checksum_child(
146            directory,
147            name.to_str()
148                .ok_or(IcSnapshotArtifactError::UnexpectedEntry)?,
149            length,
150            maximum,
151        )?;
152        if !(3..=5).contains(&index) {
153            actual.verify(checksum.hash())?;
154        }
155        *checksum = actual;
156    }
157    check_closed_tree(directory, &checksums)?;
158    checksum_relative_files(checksums)
159        .map_err(ArtifactError::from)
160        .map_err(IcSnapshotArtifactError::from)
161}
162
163fn checksum_child(
164    directory: &File,
165    name: &str,
166    length: Option<u64>,
167    maximum: u64,
168) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
169    let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
170    let (mut file, original) = open_regular_child(directory, name, length, maximum)?;
171    let checksum = checksum_exact_reader(&mut file, original.len())?;
172    let held = file.metadata()?;
173    let current = File::from(
174        unix_fs::openat(directory, name, flags, Mode::empty()).map_err(io::Error::from)?,
175    )
176    .metadata()?;
177    if !current.is_file()
178        || original.dev() != current.dev()
179        || original.ino() != current.ino()
180        || original.len() != current.len()
181        || original.len() != held.len()
182    {
183        return Err(IcSnapshotArtifactError::CustodyChanged);
184    }
185    Ok(checksum)
186}
187
188fn checksum_exact_reader(
189    reader: impl Read,
190    length: u64,
191) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
192    let identity = hash_exact_reader(reader, length).map_err(|error| match error {
193        InputError::LimitExceeded { .. } | InputError::LengthMismatch { .. } => {
194            IcSnapshotArtifactError::FileShape
195        }
196        error => ArtifactError::Io(error.into()).into(),
197    })?;
198    Ok(ArtifactChecksumRecord::from_digest(
199        *identity.sha256.as_bytes(),
200    ))
201}
202
203pub(super) fn open_regular_child(
204    directory: &File,
205    name: &str,
206    length: Option<u64>,
207    maximum: u64,
208) -> Result<(File, std::fs::Metadata), IcSnapshotArtifactError> {
209    let file = File::from(
210        unix_fs::openat(
211            directory,
212            name,
213            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
214            Mode::empty(),
215        )
216        .map_err(io::Error::from)?,
217    );
218    let metadata = file.metadata()?;
219    if !metadata.is_file()
220        || metadata.len() > maximum
221        || length.is_some_and(|length| length != metadata.len())
222    {
223        return Err(IcSnapshotArtifactError::FileShape);
224    }
225    Ok((file, metadata))
226}
227
228#[cfg(test)]
229mod tests;