Skip to main content

ic_backup/model/ic_snapshot_upload/
mod.rs

1//! Exact same-target snapshot upload codecs and passive original-reservation binding.
2
3use crate::model::evidence_digest::EvidenceDigest;
4use ic_platform::candid::contract;
5mod attempt;
6mod plan;
7mod reply;
8pub(crate) use attempt::original_authority;
9pub use attempt::{IcSnapshotUploadAttempt, IcSnapshotUploadAttemptError};
10pub use plan::{IcSnapshotDataUploadPlan, IcSnapshotDataUploadPlanningError};
11pub use reply::{IcSnapshotUploadReply, IcSnapshotUploadReplyKind};
12
13use super::{
14    artifacts::{ArtifactChecksumRecord, ChecksumError},
15    ic_request::{IcRequestError, MAX_IC_SNAPSHOT_ID_BYTES, management_request_digest},
16    ic_snapshot_data::{IcSnapshotDataError, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES, validate_kind},
17    ic_snapshot_metadata::IcSnapshotMetadataReply,
18    operation_plan::OperationPlanRecord,
19};
20use ic_platform::Principal;
21use ic_platform::management_canister_types::{
22    SnapshotDataKind, SnapshotDataOffset, UploadCanisterSnapshotDataArgs,
23    UploadCanisterSnapshotMetadataArgs,
24};
25use std::fmt;
26use thiserror::Error;
27
28/// Maximum upload arguments, including finite Candid overhead around a 1 MiB chunk.
29pub const MAX_IC_SNAPSHOT_UPLOAD_ARGUMENT_BYTES: usize = 2 * 1024 * 1024;
30/// Maximum raw upload reply bytes before finite decoding.
31pub const MAX_IC_SNAPSHOT_UPLOAD_REPLY_BYTES: usize = 4096;
32
33/// Closed upload declaration; each branch retains exact original source association.
34#[derive(Debug)]
35pub enum IcSnapshotUploadKind {
36    /// Allocate a new snapshot with exact representable metadata, without replacement.
37    Metadata,
38    /// Write exact bounded bytes into an independently qualified new upload snapshot.
39    Data {
40        /// Exact destination raw ID, separate from the retained source raw ID/token.
41        snapshot_id: Vec<u8>,
42        /// Original source extent or known hash; chunk hashes are not part of upload wire.
43        source_kind: SnapshotDataKind,
44        /// Hash of the actual bounded chunk bytes encoded in the arguments.
45        chunk_checksum: ArtifactChecksumRecord,
46        /// Exact original metadata-allocation wire request digest.
47        metadata_request: ArtifactChecksumRecord,
48    },
49}
50
51/// Ephemeral exact wire payload with retained source evidence; no dispatch permission.
52///
53/// Pure construction admits declarations only. Guarded persistence operations supply
54/// fresh local byte binding. Integrations qualify authentic source/complete transfer,
55/// new destination attribution, original spending, current controller/custody and
56/// same-release application safety. No serialization, replacement, relocation,
57/// allowance or automatic outcome is introduced.
58pub struct IcSnapshotUploadRequest<'source> {
59    source_plan: &'source OperationPlanRecord,
60    source: &'source IcSnapshotMetadataReply<'source>,
61    source_checksum: ArtifactChecksumRecord,
62    kind: IcSnapshotUploadKind,
63    arguments: Vec<u8>,
64    target_bytes: Vec<u8>,
65}
66
67impl<'source> IcSnapshotUploadRequest<'source> {
68    /// Encode exact uploadable original metadata for the same declared canister.
69    ///
70    /// Globals retain order and bits. Unavailable slots reject; absent timer/hook
71    /// values stay absent and do not default to inactive/ready states. Replacement
72    /// is always absent; this payload has no source-snapshot deletion lane.
73    /// Build these nonrecursive bytes before retaining their original operation plan.
74    /// # Errors
75    /// Rejects unavailable globals, encoding failure and excessive arguments.
76    pub fn metadata(
77        source_plan: &'source OperationPlanRecord,
78        source: &'source IcSnapshotMetadataReply<'source>,
79        source_checksum: &ArtifactChecksumRecord,
80    ) -> Result<Self, IcSnapshotUploadError> {
81        if !source_plan
82            .selected_targets()
83            .iter()
84            .any(|target| target == source.request().target())
85        {
86            return Err(IcSnapshotUploadError::SourceTargetMismatch);
87        }
88        let values = source.metadata();
89        let globals = values
90            .globals
91            .iter()
92            .cloned()
93            .collect::<Option<Vec<_>>>()
94            .ok_or(IcSnapshotUploadError::UnavailableGlobal)?;
95        let target = Principal::from_text(source.request().target())
96            .map_err(|_| IcRequestError::InvalidTarget)?;
97        let arguments = contract::encode_one(UploadCanisterSnapshotMetadataArgs {
98            canister_id: target,
99            replace_snapshot: None,
100            wasm_module_size: values.wasm_module_size,
101            globals,
102            wasm_memory_size: values.wasm_memory_size,
103            stable_memory_size: values.stable_memory_size,
104            certified_data: values.certified_data.clone(),
105            global_timer: values.global_timer.clone(),
106            on_low_wasm_memory_hook_status: values.on_low_wasm_memory_hook_status.clone(),
107        })
108        .map_err(|error| IcRequestError::Encoding(error.to_string()))?;
109        Self::from_arguments(
110            source_plan,
111            source,
112            source_checksum,
113            IcSnapshotUploadKind::Metadata,
114            arguments,
115        )
116    }
117
118    /// Encode one exact source extent/chunk for a separately qualified new raw ID.
119    ///
120    /// Requires the original metadata upload declaration. Regions are nonempty and
121    /// bounded to 1 MiB; an empty known chunk requires its exact SHA-256. Actual
122    /// new snapshot attribution is not inferred from a supplied ID or decoded reply.
123    /// Data requests include their new ID before their own plan/reservation is retained;
124    /// metadata authority never supplies data-call spending or resets prior journals.
125    /// # Errors
126    /// Rejects a non-metadata original, invalid/reused ID, source range/hash or byte
127    /// mismatch, excessive data/arguments and encoding failure.
128    pub fn data(
129        metadata: &Self,
130        snapshot_id: &[u8],
131        source_kind: SnapshotDataKind,
132        chunk: &[u8],
133    ) -> Result<Self, IcSnapshotUploadError> {
134        metadata.validate_data_destination(snapshot_id)?;
135        validate_kind(metadata.source, &source_kind)?;
136        if chunk.len() > MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES {
137            return Err(IcSnapshotUploadError::ChunkTooLarge);
138        }
139        let checksum = ArtifactChecksumRecord::from_bytes(chunk);
140        let kind = match &source_kind {
141            SnapshotDataKind::WasmModule { offset, size } => {
142                require_length(chunk, *size)?;
143                SnapshotDataOffset::WasmModule { offset: *offset }
144            }
145            SnapshotDataKind::WasmMemory { offset, size } => {
146                require_length(chunk, *size)?;
147                SnapshotDataOffset::WasmMemory { offset: *offset }
148            }
149            SnapshotDataKind::StableMemory { offset, size } => {
150                require_length(chunk, *size)?;
151                SnapshotDataOffset::StableMemory { offset: *offset }
152            }
153            SnapshotDataKind::WasmChunk { hash } => {
154                let digest = ic_host_artifacts::artifact::Sha256Digest::from_bytes(
155                    hash.as_slice()
156                        .try_into()
157                        .map_err(|_| IcSnapshotDataError::InvalidChunkHash)?,
158                );
159                checksum.verify(&digest.to_string())?;
160                SnapshotDataOffset::WasmChunk
161            }
162        };
163        let target =
164            Principal::from_text(metadata.target()).map_err(|_| IcRequestError::InvalidTarget)?;
165        let arguments = contract::encode_one(UploadCanisterSnapshotDataArgs {
166            canister_id: target,
167            snapshot_id: snapshot_id.to_vec(),
168            kind,
169            chunk: chunk.to_vec(),
170        })
171        .map_err(|error| IcRequestError::Encoding(error.to_string()))?;
172        Self::from_arguments(
173            metadata.source_plan,
174            metadata.source,
175            &metadata.source_checksum,
176            IcSnapshotUploadKind::Data {
177                snapshot_id: snapshot_id.to_vec(),
178                source_kind,
179                chunk_checksum: checksum,
180                metadata_request: metadata.digest(),
181            },
182            arguments,
183        )
184    }
185
186    fn from_arguments(
187        source_plan: &'source OperationPlanRecord,
188        source: &'source IcSnapshotMetadataReply<'source>,
189        source_checksum: &ArtifactChecksumRecord,
190        kind: IcSnapshotUploadKind,
191        arguments: Vec<u8>,
192    ) -> Result<Self, IcSnapshotUploadError> {
193        if arguments.len() > MAX_IC_SNAPSHOT_UPLOAD_ARGUMENT_BYTES {
194            return Err(IcSnapshotUploadError::ArgumentsTooLarge);
195        }
196        let target = Principal::from_text(source.request().target())
197            .map_err(|_| IcRequestError::InvalidTarget)?;
198        Ok(Self {
199            source_plan,
200            source,
201            source_checksum: source_checksum.clone(),
202            kind,
203            arguments,
204            target_bytes: target.as_slice().to_vec(),
205        })
206    }
207
208    pub(crate) fn validate_data_destination(
209        &self,
210        snapshot_id: &[u8],
211    ) -> Result<(), IcSnapshotUploadError> {
212        if !matches!(self.kind, IcSnapshotUploadKind::Metadata) {
213            return Err(IcSnapshotUploadError::MetadataRequestRequired);
214        }
215        validate_destination(self.source, snapshot_id)
216    }
217
218    /// Read the exact original source plan; later upload intent must retain its context.
219    #[must_use]
220    pub const fn source_plan(&self) -> &'source OperationPlanRecord {
221        self.source_plan
222    }
223
224    /// Read original metadata/request evidence; it authenticates no source.
225    #[must_use]
226    pub const fn source(&self) -> &'source IcSnapshotMetadataReply<'source> {
227        self.source
228    }
229    /// Read declared retained tree checksum; pure construction verifies no files.
230    #[must_use]
231    pub const fn source_checksum(&self) -> &ArtifactChecksumRecord {
232        &self.source_checksum
233    }
234    /// Read the closed kind and exact source/destination association.
235    #[must_use]
236    pub const fn kind(&self) -> &IcSnapshotUploadKind {
237        &self.kind
238    }
239    /// Read the same canonical source/destination canister target.
240    #[must_use]
241    pub fn target(&self) -> &str {
242        self.source.request().target()
243    }
244    /// Read fixed management receiver, separate from effective routing.
245    #[must_use]
246    pub const fn receiver(&self) -> &'static str {
247        "aaaaa-aa"
248    }
249    /// Read the exact replicated host-update method; no query mode exists.
250    #[must_use]
251    pub const fn method(&self) -> &'static str {
252        match self.kind {
253            IcSnapshotUploadKind::Metadata => "upload_canister_snapshot_metadata",
254            IcSnapshotUploadKind::Data { .. } => "upload_canister_snapshot_data",
255        }
256    }
257    /// Read exact canonical encoded bytes, without signing or spending.
258    #[must_use]
259    pub fn arguments(&self) -> &[u8] {
260        &self.arguments
261    }
262    /// Hash wire payload only, before plan/authority derivation, using its canonical owner.
263    #[must_use]
264    pub fn digest(&self) -> ArtifactChecksumRecord {
265        management_request_digest(&self.target_bytes, self.method(), &self.arguments)
266    }
267    /// Bind original source evidence/tree and metadata origin separately from wire intent.
268    #[must_use]
269    pub fn binding_digest(&self) -> ArtifactChecksumRecord {
270        let mut bytes = EvidenceDigest::new(b"ic-backup/ic-snapshot-upload-binding/v1\0");
271        bytes.append(self.source_plan.digest().hash().as_bytes());
272        bytes.append(self.source.digest().hash().as_bytes());
273        bytes.append(self.source_checksum.hash().as_bytes());
274        if let IcSnapshotUploadKind::Data {
275            metadata_request, ..
276        } = &self.kind
277        {
278            bytes.byte(1);
279            bytes.append(metadata_request.hash().as_bytes());
280        } else {
281            bytes.byte(0);
282        }
283        bytes.append(self.digest().hash().as_bytes());
284        bytes.finish()
285    }
286}
287
288pub(super) fn validate_destination(
289    source: &IcSnapshotMetadataReply<'_>,
290    snapshot_id: &[u8],
291) -> Result<(), IcSnapshotUploadError> {
292    if snapshot_id.is_empty() || snapshot_id.len() > MAX_IC_SNAPSHOT_ID_BYTES {
293        return Err(IcSnapshotUploadError::InvalidDestination);
294    }
295    if snapshot_id == source.request().snapshot_id() {
296        return Err(IcSnapshotUploadError::DestinationReusesSource);
297    }
298    Ok(())
299}
300
301fn require_length(chunk: &[u8], length: u64) -> Result<(), IcSnapshotUploadError> {
302    if u64::try_from(chunk.len()).ok() != Some(length) {
303        return Err(IcSnapshotUploadError::ChunkLengthMismatch);
304    }
305    Ok(())
306}
307
308impl fmt::Debug for IcSnapshotUploadRequest<'_> {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        f.debug_struct("IcSnapshotUploadRequest")
311            .field("target", &self.target())
312            .field("method", &self.method())
313            .field("argument_bytes", &self.arguments.len())
314            .finish_non_exhaustive()
315    }
316}
317
318/// Typed bounded upload denial; no error changes retained spending or source evidence.
319#[derive(Debug, Error)]
320pub enum IcSnapshotUploadError {
321    /// Exact original source target is outside its retained plan selection.
322    #[error("snapshot upload source target differs from the original selection")]
323    SourceTargetMismatch,
324    /// A missing original global cannot be represented by the upstream upload type.
325    #[error("snapshot upload requires every original global value")]
326    UnavailableGlobal,
327    /// Data upload must retain its exact original metadata-allocation declaration.
328    #[error("snapshot upload requires an original metadata request")]
329    MetadataRequestRequired,
330    /// Destination raw bytes violate the existing 1..=256 ID bounds.
331    #[error("snapshot upload destination ID is invalid")]
332    InvalidDestination,
333    /// The new upload declaration attempts to reuse its retained original source ID.
334    #[error("snapshot upload destination reuses the original source ID")]
335    DestinationReusesSource,
336    /// Actual bounded bytes differ from the requested source range length.
337    #[error("snapshot upload chunk length differs from the source range")]
338    ChunkLengthMismatch,
339    /// Actual chunk exceeds 1 MiB before cloning or encoding.
340    #[error("snapshot upload chunk exceeds bound")]
341    ChunkTooLarge,
342    /// Encoded arguments exceed the separate 2 MiB upload ceiling.
343    #[error("snapshot upload arguments exceed bound")]
344    ArgumentsTooLarge,
345    /// Raw reply exceeds 4 KiB before decoding.
346    #[error("snapshot upload reply exceeds bound")]
347    ReplyTooLarge,
348    /// Finite exact method-specific reply decoding rejected bytes.
349    #[error("snapshot upload reply is invalid")]
350    InvalidReply,
351    /// Existing range/known-hash owner rejected the source kind.
352    #[error(transparent)]
353    Data(#[from] IcSnapshotDataError),
354    /// The exact original chunk digest differs.
355    #[error(transparent)]
356    Checksum(#[from] ChecksumError),
357    /// Canonical principal or upstream Candid encoding failed.
358    #[error(transparent)]
359    Request(#[from] IcRequestError),
360}
361
362#[cfg(test)]
363mod tests;