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