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