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