Skip to main content

ic_backup/model/ic_snapshot_data/
mod.rs

1//! Metadata-bound snapshot data codecs, without dispatch or transfer completion.
2
3mod extents;
4mod wire;
5pub(crate) use extents::{ExtentPlanningError, planned_extents};
6
7use super::{
8    artifacts::ArtifactChecksumRecord,
9    ic_request::{IcRequestError, MAX_IC_ARGUMENT_BYTES, management_request_digest},
10    ic_snapshot_metadata::IcSnapshotMetadataReply,
11};
12use candid::Principal;
13use ic_management_canister_types::{ReadCanisterSnapshotDataArgs, SnapshotDataKind};
14use std::fmt;
15use thiserror::Error;
16
17/// Maximum requested/decoded data bytes per call; no aggregate allowance is granted.
18pub const MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES: usize = 1024 * 1024;
19/// Maximum raw data reply bytes, allowing wire overhead around a 1 MiB chunk.
20pub const MAX_IC_SNAPSHOT_DATA_REPLY_BYTES: usize = 2 * 1024 * 1024;
21
22/// Exact ephemeral read for one range or chunk in retained snapshot metadata.
23///
24/// The borrowed metadata owns the target/raw ID, declared sizes and chunk set.
25/// Construction validates bytes only, without authenticating that metadata or
26/// granting a reservation, fresh read access, signing, dispatch or repeat call.
27pub struct IcSnapshotDataRequest<'metadata> {
28    metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
29    kind: SnapshotDataKind,
30    target_bytes: Vec<u8>,
31    arguments: Vec<u8>,
32}
33
34impl<'metadata> IcSnapshotDataRequest<'metadata> {
35    /// Encode one bounded nonempty range or exact known chunk-store identity.
36    ///
37    /// # Errors
38    /// Rejects zero/oversized ranges, overflow or ranges beyond declared metadata,
39    /// malformed/unknown chunk hashes and existing argument encoding/size failures.
40    pub fn new(
41        metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
42        kind: SnapshotDataKind,
43    ) -> Result<Self, IcSnapshotDataError> {
44        validate_kind(metadata, &kind)?;
45        let target = Principal::from_text(metadata.request().target())
46            .map_err(|_| IcRequestError::InvalidTarget)?;
47        let arguments = candid::encode_one(ReadCanisterSnapshotDataArgs {
48            canister_id: target,
49            snapshot_id: metadata.request().snapshot_id().to_vec(),
50            kind: kind.clone(),
51        })
52        .map_err(|error| IcRequestError::Encoding(error.to_string()))?;
53        if arguments.len() > MAX_IC_ARGUMENT_BYTES {
54            return Err(IcRequestError::ArgumentsTooLarge.into());
55        }
56        Ok(Self {
57            metadata,
58            kind,
59            target_bytes: target.as_slice().to_vec(),
60            arguments,
61        })
62    }
63
64    /// Read the retained metadata evidence used for range/hash admission.
65    #[must_use]
66    pub const fn metadata(&self) -> &'metadata IcSnapshotMetadataReply<'metadata> {
67        self.metadata
68    }
69
70    /// Read the exact admitted upstream range or chunk identity.
71    #[must_use]
72    pub const fn kind(&self) -> &SnapshotDataKind {
73        &self.kind
74    }
75
76    pub(crate) fn matches_kind(&self, original: &SnapshotDataKind) -> bool {
77        use SnapshotDataKind::{StableMemory, WasmChunk, WasmMemory, WasmModule};
78        match (&self.kind, original) {
79            (WasmModule { offset: a, size: b }, WasmModule { offset: c, size: d })
80            | (WasmMemory { offset: a, size: b }, WasmMemory { offset: c, size: d })
81            | (StableMemory { offset: a, size: b }, StableMemory { offset: c, size: d }) => {
82                a == c && b == d
83            }
84            (WasmChunk { hash: a }, WasmChunk { hash: b }) => a == b,
85            _ => false,
86        }
87    }
88
89    /// Read the canonical effective routing target, also encoded in the arguments.
90    #[must_use]
91    pub fn target(&self) -> &str {
92        self.metadata.request().target()
93    }
94
95    /// Read the exact raw snapshot identity, distinct from generic backend tokens.
96    #[must_use]
97    pub fn snapshot_id(&self) -> &[u8] {
98        self.metadata.request().snapshot_id()
99    }
100
101    /// Read exact Candid arguments, without a transport or execution permit.
102    #[must_use]
103    pub fn arguments(&self) -> &[u8] {
104        &self.arguments
105    }
106
107    /// Read the management receiver, separate from effective routing.
108    #[must_use]
109    pub const fn receiver(&self) -> &'static str {
110        "aaaaa-aa"
111    }
112
113    /// Read the sole data-read method, using replicated host update ingress.
114    #[must_use]
115    pub const fn method(&self) -> &'static str {
116        "read_canister_snapshot_data"
117    }
118
119    /// Hash exact wire identity with the existing management payload owner.
120    ///
121    /// Metadata evidence is excluded from this nonrecursive request hash and
122    /// separately included in the reply evidence digest. Spending stays external.
123    #[must_use]
124    pub fn digest(&self) -> ArtifactChecksumRecord {
125        management_request_digest(&self.target_bytes, self.method(), &self.arguments)
126    }
127}
128
129pub(crate) fn validate_kind(
130    metadata: &IcSnapshotMetadataReply<'_>,
131    kind: &SnapshotDataKind,
132) -> Result<(), IcSnapshotDataError> {
133    let values = metadata.metadata();
134    match kind {
135        SnapshotDataKind::WasmModule { offset, size } => {
136            range(*offset, *size, values.wasm_module_size)?;
137        }
138        SnapshotDataKind::WasmMemory { offset, size } => {
139            range(*offset, *size, values.wasm_memory_size)?;
140        }
141        SnapshotDataKind::StableMemory { offset, size } => {
142            range(*offset, *size, values.stable_memory_size)?;
143        }
144        SnapshotDataKind::WasmChunk { hash } => {
145            if hash.len() != 32 {
146                return Err(IcSnapshotDataError::InvalidChunkHash);
147            }
148            if !values
149                .wasm_chunk_store
150                .iter()
151                .any(|chunk| chunk.hash == *hash)
152            {
153                return Err(IcSnapshotDataError::ChunkNotInMetadata);
154            }
155        }
156    }
157    Ok(())
158}
159
160fn range(offset: u64, size: u64, total: u64) -> Result<(), IcSnapshotDataError> {
161    let admitted_size = usize::try_from(size)
162        .is_ok_and(|size| (1..=MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES).contains(&size));
163    if !admitted_size {
164        return Err(IcSnapshotDataError::InvalidRangeSize);
165    }
166    if offset.checked_add(size).is_none_or(|end| end > total) {
167        return Err(IcSnapshotDataError::RangeOutsideMetadata);
168    }
169    Ok(())
170}
171
172impl fmt::Debug for IcSnapshotDataRequest<'_> {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.debug_struct("IcSnapshotDataRequest")
175            .field("target", &self.target())
176            .field("argument_bytes", &self.arguments.len())
177            .finish_non_exhaustive()
178    }
179}
180
181/// Exact bounded data bytes associated with one declared request and metadata reply.
182///
183/// Range replies must have the exact requested length. Chunk-store replies must
184/// hash to the requested retained identity, including a valid empty chunk. Neither
185/// condition authenticates origin, proves whole-snapshot coverage or settles effects.
186pub struct IcSnapshotDataReply<'request, 'metadata> {
187    request: &'request IcSnapshotDataRequest<'metadata>,
188    chunk: Vec<u8>,
189    chunk_checksum: ArtifactChecksumRecord,
190    payload_checksum: ArtifactChecksumRecord,
191}
192
193impl<'request, 'metadata> IcSnapshotDataReply<'request, 'metadata> {
194    /// Decode exactly one bounded data argument, then admit length/hash association.
195    ///
196    /// # Errors
197    /// Rejects excessive raw/chunk bytes, malformed/unsupported/skipped/extra/trailing
198    /// wire, an inexact range length or bytes with a different chunk-store hash.
199    pub fn decode(
200        request: &'request IcSnapshotDataRequest<'metadata>,
201        bytes: &[u8],
202    ) -> Result<Self, IcSnapshotDataError> {
203        if bytes.len() > MAX_IC_SNAPSHOT_DATA_REPLY_BYTES {
204            return Err(IcSnapshotDataError::ReplyTooLarge);
205        }
206        let chunk = wire::decode(bytes)?;
207        let chunk_checksum = ArtifactChecksumRecord::from_bytes(&chunk);
208        match request.kind() {
209            SnapshotDataKind::WasmModule { size, .. }
210            | SnapshotDataKind::WasmMemory { size, .. }
211            | SnapshotDataKind::StableMemory { size, .. } => {
212                if u64::try_from(chunk.len()).ok() != Some(*size) {
213                    return Err(IcSnapshotDataError::LengthMismatch);
214                }
215            }
216            SnapshotDataKind::WasmChunk { hash } => {
217                if chunk_checksum.hash() != crate::hash::hex_bytes(hash) {
218                    return Err(IcSnapshotDataError::ChunkHashMismatch);
219                }
220            }
221        }
222        Ok(Self {
223            request,
224            chunk,
225            chunk_checksum,
226            payload_checksum: ArtifactChecksumRecord::from_bytes(bytes),
227        })
228    }
229
230    /// Read the exact request and retained metadata association.
231    #[must_use]
232    pub const fn request(&self) -> &'request IcSnapshotDataRequest<'metadata> {
233        self.request
234    }
235
236    /// Read exact admitted data bytes, with no mutable access or persistence side effect.
237    #[must_use]
238    pub fn chunk(&self) -> &[u8] {
239        &self.chunk
240    }
241
242    /// Read SHA-256 of actual data, distinct from raw Candid wire identity.
243    #[must_use]
244    pub const fn chunk_checksum(&self) -> &ArtifactChecksumRecord {
245        &self.chunk_checksum
246    }
247
248    /// Read SHA-256 of exact raw reply bytes.
249    #[must_use]
250    pub const fn payload_checksum(&self) -> &ArtifactChecksumRecord {
251        &self.payload_checksum
252    }
253
254    /// Bind exact retained metadata, request and raw reply hashes in the data domain.
255    #[must_use]
256    pub fn digest(&self) -> ArtifactChecksumRecord {
257        let mut bytes = b"ic-backup/ic-snapshot-data-reply/v1\0".to_vec();
258        bytes.extend_from_slice(self.request.metadata().digest().hash().as_bytes());
259        bytes.extend_from_slice(self.request.digest().hash().as_bytes());
260        bytes.extend_from_slice(self.payload_checksum.hash().as_bytes());
261        ArtifactChecksumRecord::from_bytes(&bytes)
262    }
263}
264
265impl fmt::Debug for IcSnapshotDataReply<'_, '_> {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.debug_struct("IcSnapshotDataReply")
268            .field("request", &self.request)
269            .field("chunk_bytes", &self.chunk.len())
270            .field("payload_checksum", &self.payload_checksum)
271            .finish_non_exhaustive()
272    }
273}
274
275/// Typed metadata/range/data admission failures, without raw payload diagnostics.
276#[derive(Debug, Error, Eq, PartialEq)]
277pub enum IcSnapshotDataError {
278    /// Existing canonical IC argument boundary rejected the request.
279    #[error(transparent)]
280    Request(#[from] IcRequestError),
281    /// Range size is zero or greater than the local single-call bound.
282    #[error("snapshot range size must be 1..={MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES}")]
283    InvalidRangeSize,
284    /// Checked offset plus size exceeds the retained region or overflows nat64.
285    #[error("snapshot range is outside retained metadata")]
286    RangeOutsideMetadata,
287    /// Chunk identity is not exactly 32 SHA-256 bytes.
288    #[error("snapshot chunk identity must be 32 bytes")]
289    InvalidChunkHash,
290    /// The exact chunk identity is absent from retained metadata.
291    #[error("snapshot chunk identity is absent from retained metadata")]
292    ChunkNotInMetadata,
293    /// Raw wire input exceeded its bound before parsing.
294    #[error("snapshot data reply exceeds {MAX_IC_SNAPSHOT_DATA_REPLY_BYTES} bytes")]
295    ReplyTooLarge,
296    /// Wire shape, decoder work, header or actual chunk bounds failed.
297    #[error("invalid or unsupported snapshot data reply")]
298    InvalidReply,
299    /// The decoded range is shorter or longer than the exact request.
300    #[error("snapshot data length differs from the requested range")]
301    LengthMismatch,
302    /// Actual chunk bytes do not hash to the requested retained identity.
303    #[error("snapshot data differs from the requested chunk hash")]
304    ChunkHashMismatch,
305}
306
307#[cfg(test)]
308mod tests;