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