ic_backup/model/ic_snapshot_reply/mod.rs
1//! Bounded capture/inventory reply decoding; transport authenticity stays external.
2
3mod wire;
4pub(crate) use wire::snapshot_id;
5
6use super::{
7 artifacts::ArtifactChecksumRecord,
8 ic_request::{IcManagementMethodRecord, IcManagementRequestRecord},
9};
10use thiserror::Error;
11
12/// Maximum raw Candid reply bytes admitted before decoding.
13pub const MAX_IC_SNAPSHOT_REPLY_BYTES: usize = 1024 * 1024;
14/// Maximum retained inventory entries; this is a local bound, not an IC capacity claim.
15pub const MAX_IC_SNAPSHOT_REPLY_ENTRIES: usize = 1024;
16
17/// Exact decoded snapshot identity and declared timestamp/size.
18///
19/// Raw identifiers remain opaque bytes. Timestamp and size may be zero or any
20/// nat64 value; neither proves artifact completeness or unique effect settlement.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct IcSnapshotInfo {
23 id: Vec<u8>,
24 taken_at_timestamp: u64,
25 total_size: u64,
26}
27
28impl IcSnapshotInfo {
29 /// Read exact raw snapshot bytes, without interpreting a backend token.
30 #[must_use]
31 pub fn id(&self) -> &[u8] {
32 &self.id
33 }
34
35 /// Read the reply's declared snapshot timestamp in nanoseconds.
36 #[must_use]
37 pub const fn taken_at_timestamp(&self) -> u64 {
38 self.taken_at_timestamp
39 }
40
41 /// Read the reply's declared snapshot size, distinct from local artifact bytes.
42 #[must_use]
43 pub const fn total_size(&self) -> u64 {
44 self.total_size
45 }
46}
47
48/// Read-only decoded reply associated with the caller's exact request.
49///
50/// Capture has one entry; inventory has 0..=1,024 unique entries in raw-ID order.
51/// The wire contains no network, caller or target, so this association and digest
52/// do not authenticate response origin, freshness, permissions or execution.
53/// No Serde admission, completion receipt, reservation or settlement is provided.
54#[derive(Debug)]
55pub struct IcSnapshotReply<'request> {
56 request: &'request IcManagementRequestRecord,
57 snapshots: Vec<IcSnapshotInfo>,
58 payload_checksum: ArtifactChecksumRecord,
59}
60
61impl<'request> IcSnapshotReply<'request> {
62 /// Decode the pinned capture or inventory result under finite local bounds.
63 ///
64 /// Exactly one Candid argument with the required snapshot fields is admitted.
65 /// Extra arguments, skipped fields, trailing bytes and duplicate IDs reject.
66 /// The integration must independently qualify the transport/request association.
67 ///
68 /// # Errors
69 /// Returns typed unsupported-method, raw-size, invalid-reply or duplicate-ID
70 /// failures. No journal or caller-owned state changes on any outcome.
71 pub fn decode(
72 request: &'request IcManagementRequestRecord,
73 bytes: &[u8],
74 ) -> Result<Self, IcSnapshotReplyError> {
75 let method = request.method();
76 if !matches!(
77 method,
78 IcManagementMethodRecord::TakeCanisterSnapshot
79 | IcManagementMethodRecord::ListCanisterSnapshots
80 ) {
81 return Err(IcSnapshotReplyError::UnsupportedMethod { method });
82 }
83 if bytes.len() > MAX_IC_SNAPSHOT_REPLY_BYTES {
84 return Err(IcSnapshotReplyError::ReplyTooLarge);
85 }
86 let mut snapshots = wire::decode(method, bytes)?;
87 snapshots.sort_unstable_by(|left, right| left.id.cmp(&right.id));
88 if snapshots.windows(2).any(|pair| pair[0].id == pair[1].id) {
89 return Err(IcSnapshotReplyError::DuplicateSnapshotId);
90 }
91 Ok(Self {
92 request,
93 snapshots,
94 payload_checksum: ArtifactChecksumRecord::from_bytes(bytes),
95 })
96 }
97
98 /// Read the original request; its owner supplies exact target/method/byte identity.
99 #[must_use]
100 pub const fn request(&self) -> &'request IcManagementRequestRecord {
101 self.request
102 }
103
104 /// Read the capture singleton or canonical inventory without mutation access.
105 #[must_use]
106 pub fn snapshots(&self) -> &[IcSnapshotInfo] {
107 &self.snapshots
108 }
109
110 /// Read the SHA-256 checksum of the exact raw reply, including its wire ordering.
111 #[must_use]
112 pub const fn payload_checksum(&self) -> &ArtifactChecksumRecord {
113 &self.payload_checksum
114 }
115
116 /// Hash the exact request digest and raw-reply checksum in a separate v1 domain.
117 ///
118 /// Two fixed 64-byte lowercase SHA-256 strings follow the domain. A canonical
119 /// inventory view does not erase raw wire differences or request identity.
120 /// This is local evidence identity, not an authenticated IC receipt.
121 #[must_use]
122 pub fn digest(&self) -> ArtifactChecksumRecord {
123 let mut bytes = b"ic-backup/ic-snapshot-reply/v1\0".to_vec();
124 bytes.extend_from_slice(self.request.digest().hash().as_bytes());
125 bytes.extend_from_slice(self.payload_checksum.hash().as_bytes());
126 ArtifactChecksumRecord::from_bytes(&bytes)
127 }
128}
129
130/// Typed snapshot wire admission failure, with no raw payload in diagnostics.
131#[derive(Debug, Error, Eq, PartialEq)]
132pub enum IcSnapshotReplyError {
133 /// Only the existing capture and inventory request methods have this reply shape.
134 #[error("snapshot reply codec does not support {method:?}")]
135 UnsupportedMethod {
136 /// Actual request method, as admitted by the existing request owner.
137 method: IcManagementMethodRecord,
138 },
139 /// Raw input exceeded the local bound before Candid parsing.
140 #[error("snapshot reply exceeds {MAX_IC_SNAPSHOT_REPLY_BYTES} bytes")]
141 ReplyTooLarge,
142 /// Shape, field/count/ID bounds, decoding quota or exact consumption failed.
143 #[error("invalid or unbounded snapshot Candid reply")]
144 InvalidReply,
145 /// Identical raw identifiers appeared more than once, even with different metadata.
146 #[error("snapshot inventory contains duplicate raw identifiers")]
147 DuplicateSnapshotId,
148}
149
150#[cfg(test)]
151mod tests;