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 extents;
4mod kind;
5mod wire;
6pub(crate) use extents::{ExtentPlanningError, planned_extents};
7pub(crate) use kind::DataExtent;
8
9use super::{
10    artifacts::ArtifactChecksumRecord,
11    ic_request::{IcRequestError, encode_arguments, management_request_digest},
12    ic_snapshot_metadata::IcSnapshotMetadataReply,
13};
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    arguments: Vec<u8>,
32}
33
34impl<'metadata> IcSnapshotDataRequest<'metadata> {
35    /// Encode one bounded nonempty range or exact known chunk-store identity.
36    ///
37    /// # Errors
38    /// Rejects zero/oversized ranges, overflow or ranges beyond declared metadata,
39    /// malformed/unknown chunk hashes and existing argument encoding/size failures.
40    pub fn new(
41        metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
42        kind: SnapshotDataKind,
43    ) -> Result<Self, IcSnapshotDataError> {
44        validate_kind(metadata, &kind)?;
45        let target = metadata.request().principal();
46        let arguments = encode_arguments(ReadCanisterSnapshotDataArgs {
47            canister_id: target.into(),
48            snapshot_id: metadata.request().snapshot_id().to_vec(),
49            kind: kind.clone(),
50        })?;
51        Ok(Self {
52            metadata,
53            kind,
54            arguments,
55        })
56    }
57
58    /// Read the retained metadata evidence used for range/hash admission.
59    #[must_use]
60    pub const fn metadata(&self) -> &'metadata IcSnapshotMetadataReply<'metadata> {
61        self.metadata
62    }
63
64    /// Read the exact admitted upstream range or chunk identity.
65    #[must_use]
66    pub const fn kind(&self) -> &SnapshotDataKind {
67        &self.kind
68    }
69
70    pub(crate) fn matches_kind(&self, original: &SnapshotDataKind) -> bool {
71        DataExtent::from(&self.kind) == DataExtent::from(original)
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(
111            self.metadata.request().principal().as_slice(),
112            self.method(),
113            &self.arguments,
114        )
115    }
116}
117
118pub(crate) fn validate_kind(
119    metadata: &IcSnapshotMetadataReply<'_>,
120    kind: &SnapshotDataKind,
121) -> Result<(), IcSnapshotDataError> {
122    match DataExtent::from(kind) {
123        DataExtent::Region {
124            index,
125            offset,
126            size,
127        } => {
128            range(offset, size, metadata.region_sizes()[index])?;
129        }
130        DataExtent::Chunk { hash } => {
131            if hash.len() != 32 {
132                return Err(IcSnapshotDataError::InvalidChunkHash);
133            }
134            if metadata.chunk_index(hash).is_none() {
135                return Err(IcSnapshotDataError::ChunkNotInMetadata);
136            }
137        }
138    }
139    Ok(())
140}
141
142fn range(offset: u64, size: u64, total: u64) -> Result<(), IcSnapshotDataError> {
143    let admitted_size = usize::try_from(size)
144        .is_ok_and(|size| (1..=MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES).contains(&size));
145    if !admitted_size {
146        return Err(IcSnapshotDataError::InvalidRangeSize);
147    }
148    if offset.checked_add(size).is_none_or(|end| end > total) {
149        return Err(IcSnapshotDataError::RangeOutsideMetadata);
150    }
151    Ok(())
152}
153
154impl fmt::Debug for IcSnapshotDataRequest<'_> {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("IcSnapshotDataRequest")
157            .field("target", &self.target())
158            .field("argument_bytes", &self.arguments.len())
159            .finish_non_exhaustive()
160    }
161}
162
163/// Exact bounded data bytes associated with one declared request and metadata reply.
164///
165/// Range replies must have the exact requested length. Chunk-store replies must
166/// hash to the requested retained identity, including a valid empty chunk. Neither
167/// condition authenticates origin, proves whole-snapshot coverage or settles effects.
168pub struct IcSnapshotDataReply<'request, 'metadata> {
169    request: &'request IcSnapshotDataRequest<'metadata>,
170    chunk: Vec<u8>,
171    chunk_checksum: ArtifactChecksumRecord,
172    payload_checksum: ArtifactChecksumRecord,
173}
174
175impl<'request, 'metadata> IcSnapshotDataReply<'request, 'metadata> {
176    /// Decode exactly one bounded data argument, then admit length/hash association.
177    ///
178    /// # Errors
179    /// Rejects excessive raw/chunk bytes, malformed/unsupported/skipped/extra/trailing
180    /// wire, an inexact range length or bytes with a different chunk-store hash.
181    pub fn decode(
182        request: &'request IcSnapshotDataRequest<'metadata>,
183        bytes: &[u8],
184    ) -> Result<Self, IcSnapshotDataError> {
185        if bytes.len() > MAX_IC_SNAPSHOT_DATA_REPLY_BYTES {
186            return Err(IcSnapshotDataError::ReplyTooLarge);
187        }
188        let chunk = wire::decode(bytes)?;
189        let chunk_digest = ic_host_artifacts::artifact::Sha256Digest::compute(&chunk);
190        let chunk_checksum = ArtifactChecksumRecord::from_digest(*chunk_digest.as_bytes());
191        match DataExtent::from(request.kind()) {
192            DataExtent::Region { size, .. } => {
193                if u64::try_from(chunk.len()).ok() != Some(size) {
194                    return Err(IcSnapshotDataError::LengthMismatch);
195                }
196            }
197            DataExtent::Chunk { hash } => {
198                if chunk_digest.as_bytes().as_slice() != hash {
199                    return Err(IcSnapshotDataError::ChunkHashMismatch);
200                }
201            }
202        }
203        Ok(Self {
204            request,
205            chunk,
206            chunk_checksum,
207            payload_checksum: ArtifactChecksumRecord::from_bytes(bytes),
208        })
209    }
210
211    /// Read the exact request and retained metadata association.
212    #[must_use]
213    pub const fn request(&self) -> &'request IcSnapshotDataRequest<'metadata> {
214        self.request
215    }
216
217    /// Read exact admitted data bytes, with no mutable access or persistence side effect.
218    #[must_use]
219    pub fn chunk(&self) -> &[u8] {
220        &self.chunk
221    }
222
223    /// Read SHA-256 of actual data, distinct from raw Candid wire identity.
224    #[must_use]
225    pub const fn chunk_checksum(&self) -> &ArtifactChecksumRecord {
226        &self.chunk_checksum
227    }
228
229    /// Read SHA-256 of exact raw reply bytes.
230    #[must_use]
231    pub const fn payload_checksum(&self) -> &ArtifactChecksumRecord {
232        &self.payload_checksum
233    }
234
235    /// Bind exact retained metadata, request and raw reply hashes in the data domain.
236    #[must_use]
237    pub fn digest(&self) -> ArtifactChecksumRecord {
238        super::evidence_digest::reply(
239            b"ic-backup/ic-snapshot-data-reply/v1\0",
240            [
241                &self.request.metadata().digest(),
242                &self.request.digest(),
243                &self.payload_checksum,
244            ],
245        )
246    }
247}
248
249impl fmt::Debug for IcSnapshotDataReply<'_, '_> {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        f.debug_struct("IcSnapshotDataReply")
252            .field("request", &self.request)
253            .field("chunk_bytes", &self.chunk.len())
254            .field("payload_checksum", &self.payload_checksum)
255            .finish_non_exhaustive()
256    }
257}
258
259/// Typed metadata/range/data admission failures, without raw payload diagnostics.
260#[derive(Debug, Error, Eq, PartialEq)]
261pub enum IcSnapshotDataError {
262    /// Existing canonical IC argument boundary rejected the request.
263    #[error(transparent)]
264    Request(#[from] IcRequestError),
265    /// Range size is zero or greater than the local single-call bound.
266    #[error("snapshot range size must be 1..={MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES}")]
267    InvalidRangeSize,
268    /// Checked offset plus size exceeds the retained region or overflows nat64.
269    #[error("snapshot range is outside retained metadata")]
270    RangeOutsideMetadata,
271    /// Chunk identity is not exactly 32 SHA-256 bytes.
272    #[error("snapshot chunk identity must be 32 bytes")]
273    InvalidChunkHash,
274    /// The exact chunk identity is absent from retained metadata.
275    #[error("snapshot chunk identity is absent from retained metadata")]
276    ChunkNotInMetadata,
277    /// Raw wire input exceeded its bound before parsing.
278    #[error("snapshot data reply exceeds {MAX_IC_SNAPSHOT_DATA_REPLY_BYTES} bytes")]
279    ReplyTooLarge,
280    /// Wire shape, decoder work, header or actual chunk bounds failed.
281    #[error("invalid or unsupported snapshot data reply")]
282    InvalidReply,
283    /// The decoded range is shorter or longer than the exact request.
284    #[error("snapshot data length differs from the requested range")]
285    LengthMismatch,
286    /// Actual chunk bytes do not hash to the requested retained identity.
287    #[error("snapshot data differs from the requested chunk hash")]
288    ChunkHashMismatch,
289}
290
291#[cfg(test)]
292mod tests;