Skip to main content

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}