Skip to main content

vs_protocol/
envelope.rs

1//! Response envelope, warnings, and the state token.
2//!
3//! Per `docs/PROTOCOL.md` § "Response envelope":
4//!
5//! - `@<token>` — success.
6//! - `! <CODE> [arg]...` — error.
7//! - `? <code> [arg]...` — warning (one per line, before the success
8//!   envelope).
9//!
10//! The full message envelope (warnings + success/error + body) is composed
11//! by callers; this module deals with the individual lines.
12
13use std::fmt;
14use std::str::FromStr;
15
16use crate::codes::{ErrorCode, WarningCode};
17use crate::error::{ParseError, Result};
18use crate::tokenize::{quote_value, strip_quotes, Tokenizer};
19
20/// A 64-bit state token, rendered as 16 lowercase hex chars on the wire.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
22pub struct StateToken(pub [u8; 8]);
23
24impl StateToken {
25    /// The all-zero token. Useful as a sentinel in tests; the daemon
26    /// never emits this in production.
27    pub const ZERO: Self = Self([0; 8]);
28
29    /// Construct from an explicit byte array.
30    #[must_use]
31    pub const fn from_bytes(bytes: [u8; 8]) -> Self {
32        Self(bytes)
33    }
34}
35
36impl fmt::Display for StateToken {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        for b in self.0 {
39            write!(f, "{b:02x}")?;
40        }
41        Ok(())
42    }
43}
44
45impl FromStr for StateToken {
46    type Err = ParseError;
47    fn from_str(s: &str) -> Result<Self> {
48        if s.len() != 16
49            || !s
50                .bytes()
51                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
52        {
53            return Err(ParseError::InvalidToken { raw: s.to_string() });
54        }
55        let mut bytes = [0u8; 8];
56        for (i, chunk) in s.as_bytes().chunks_exact(2).enumerate() {
57            // SAFETY-via-validation: chunk is guaranteed lowercase hex above.
58            bytes[i] = u8::from_str_radix(std::str::from_utf8(chunk).expect("ascii"), 16)
59                .map_err(|_| ParseError::InvalidToken { raw: s.to_string() })?;
60        }
61        Ok(Self(bytes))
62    }
63}
64
65/// One warning line, preceding the success envelope.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Warning {
68    pub code: WarningCode,
69    pub args: Vec<String>,
70}
71
72impl Warning {
73    #[must_use]
74    pub fn new(code: WarningCode) -> Self {
75        Self {
76            code,
77            args: Vec::new(),
78        }
79    }
80
81    #[must_use]
82    pub fn with_args(code: WarningCode, args: Vec<String>) -> Self {
83        Self { code, args }
84    }
85
86    /// Encode as a single wire line, including the trailing newline.
87    #[must_use]
88    pub fn encode(&self) -> String {
89        let mut out = format!("? {}", self.code);
90        for arg in &self.args {
91            out.push(' ');
92            out.push_str(&quote_value(arg));
93        }
94        out.push('\n');
95        out
96    }
97
98    /// Parse one warning line. The leading `?` and any surrounding
99    /// whitespace must already be present in `line`; trailing newline
100    /// is optional.
101    pub fn parse(line: &str) -> Result<Self> {
102        let trimmed = line.trim_end_matches('\n');
103        let body = trimmed
104            .strip_prefix('?')
105            .ok_or(ParseError::InvalidEnvelope {
106                detail: "warning line must start with `?`",
107            })?;
108        let mut tokens = Tokenizer::new(body);
109        let code_tok = tokens.next().ok_or(ParseError::InvalidEnvelope {
110            detail: "warning missing code",
111        })?;
112        let code: WarningCode = code_tok.parse()?;
113        let args: Vec<String> = tokens.map(|t| strip_quotes(t).to_string()).collect();
114        Ok(Self { code, args })
115    }
116}
117
118/// The single envelope line on a response.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Envelope {
121    Success(StateToken),
122    Error { code: ErrorCode, args: Vec<String> },
123}
124
125impl Envelope {
126    /// Encode this envelope as one wire line, including the trailing
127    /// newline.
128    #[must_use]
129    pub fn encode(&self) -> String {
130        match self {
131            Self::Success(t) => format!("@{t}\n"),
132            Self::Error { code, args } => {
133                let mut out = format!("! {code}");
134                for arg in args {
135                    out.push(' ');
136                    out.push_str(&quote_value(arg));
137                }
138                out.push('\n');
139                out
140            }
141        }
142    }
143
144    /// Parse a single envelope line.
145    pub fn parse(line: &str) -> Result<Self> {
146        let trimmed = line.trim_end_matches('\n');
147        let mut chars = trimmed.chars();
148        match chars.next() {
149            Some('@') => {
150                let rest = chars.as_str();
151                let token: StateToken = rest.parse()?;
152                Ok(Self::Success(token))
153            }
154            Some('!') => {
155                let body = chars.as_str();
156                let mut tokens = Tokenizer::new(body);
157                let code_tok = tokens.next().ok_or(ParseError::InvalidEnvelope {
158                    detail: "error missing code",
159                })?;
160                let code: ErrorCode = code_tok.parse()?;
161                let args: Vec<String> = tokens.map(|t| strip_quotes(t).to_string()).collect();
162                Ok(Self::Error { code, args })
163            }
164            _ => Err(ParseError::InvalidEnvelope {
165                detail: "envelope must start with `@` or `!`",
166            }),
167        }
168    }
169}
170
171/// A response head: zero or more warnings followed by an envelope.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ResponseHead {
174    pub warnings: Vec<Warning>,
175    pub envelope: Envelope,
176}
177
178impl ResponseHead {
179    #[must_use]
180    pub fn ok(token: StateToken) -> Self {
181        Self {
182            warnings: Vec::new(),
183            envelope: Envelope::Success(token),
184        }
185    }
186
187    #[must_use]
188    pub fn err(code: ErrorCode, args: Vec<String>) -> Self {
189        Self {
190            warnings: Vec::new(),
191            envelope: Envelope::Error { code, args },
192        }
193    }
194
195    #[must_use]
196    pub fn with_warning(mut self, w: Warning) -> Self {
197        self.warnings.push(w);
198        self
199    }
200
201    /// Encode the head: warnings (one per line) then the envelope line.
202    /// Does not emit the trailing blank line that terminates a full
203    /// response — callers compose that.
204    #[must_use]
205    pub fn encode(&self) -> String {
206        let mut out = String::new();
207        for w in &self.warnings {
208            out.push_str(&w.encode());
209        }
210        out.push_str(&self.envelope.encode());
211        out
212    }
213
214    /// Parse a head from a multi-line string. Stops at the envelope line
215    /// (anything after that line is the body, which the caller handles).
216    pub fn parse(input: &str) -> Result<Self> {
217        let mut warnings = Vec::new();
218        for line in input.lines() {
219            if line.trim().is_empty() {
220                continue;
221            }
222            if line.starts_with('?') {
223                warnings.push(Warning::parse(line)?);
224            } else if line.starts_with('@') || line.starts_with('!') {
225                let envelope = Envelope::parse(line)?;
226                return Ok(Self { warnings, envelope });
227            } else {
228                return Err(ParseError::InvalidEnvelope {
229                    detail: "expected `?`, `@`, or `!` at start of line",
230                });
231            }
232        }
233        Err(ParseError::InvalidEnvelope {
234            detail: "no envelope line found",
235        })
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn token_round_trip() {
245        let t = StateToken([0xa3, 0xf9, 0xb2, 0xc1, 0xd4, 0xe6, 0xf7, 0x0a]);
246        assert_eq!(t.to_string(), "a3f9b2c1d4e6f70a");
247        assert_eq!("a3f9b2c1d4e6f70a".parse::<StateToken>().unwrap(), t);
248    }
249
250    #[test]
251    fn token_zero() {
252        assert_eq!(StateToken::ZERO.to_string(), "0000000000000000");
253    }
254
255    #[test]
256    fn token_rejects_uppercase() {
257        let err = "A3F9B2C1D4E6F70A".parse::<StateToken>().unwrap_err();
258        matches!(err, ParseError::InvalidToken { .. });
259    }
260
261    #[test]
262    fn token_rejects_short() {
263        let err = "abc".parse::<StateToken>().unwrap_err();
264        matches!(err, ParseError::InvalidToken { .. });
265    }
266
267    #[test]
268    fn warning_round_trip_no_args() {
269        let w = Warning::new(WarningCode::CaptchaVisible);
270        let s = w.encode();
271        assert_eq!(s, "? captcha_visible\n");
272        assert_eq!(Warning::parse(&s).unwrap(), w);
273    }
274
275    #[test]
276    fn warning_round_trip_with_args() {
277        let w = Warning::with_args(WarningCode::Nav, vec!["https://example.com".into()]);
278        let s = w.encode();
279        assert_eq!(s, "? nav https://example.com\n");
280        assert_eq!(Warning::parse(&s).unwrap(), w);
281    }
282
283    #[test]
284    fn warning_arg_with_spaces_quoted() {
285        let w = Warning::with_args(WarningCode::Nav, vec!["with spaces".into()]);
286        let s = w.encode();
287        assert_eq!(s, "? nav \"with spaces\"\n");
288        assert_eq!(Warning::parse(&s).unwrap(), w);
289    }
290
291    #[test]
292    fn envelope_success_round_trip() {
293        let env = Envelope::Success(StateToken([0x12; 8]));
294        let s = env.encode();
295        assert_eq!(s, "@1212121212121212\n");
296        assert_eq!(Envelope::parse(&s).unwrap(), env);
297    }
298
299    #[test]
300    fn envelope_error_round_trip() {
301        let env = Envelope::Error {
302            code: ErrorCode::StaleToken,
303            args: vec!["abcdef0123456789".into(), "nav".into()],
304        };
305        let s = env.encode();
306        assert_eq!(s, "! STALE_TOKEN abcdef0123456789 nav\n");
307        assert_eq!(Envelope::parse(&s).unwrap(), env);
308    }
309
310    #[test]
311    fn response_head_with_warnings_round_trip() {
312        let head = ResponseHead::ok(StateToken([0xab; 8]))
313            .with_warning(Warning::with_args(
314                WarningCode::Nav,
315                vec!["https://example.com".into()],
316            ))
317            .with_warning(Warning::new(WarningCode::CaptchaVisible));
318        let s = head.encode();
319        let parsed = ResponseHead::parse(&s).unwrap();
320        assert_eq!(parsed, head);
321    }
322
323    #[test]
324    fn response_head_error() {
325        let head = ResponseHead::err(ErrorCode::Timeout, vec!["5000ms".into(), "vs_wait".into()]);
326        let s = head.encode();
327        assert_eq!(s, "! TIMEOUT 5000ms vs_wait\n");
328        assert_eq!(ResponseHead::parse(&s).unwrap(), head);
329    }
330}