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