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, thiserror::Error)]
51pub enum ResponseError {
52    /// Complete input exceeds its allowance, before any parsing.
53    #[error("response input exceeds {limit} bytes")]
54    InputLimit {
55        /// Caller-selected input allowance.
56        limit: usize,
57    },
58    /// JSON input contains invalid UTF-8, including in ignored metadata.
59    #[error("response JSON is not UTF-8 at byte {offset}")]
60    InvalidUtf8 {
61        /// First invalid byte offset in the complete input.
62        offset: usize,
63    },
64    /// JSON could not be decoded as the canonical envelope.
65    #[error("response JSON failed ({kind:?}) at {line}:{column}")]
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    #[error("response JSON lacks response_bytes")]
76    MissingResponseBytes,
77    /// The selected text hex format contains no digits.
78    #[error("response hex is empty")]
79    EmptyHex,
80    /// A byte is not a hex digit or permitted whitespace.
81    #[error("invalid response hex at byte {offset}")]
82    InvalidHex {
83        /// Byte offset within the hex portion (within the decoded JSON string
84        /// for JSON input), not necessarily an offset in the original input.
85        offset: usize,
86    },
87    /// The hex portion contains an odd number of digits.
88    #[error("response hex has an odd digit count")]
89    OddHexLength,
90    /// Valid hex would exceed the decoded byte allowance.
91    #[error("decoded response exceeds {limit} bytes")]
92    DecodedLimit {
93        /// Caller-selected decoded allowance.
94        limit: usize,
95    },
96    /// Storage could not be reserved for decoded bytes.
97    #[error("response allocation failed: {0}")]
98    Allocation(#[source] TryReserveError),
99}
100
101/// Decode one complete response without dispatching a command or retrying it.
102///
103/// Input size is checked before parsing. `serde_json` owns JSON grammar and
104/// escaped strings; unknown metadata is skipped without building a value tree.
105/// JSON parser storage is bounded by the input allowance; its allocations are
106/// owned by Serde. Decoded bytes are reserved fallibly only after validating all
107/// hex and its independent allowance. Text hex permits only Rust ASCII whitespace;
108/// JSON hex is compact, even at its edges. Candid validity is not checked.
109///
110/// # Errors
111/// Returns typed size, JSON, envelope, hex or allocation failures. The caller
112/// retains the original input for any explicitly authorized diagnostics.
113pub fn decode(
114    input: &[u8],
115    format: ResponseFormat,
116    limits: ResponseLimits,
117) -> Result<Vec<u8>, ResponseError> {
118    if input.len() > limits.input_bytes {
119        return Err(ResponseError::InputLimit {
120            limit: limits.input_bytes,
121        });
122    }
123    match format {
124        ResponseFormat::Json => {
125            std::str::from_utf8(input).map_err(|error| ResponseError::InvalidUtf8 {
126                offset: error.valid_up_to(),
127            })?;
128            let envelope: Envelope =
129                serde_json::from_slice(input).map_err(|error| json_error(&error))?;
130            let hex = envelope.0.ok_or(ResponseError::MissingResponseBytes)?;
131            decode_hex(hex.as_bytes(), false, limits.decoded_bytes)
132        }
133        ResponseFormat::Hex => decode_hex(input, true, limits.decoded_bytes),
134    }
135}
136
137fn decode_hex(hex: &[u8], whitespace: bool, limit: usize) -> Result<Vec<u8>, ResponseError> {
138    let mut digits = 0;
139    for (offset, &byte) in hex.iter().enumerate() {
140        if byte.is_ascii_hexdigit() {
141            digits += 1;
142        } else if !(whitespace && byte.is_ascii_whitespace()) {
143            return Err(ResponseError::InvalidHex { offset });
144        }
145    }
146    // Only text formats require a digit; JSON permits an empty byte response.
147    if whitespace && digits == 0 {
148        return Err(ResponseError::EmptyHex);
149    }
150    if digits % 2 != 0 {
151        return Err(ResponseError::OddHexLength);
152    }
153    if digits / 2 > limit {
154        return Err(ResponseError::DecodedLimit { limit });
155    }
156    let mut bytes = Vec::new();
157    bytes
158        .try_reserve_exact(digits / 2)
159        .map_err(ResponseError::Allocation)?;
160    let mut high = None;
161    for byte in hex.iter().copied().filter(u8::is_ascii_hexdigit) {
162        // The validation pass admits only these digit ranges.
163        let nibble = if byte <= b'9' {
164            byte - b'0'
165        } else {
166            byte.to_ascii_lowercase() - b'a' + 10
167        };
168        if let Some(previous) = high.take() {
169            bytes.push((previous << 4) | nibble);
170        } else {
171            high = Some(nibble);
172        }
173    }
174    Ok(bytes)
175}
176
177fn json_error(error: &serde_json::Error) -> ResponseError {
178    let kind = match error.classify() {
179        Category::Io => JsonErrorKind::Io,
180        Category::Syntax => JsonErrorKind::Syntax,
181        Category::Data => JsonErrorKind::Data,
182        Category::Eof => JsonErrorKind::EndOfInput,
183    };
184    ResponseError::Json {
185        kind,
186        line: error.line(),
187        column: error.column(),
188    }
189}
190
191struct Envelope(Option<String>);
192
193impl<'de> Deserialize<'de> for Envelope {
194    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
195        struct EnvelopeVisitor;
196        impl<'de> Visitor<'de> for EnvelopeVisitor {
197            type Value = Envelope;
198
199            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200                f.write_str("an ICP response object")
201            }
202
203            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Envelope, M::Error> {
204                let mut response = None;
205                let mut seen = false;
206                while let Some(key) = map.next_key::<String>()? {
207                    if key == "response_bytes" {
208                        if seen {
209                            return Err(serde::de::Error::duplicate_field("response_bytes"));
210                        }
211                        seen = true;
212                        response = map.next_value::<Option<String>>()?;
213                    } else {
214                        map.next_value::<IgnoredAny>()?;
215                    }
216                }
217                Ok(Envelope(response))
218            }
219        }
220        deserializer.deserialize_map(EnvelopeVisitor)
221    }
222}