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::checksum_reader,
16        persistence::{DownloadIntegrityError, read_operation_plan},
17    },
18    policy::download_integrity::validate,
19};
20use std::time::Instant;
21use std::{io::Read, os::unix::fs::MetadataExt};
22
23impl DownloadJournalGuard<'_> {
24    /// Explicitly verify one published IC tree against exact original metadata and intent.
25    ///
26    /// Requires the retained full plan, unchanged held journal and complete Durable
27    /// selected set. Only this target's artifact bytes are read. The fixed format,
28    /// original metadata/request hashes, region lengths, known bounded chunk hashes
29    /// and closed direct-child tree must match the retained checksum. No metadata
30    /// defaults, generic-token/raw-ID inference or progress reconstruction occurs.
31    ///
32    /// Reads use no-follow descriptors and a bounded checksum buffer; journal and
33    /// directory identities are rechecked at closing. These sequential observations
34    /// cannot fence noncooperating writers or hold fresh byte custody after return.
35    /// The returned checksum is passive local evidence. Integrations still qualify
36    /// authentic complete transfer, token association, fresh permission/accounting,
37    /// stable byte/command custody and upload/load/start safety. Nothing is written,
38    /// spent, settled or released; ordinary resume never invokes this check.
39    ///
40    /// # Errors
41    /// Rejects wrong original evidence, incomplete selection, changed records/custody,
42    /// missing/unsafe/extra children, wrong bounded lengths/hashes and IO failure.
43    pub fn verify_ic_snapshot_artifact(
44        &self,
45        plan: &OperationPlanRecord,
46        snapshot: &str,
47        metadata: &IcSnapshotMetadataReply<'_>,
48    ) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
49        let started = Instant::now();
50        let result = (|| {
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(&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(errno_to_io)?,
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        self.record_ic_snapshot_metrics(
83            LocalOperation::Verification,
84            started,
85            result.is_ok(),
86            None,
87        );
88        result
89    }
90
91    fn admit_ic_artifact_original(
92        &self,
93        plan: &OperationPlanRecord,
94    ) -> Result<(), DownloadIntegrityError> {
95        self.check_usable()?;
96        read_operation_plan(self.layout, &plan.digest())?;
97        self.require_unchanged_integrity_journal()?;
98        validate(plan, &self.record)?;
99        Ok(())
100    }
101}
102
103fn checksum_ic_tree(
104    directory: &File,
105    metadata: &IcSnapshotMetadataReply<'_>,
106) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
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        checksums.push((
130            format!("chunk-{}.bin", hex_bytes(&chunk.hash)).into(),
131            ArtifactChecksumRecord::from_digest(
132                chunk
133                    .hash
134                    .as_slice()
135                    .try_into()
136                    .map_err(|_| IcSnapshotArtifactError::OriginalMismatch)?,
137            ),
138        ));
139    }
140    check_closed_tree(directory, &checksums)?;
141    for (index, (name, checksum)) in checksums.iter_mut().enumerate() {
142        let (length, maximum) = match index {
143            0 => (Some(FORMAT.len() as u64), FORMAT.len() as u64),
144            1 => (None, MAX_IC_SNAPSHOT_METADATA_BYTES as u64),
145            2 => {
146                let length = metadata.request().arguments().len() as u64;
147                (Some(length), length)
148            }
149            3..=5 => (Some(sizes[index - 3]), sizes[index - 3]),
150            _ => (None, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES as u64),
151        };
152        let actual = checksum_child(
153            directory,
154            name.to_str()
155                .ok_or(IcSnapshotArtifactError::UnexpectedEntry)?,
156            length,
157            maximum,
158        )?;
159        if !(3..=5).contains(&index) {
160            actual.verify(checksum.hash())?;
161        }
162        *checksum = actual;
163    }
164    check_closed_tree(directory, &checksums)?;
165    Ok(checksum_relative_files(checksums))
166}
167
168fn checksum_child(
169    directory: &File,
170    name: &str,
171    length: Option<u64>,
172    maximum: u64,
173) -> Result<ArtifactChecksumRecord, IcSnapshotArtifactError> {
174    let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
175    let (mut file, original) = open_regular_child(directory, name, length, maximum)?;
176    // Read at most the observed length plus one, detecting shrinking/growing files
177    // without an unbounded read even if a noncooperating writer changes the file.
178    let limit = original
179        .len()
180        .checked_add(1)
181        .ok_or(IcSnapshotArtifactError::FileShape)?;
182    let mut bounded = (&mut file).take(limit);
183    let checksum = checksum_reader(&mut bounded)?;
184    if bounded.limit() != 1 {
185        return Err(IcSnapshotArtifactError::FileShape);
186    }
187    let held = file.metadata()?;
188    let current =
189        File::from(unix_fs::openat(directory, name, flags, Mode::empty()).map_err(errno_to_io)?)
190            .metadata()?;
191    if !current.is_file()
192        || original.dev() != current.dev()
193        || original.ino() != current.ino()
194        || original.len() != current.len()
195        || original.len() != held.len()
196    {
197        return Err(IcSnapshotArtifactError::CustodyChanged);
198    }
199    Ok(checksum)
200}
201
202pub(super) fn open_regular_child(
203    directory: &File,
204    name: &str,
205    length: Option<u64>,
206    maximum: u64,
207) -> Result<(File, std::fs::Metadata), IcSnapshotArtifactError> {
208    let file = File::from(
209        unix_fs::openat(
210            directory,
211            name,
212            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
213            Mode::empty(),
214        )
215        .map_err(errno_to_io)?,
216    );
217    let metadata = file.metadata()?;
218    if !metadata.is_file()
219        || metadata.len() > maximum
220        || length.is_some_and(|length| length != metadata.len())
221    {
222        return Err(IcSnapshotArtifactError::FileShape);
223    }
224    Ok((file, metadata))
225}