Skip to main content

ic_backup/model/ic_snapshot_data/
mod.rs

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