Skip to main content

ic_host_tools/response/
mod.rs

1//! Bounded decoding of caller-selected ICP CLI response formats.
2//!
3//! This boundary returns opaque bytes. Consumers own Candid decoding, canister
4//! rejection semantics, CLI version admission, capture limits and replay policy.
5//! No command is executed and no output format is inferred.
6
7#[cfg(test)]
8mod tests;
9
10use serde::de::{Deserialize, Deserializer, IgnoredAny, MapAccess, Visitor};
11use serde_json::error::Category;
12use std::{collections::TryReserveError, fmt};
13
14/// The output format explicitly requested by the caller's CLI command.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum ResponseFormat {
17    /// An object containing a top-level string `response_bytes` of compact hex.
18    /// Unknown fields are ignored; a missing or null field is rejected. Empty
19    /// hex represents empty bytes. Duplicate `response_bytes` fields are rejected.
20    Json,
21    /// Nonempty hex, permitting ASCII space, tab, CR, LF and form feed between
22    /// any two digits (Rust's `is_ascii_whitespace` set).
23    Hex,
24    /// An exact `response (hex):` prefix followed by nonempty whitespace-tolerant
25    /// hex. Leading ASCII whitespace is permitted; preambles and repeated labels
26    /// are rejected rather than discarded.
27    LabeledHex,
28}
29
30/// Caller-owned bounds for input parsing and decoded byte allocation.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct ResponseLimits {
33    /// Maximum complete input length, including JSON metadata or the hex label.
34    pub input_bytes: usize,
35    /// Maximum decoded byte length, independent of the input allowance.
36    pub decoded_bytes: usize,
37}
38
39/// JSON parser failure category without response values or parser error prose.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum JsonErrorKind {
42    /// JSON grammar or encoding is invalid.
43    Syntax,
44    /// The JSON shape does not match the envelope, including duplicate fields.
45    Data,
46    /// The input ends before the JSON value is complete.
47    EndOfInput,
48    /// The underlying JSON parser reported an I/O failure.
49    Io,
50}
51
52/// Response decoding failed. Formatting never includes response contents.
53#[derive(Debug)]
54pub enum ResponseError {
55    /// Complete input exceeds its allowance, before any parsing.
56    InputLimit {
57        /// Caller-selected input allowance.
58        limit: usize,
59    },
60    /// JSON input contains invalid UTF-8, including in ignored metadata.
61    InvalidUtf8 {
62        /// First invalid byte offset in the complete input.
63        offset: usize,
64    },
65    /// JSON could not be decoded as the canonical envelope.
66    Json {
67        /// Structured parser category.
68        kind: JsonErrorKind,
69        /// One-based parser line, or zero if unavailable.
70        line: usize,
71        /// Parser column, or zero if unavailable.
72        column: usize,
73    },
74    /// The top-level response field is absent or null.
75    MissingResponseBytes,
76    /// Labeled hex does not begin with the exact admitted label.
77    MissingHexLabel,
78    /// The selected text hex format contains no digits.
79    EmptyHex,
80    /// A byte is not a hex digit or permitted whitespace.
81    InvalidHex {
82        /// Byte offset within the hex portion (within the decoded JSON string
83        /// for JSON input), not necessarily an offset in the original input.
84        offset: usize,
85    },
86    /// The hex portion contains an odd number of digits.
87    OddHexLength,
88    /// Valid hex would exceed the decoded byte allowance.
89    DecodedLimit {
90        /// Caller-selected decoded allowance.
91        limit: usize,
92    },
93    /// Storage could not be reserved for decoded bytes.
94    Allocation(TryReserveError),
95}
96
97impl fmt::Display for ResponseError {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::InputLimit { limit } => write!(f, "response input exceeds {limit} bytes"),
101            Self::InvalidUtf8 { offset } => {
102                write!(f, "response JSON is not UTF-8 at byte {offset}")
103            }
104            Self::Json { kind, line, column } => {
105                write!(f, "response JSON failed ({kind:?}) at {line}:{column}")
106            }
107            Self::MissingResponseBytes => f.write_str("response JSON lacks response_bytes"),
108            Self::MissingHexLabel => f.write_str("response hex label is missing"),
109            Self::EmptyHex => f.write_str("response hex is empty"),
110            Self::InvalidHex { offset } => write!(f, "invalid response hex at byte {offset}"),
111            Self::OddHexLength => f.write_str("response hex has an odd digit count"),
112            Self::DecodedLimit { limit } => write!(f, "decoded response exceeds {limit} bytes"),
113            Self::Allocation(source) => write!(f, "response allocation failed: {source}"),
114        }
115    }
116}
117
118impl std::error::Error for ResponseError {
119    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
120        match self {
121            Self::Allocation(source) => Some(source),
122            _ => None,
123        }
124    }
125}
126
127/// Decode one complete response without dispatching a command or retrying it.
128///
129/// Input size is checked before parsing. `serde_json` owns JSON grammar and
130/// escaped strings; unknown metadata is skipped without building a value tree.
131/// JSON parser storage is bounded by the input allowance; its allocations are
132/// owned by Serde. Decoded bytes are reserved fallibly only after validating all
133/// hex and its independent allowance. Text hex permits only Rust ASCII whitespace;
134/// JSON hex is compact, even at its edges. Candid validity is not checked.
135///
136/// # Errors
137/// Returns typed size, JSON, envelope, hex or allocation failures. The caller
138/// retains the original input for any explicitly authorized diagnostics.
139pub fn decode(
140    input: &[u8],
141    format: ResponseFormat,
142    limits: ResponseLimits,
143) -> Result<Vec<u8>, ResponseError> {
144    if input.len() > limits.input_bytes {
145        return Err(ResponseError::InputLimit {
146            limit: limits.input_bytes,
147        });
148    }
149    match format {
150        ResponseFormat::Json => {
151            std::str::from_utf8(input).map_err(|error| ResponseError::InvalidUtf8 {
152                offset: error.valid_up_to(),
153            })?;
154            let envelope: Envelope =
155                serde_json::from_slice(input).map_err(|error| json_error(&error))?;
156            let hex = envelope.0.ok_or(ResponseError::MissingResponseBytes)?;
157            decode_hex(hex.as_bytes(), false, limits.decoded_bytes)
158        }
159        ResponseFormat::Hex => decode_text_hex(input, limits.decoded_bytes),
160        ResponseFormat::LabeledHex => {
161            let input = input.trim_ascii_start();
162            let hex = input
163                .strip_prefix(b"response (hex):")
164                .ok_or(ResponseError::MissingHexLabel)?;
165            decode_text_hex(hex, limits.decoded_bytes)
166        }
167    }
168}
169
170fn decode_text_hex(hex: &[u8], limit: usize) -> Result<Vec<u8>, ResponseError> {
171    if hex.iter().all(u8::is_ascii_whitespace) {
172        return Err(ResponseError::EmptyHex);
173    }
174    decode_hex(hex, true, limit)
175}
176
177fn decode_hex(hex: &[u8], whitespace: bool, limit: usize) -> Result<Vec<u8>, ResponseError> {
178    let mut digits = 0;
179    for (offset, &byte) in hex.iter().enumerate() {
180        if byte.is_ascii_hexdigit() {
181            digits += 1;
182        } else if !(whitespace && byte.is_ascii_whitespace()) {
183            return Err(ResponseError::InvalidHex { offset });
184        }
185    }
186    if digits % 2 != 0 {
187        return Err(ResponseError::OddHexLength);
188    }
189    if digits / 2 > limit {
190        return Err(ResponseError::DecodedLimit { limit });
191    }
192    let mut bytes = Vec::new();
193    bytes
194        .try_reserve_exact(digits / 2)
195        .map_err(ResponseError::Allocation)?;
196    let mut high = None;
197    for byte in hex.iter().copied().filter(u8::is_ascii_hexdigit) {
198        // The validation pass admits only these digit ranges.
199        let nibble = if byte <= b'9' {
200            byte - b'0'
201        } else {
202            byte.to_ascii_lowercase() - b'a' + 10
203        };
204        if let Some(previous) = high.take() {
205            bytes.push((previous << 4) | nibble);
206        } else {
207            high = Some(nibble);
208        }
209    }
210    Ok(bytes)
211}
212
213fn json_error(error: &serde_json::Error) -> ResponseError {
214    let kind = match error.classify() {
215        Category::Io => JsonErrorKind::Io,
216        Category::Syntax => JsonErrorKind::Syntax,
217        Category::Data => JsonErrorKind::Data,
218        Category::Eof => JsonErrorKind::EndOfInput,
219    };
220    ResponseError::Json {
221        kind,
222        line: error.line(),
223        column: error.column(),
224    }
225}
226
227struct Envelope(Option<String>);
228
229impl<'de> Deserialize<'de> for Envelope {
230    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
231        struct EnvelopeVisitor;
232        impl<'de> Visitor<'de> for EnvelopeVisitor {
233            type Value = Envelope;
234
235            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236                f.write_str("an ICP response object")
237            }
238
239            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Envelope, M::Error> {
240                let mut response = None;
241                let mut seen = false;
242                while let Some(key) = map.next_key::<String>()? {
243                    if key == "response_bytes" {
244                        if seen {
245                            return Err(serde::de::Error::duplicate_field("response_bytes"));
246                        }
247                        seen = true;
248                        response = map.next_value::<Option<String>>()?;
249                    } else {
250                        map.next_value::<IgnoredAny>()?;
251                    }
252                }
253                Ok(Envelope(response))
254            }
255        }
256        deserializer.deserialize_map(EnvelopeVisitor)
257    }
258}