shep_core/protocol/request/smit.rs
1//! [`Smit`], the validated marker a sheep carries, and its parse error.
2
3use core::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7/// A short marker a dog attaches to a sheep for `shep flock` to paint.
8///
9/// shep stores and prints it, never parses it: `▲ main@a1b2c3` is a
10/// deploy tool's sentence.
11///
12/// The grammar: non-empty once whitespace is discounted, at most
13/// [`Self::MAX_CHARS`] characters, no [`char::is_control`] character,
14/// `\u{1b}` included. Refused, never repaired, and validated here rather
15/// than at the renderer: `shep`'s own `output::width::sanitize_cell` keeps
16/// a well-formed CSI sequence, since shep's colouring is made of them.
17///
18/// [`Self::MAX_CHARS`] counts `char`s, not bytes: a byte cap would refuse a
19/// legitimate CJK smit at roughly a third of its apparent length.
20///
21/// `Debug` is derived: a smit carries no secret, so there is nothing to
22/// redact.
23///
24/// # Example
25/// ```
26/// use shep_core::protocol::Smit;
27///
28/// assert_eq!("▲ main@a1b2c3".parse::<Smit>()?.as_str(), "▲ main@a1b2c3");
29/// assert!("\u{1b}[2Jgone".parse::<Smit>().is_err()); // no escapes
30/// # Ok::<(), shep_core::protocol::SmitError>(())
31/// ```
32// wire format: changing this is a breaking change
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Smit(String);
35
36impl Smit {
37 /// The longest a smit may be, in characters.
38 pub const MAX_CHARS: usize = 48;
39
40 /// The marker as text, exactly as its publisher sent it.
41 #[must_use]
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45}
46
47impl fmt::Display for Smit {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(&self.0)
50 }
51}
52
53impl core::str::FromStr for Smit {
54 type Err = SmitError;
55
56 /// # Errors
57 /// - [`SmitError::Empty`] if the text is nothing but whitespace.
58 /// - [`SmitError::TooLong`] if it is over [`Self::MAX_CHARS`] characters.
59 /// - [`SmitError::Unprintable`] if it holds a control character.
60 fn from_str(text: &str) -> Result<Self, Self::Err> {
61 if text.trim().is_empty() {
62 return Err(SmitError::Empty);
63 }
64 let chars = text.chars().count();
65 if chars > Self::MAX_CHARS {
66 return Err(SmitError::TooLong { chars });
67 }
68 if text.chars().any(char::is_control) {
69 return Err(SmitError::Unprintable);
70 }
71 Ok(Self(text.to_string()))
72 }
73}
74
75/// Validates on decode: a dog written in another language speaks this wire
76/// directly and never runs [`core::str::FromStr`], so a derived impl would
77/// let `\u{1b}[2J` reach every listing built from a smit.
78impl<'de> Deserialize<'de> for Smit {
79 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
80 // String, not &str: a non-borrowing deserializer cannot always borrow
81 let text = String::deserialize(deserializer)?;
82 text.parse().map_err(serde::de::Error::custom)
83 }
84}
85
86/// Why a string is not a [`Smit`].
87///
88/// `#[non_exhaustive]`: the grammar can gain a reason to reject, and an
89/// out-of-tree consumer matching exhaustively would break on a new variant
90/// with no version bump to say so. It buys nothing on the wire. This enum is
91/// not serialized: a smit that fails to decode arrives as a serde error
92/// carrying the [`fmt::Display`] text below.
93#[non_exhaustive]
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SmitError {
96 /// Over [`Smit::MAX_CHARS`] characters; carries the count that was sent.
97 TooLong {
98 /// How many characters the string held.
99 chars: usize,
100 },
101 /// A control character, `\u{1b}` included.
102 Unprintable,
103 /// Empty, or nothing but whitespace.
104 Empty,
105}
106
107impl fmt::Display for SmitError {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::TooLong { chars } => write!(
111 f,
112 "a smit is at most {} characters; this one is {chars}",
113 Smit::MAX_CHARS
114 ),
115 Self::Unprintable => {
116 f.write_str("a smit may not contain a control character, an escape included")
117 }
118 Self::Empty => f.write_str("a smit may not be empty"),
119 }
120 }
121}
122
123impl core::error::Error for SmitError {}
124
125#[cfg(test)]
126mod tests {
127 use super::super::{Envelope, Request, SelectorSpec};
128 use super::*;
129
130 /// A dog written in another language speaks this wire directly and never
131 /// runs `FromStr`.
132 #[test]
133 fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
134 for bad in [
135 r#""\u001b[2Jgone""#.to_string(), // an escape
136 r#""a\nb""#.to_string(), // a newline
137 r#""""#.to_string(), // empty
138 r#"" ""#.to_string(), // whitespace
139 format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
140 ] {
141 assert!(
142 serde_json::from_str::<Smit>(&bad).is_err(),
143 "a daemon must refuse this on the wire: {bad}"
144 );
145 }
146 assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
147 }
148
149 /// The hand-written `Deserialize` agrees with the derived `Serialize`
150 /// only while the serialize side stays transparent.
151 #[test]
152 fn a_smit_travels_as_a_bare_string() {
153 let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
154 let json = serde_json::to_string(&smit).unwrap();
155 assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
156 assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
157 }
158
159 #[test]
160 fn a_smit_is_capped_in_characters_not_bytes() {
161 let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
162 assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
163 assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
164 assert_eq!(
165 "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
166 Err(SmitError::TooLong {
167 chars: Smit::MAX_CHARS + 1
168 })
169 );
170 }
171
172 #[test]
173 fn a_smit_is_stored_exactly_as_it_arrived() {
174 let padded: Smit = " main@a1b2c3 ".parse().expect("valid");
175 assert_eq!(padded.as_str(), " main@a1b2c3 ");
176 assert_eq!(padded.to_string(), " main@a1b2c3 ");
177 }
178
179 #[test]
180 fn v1_fixture_still_deserializes() {
181 // Committed byte fixture from protocol v1. If this breaks, bump
182 // PROTOCOL_VERSION and record it in the CHANGELOG.
183 let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
184 let env: Envelope = serde_json::from_str(fixture).unwrap();
185 assert_eq!(env.id, 7);
186 assert!(matches!(
187 env.body,
188 Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
189 ));
190 }
191}