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