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