Skip to main content

ic_backup/model/ic_request/
mod.rs

1//! Closed host-ingress IC request codec; no transport, authority or effect admission.
2
3mod method;
4pub use method::{IcManagementMethodRecord, IcRequestEffect};
5
6use crate::model::{
7    artifacts::ArtifactChecksumRecord, attempt_journal::OperationBindingRecord, bounded_sequence,
8};
9use ic_management_canister_types::{
10    CanisterIdRecord, LoadCanisterSnapshotArgs, TakeCanisterSnapshotArgs,
11};
12use serde::{Deserialize, Deserializer, Serialize};
13use thiserror::Error;
14
15/// Maximum raw snapshot identifier bytes, distinct from any backend's rendered token.
16pub const MAX_IC_SNAPSHOT_ID_BYTES: usize = 256;
17/// Maximum derived Candid argument bytes; checked before a request is admitted.
18pub const MAX_IC_ARGUMENT_BYTES: usize = 4096;
19/// Bound callers must use for encoded JSON input/output when retaining this record.
20pub const MAX_IC_REQUEST_RECORD_BYTES: u64 = 8192;
21
22/// Passive method/target/snapshot input; network, caller and permissions stay with their owners.
23#[derive(Clone, Debug)]
24pub struct IcManagementRequest {
25    /// One of the closed supported management methods.
26    pub method: IcManagementMethodRecord,
27    /// Exact effective canister principal; normalized on admission.
28    pub target: String,
29    /// Raw nonempty snapshot bytes required only for load; otherwise exactly None.
30    pub snapshot_id: Option<Vec<u8>>,
31}
32
33/// Immutable v1 request declaration with model-derived exact Candid bytes.
34///
35/// All supported methods use replicated update ingress, including observations.
36/// Network/caller selection, signing, freshness, budgets, custody and lifecycle
37/// safety are not granted by this record or its digest.
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(try_from = "RequestFields")]
40pub struct IcManagementRequestRecord {
41    version: u16,
42    method: IcManagementMethodRecord,
43    target: String,
44    snapshot_id: Option<Vec<u8>>,
45    #[serde(skip)]
46    target_bytes: Vec<u8>,
47    #[serde(skip)]
48    arguments: Vec<u8>,
49}
50#[derive(Deserialize)]
51#[serde(deny_unknown_fields)]
52struct RequestFields {
53    version: u16,
54    method: IcManagementMethodRecord,
55    target: String,
56    #[serde(deserialize_with = "required_snapshot")]
57    snapshot_id: Option<Vec<u8>>,
58}
59impl TryFrom<RequestFields> for IcManagementRequestRecord {
60    type Error = IcRequestError;
61    fn try_from(fields: RequestFields) -> Result<Self, Self::Error> {
62        if fields.version != 1 {
63            return Err(IcRequestError::UnsupportedVersion(fields.version));
64        }
65        Self::new(IcManagementRequest {
66            method: fields.method,
67            target: fields.target,
68            snapshot_id: fields.snapshot_id,
69        })
70    }
71}
72impl IcManagementRequestRecord {
73    /// Validate the closed request and encode exact host-ingress arguments.
74    ///
75    /// Capture retains code and creates a new snapshot; sender canister version
76    /// stays absent for host ingress. Load names exact raw bytes, with no identity remap.
77    ///
78    /// # Errors
79    /// Rejects malformed principals, missing/unexpected/oversized snapshot IDs,
80    /// Candid encoding failure and excessive derived argument bytes.
81    pub fn new(request: IcManagementRequest) -> Result<Self, IcRequestError> {
82        let (target, principal) = crate::model::principal::canonical_parts(&request.target)
83            .ok_or(IcRequestError::InvalidTarget)?;
84        match (request.method, request.snapshot_id.as_deref()) {
85            (IcManagementMethodRecord::LoadCanisterSnapshot, None) => {
86                return Err(IcRequestError::SnapshotRequired);
87            }
88            (IcManagementMethodRecord::LoadCanisterSnapshot, Some(bytes)) => {
89                if bytes.is_empty() || bytes.len() > MAX_IC_SNAPSHOT_ID_BYTES {
90                    return Err(IcRequestError::InvalidSnapshotId);
91                }
92            }
93            (_, Some(_)) => return Err(IcRequestError::UnexpectedSnapshot),
94            (_, None) => {}
95        }
96        let arguments = match request.method {
97            IcManagementMethodRecord::TakeCanisterSnapshot => {
98                candid::encode_one(TakeCanisterSnapshotArgs {
99                    canister_id: principal,
100                    replace_snapshot: None,
101                    uninstall_code: Some(false),
102                    sender_canister_version: None,
103                })
104            }
105            IcManagementMethodRecord::LoadCanisterSnapshot => {
106                candid::encode_one(LoadCanisterSnapshotArgs {
107                    canister_id: principal,
108                    snapshot_id: request
109                        .snapshot_id
110                        .clone()
111                        .ok_or(IcRequestError::SnapshotRequired)?,
112                    sender_canister_version: None,
113                })
114            }
115            IcManagementMethodRecord::CanisterStatus
116            | IcManagementMethodRecord::ListCanisterSnapshots
117            | IcManagementMethodRecord::StartCanister
118            | IcManagementMethodRecord::StopCanister => candid::encode_one(CanisterIdRecord {
119                canister_id: principal,
120            }),
121        }
122        .map_err(|error| IcRequestError::Encoding(error.to_string()))?;
123        if arguments.len() > MAX_IC_ARGUMENT_BYTES {
124            return Err(IcRequestError::ArgumentsTooLarge);
125        }
126        Ok(Self {
127            version: 1,
128            method: request.method,
129            target,
130            snapshot_id: request.snapshot_id,
131            target_bytes: principal.as_slice().to_vec(),
132            arguments,
133        })
134    }
135    /// Read the exact closed method; its effect class does not grant spending authority.
136    #[must_use]
137    pub const fn method(&self) -> IcManagementMethodRecord {
138        self.method
139    }
140    /// Read the canonical effective routing target, also encoded inside the arguments.
141    #[must_use]
142    pub fn target(&self) -> &str {
143        &self.target
144    }
145    /// Read exact raw load snapshot bytes, without backend token interpretation.
146    #[must_use]
147    pub fn snapshot_id(&self) -> Option<&[u8]> {
148        self.snapshot_id.as_deref()
149    }
150    /// Read derived exact Candid bytes for a future qualified update-ingress transport.
151    #[must_use]
152    pub fn arguments(&self) -> &[u8] {
153        &self.arguments
154    }
155    /// Read the fixed management receiver; the effective routing target is separate.
156    #[must_use]
157    pub const fn receiver(&self) -> &'static str {
158        "aaaaa-aa"
159    }
160    /// Hash fixed receiver, effective target, update mode, exact method and Candid bytes.
161    ///
162    /// Network/caller/intent/release/budgets stay outside this nonrecursive payload
163    /// digest and are bound by the existing operation plan/attempt authority.
164    #[must_use]
165    pub fn digest(&self) -> ArtifactChecksumRecord {
166        management_request_digest(&self.target_bytes, self.method.name(), &self.arguments)
167    }
168    /// Check exact payload target/digest and mutation class against original declared binding.
169    ///
170    /// This verifies bytes only; it does not authenticate the binding or admit dispatch.
171    /// # Errors
172    /// Rejects observation methods and changed target or request digest.
173    pub fn validate_mutation_binding(
174        &self,
175        binding: &OperationBindingRecord,
176    ) -> Result<(), IcRequestError> {
177        self.require_effect(IcRequestEffect::Mutation)?;
178        self.validate_identity(binding, binding.request())
179    }
180    /// Check an observation payload against its exact target and reserved observation digest.
181    ///
182    /// The original binding still names the mutation. The observation digest is
183    /// separately retained by the existing journal's observation reservation.
184    /// # Errors
185    /// Rejects mutation methods, changed target or a different observation digest.
186    pub fn validate_observation_binding(
187        &self,
188        binding: &OperationBindingRecord,
189        request: &ArtifactChecksumRecord,
190    ) -> Result<(), IcRequestError> {
191        self.require_effect(IcRequestEffect::Observation)?;
192        self.validate_identity(binding, request.hash())
193    }
194    fn require_effect(&self, expected: IcRequestEffect) -> Result<(), IcRequestError> {
195        if self.method.effect() != expected {
196            return Err(IcRequestError::EffectMismatch { expected });
197        }
198        Ok(())
199    }
200    fn validate_identity(
201        &self,
202        binding: &OperationBindingRecord,
203        expected: &str,
204    ) -> Result<(), IcRequestError> {
205        if self.target != binding.target() {
206            return Err(IcRequestError::TargetMismatch);
207        }
208        if self.digest().hash() != expected {
209            return Err(IcRequestError::DigestMismatch);
210        }
211        Ok(())
212    }
213}
214
215// Callers own canonical principal bytes, a fixed ASCII method and bounded arguments.
216pub(super) fn management_request_digest(
217    target_bytes: &[u8],
218    method: &str,
219    arguments: &[u8],
220) -> ArtifactChecksumRecord {
221    let mut bytes = b"ic-backup/ic-management-request/v1\0".to_vec();
222    bytes.push(0); // Management receiver principal has zero raw bytes.
223    bytes.push(target_bytes.len().to_le_bytes()[0]); // Principal <=29 raw bytes.
224    bytes.extend_from_slice(target_bytes);
225    bytes.push(1); // Replicated update ingress.
226    bytes.push(method.len().to_le_bytes()[0]); // Fixed names fit one byte.
227    bytes.extend_from_slice(method.as_bytes());
228    append_argument_length(&mut bytes, arguments.len());
229    bytes.extend_from_slice(arguments);
230    ArtifactChecksumRecord::from_bytes(&bytes)
231}
232
233#[expect(
234    clippy::cast_possible_truncation,
235    reason = "admitted argument byte length is at most 4096"
236)]
237fn append_argument_length(bytes: &mut Vec<u8>, length: usize) {
238    bytes.extend_from_slice(&(length as u32).to_be_bytes());
239}
240
241fn required_snapshot<'de, D: Deserializer<'de>>(
242    deserializer: D,
243) -> Result<Option<Vec<u8>>, D::Error> {
244    Ok(Option::<SnapshotBytes>::deserialize(deserializer)?.map(|bytes| bytes.0))
245}
246struct SnapshotBytes(Vec<u8>);
247impl<'de> Deserialize<'de> for SnapshotBytes {
248    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
249        bounded_sequence::deserialize::<_, u8, MAX_IC_SNAPSHOT_ID_BYTES>(
250            deserializer,
251            "at most 256 exact snapshot bytes",
252            IcRequestError::InvalidSnapshotId,
253        )
254        .map(Self)
255    }
256}
257
258/// Typed closed IC request, exact byte binding or codec admission failure.
259#[derive(Debug, Error, Eq, PartialEq)]
260pub enum IcRequestError {
261    /// Other product generations are not maintained.
262    #[error("unsupported IC request version {0}")]
263    UnsupportedVersion(u16),
264    /// Target text is not a bounded canonicalizable principal.
265    #[error("invalid IC request target principal")]
266    InvalidTarget,
267    /// Load requires exact raw snapshot bytes.
268    #[error("load snapshot request requires snapshot_id")]
269    SnapshotRequired,
270    /// A method that has no snapshot argument received one.
271    #[error("snapshot_id is not admitted for this IC request method")]
272    UnexpectedSnapshot,
273    /// Snapshot bytes are empty or exceed their finite bound.
274    #[error("snapshot_id must contain 1..={MAX_IC_SNAPSHOT_ID_BYTES} raw bytes")]
275    InvalidSnapshotId,
276    /// The pinned upstream Candid encoder rejected the typed arguments.
277    #[error("IC request Candid encoding failed: {0}")]
278    Encoding(String),
279    /// Derived argument size exceeds the codec bound.
280    #[error("IC request arguments exceed {MAX_IC_ARGUMENT_BYTES} bytes")]
281    ArgumentsTooLarge,
282    /// A mutation and observation request cannot substitute for each other.
283    #[error("IC request must have effect class {expected:?}")]
284    EffectMismatch {
285        /// Required semantic class, independent of replicated call mode.
286        expected: IcRequestEffect,
287    },
288    /// The payload/routing target differs from the original operation target.
289    #[error("IC request target differs from original binding")]
290    TargetMismatch,
291    /// Exact method/argument digest differs from its original reservation.
292    #[error("IC request digest differs from original binding")]
293    DigestMismatch,
294}
295
296#[cfg(test)]
297mod tests;