umshctl/command/
values.rs

1//! `FromStr`-backed value parsers for the command tree.
2//!
3//! clap turns each of these into a typed argument, so a bad value is
4//! reported against the flag or positional that carried it instead of
5//! being re-checked by hand after parsing. The parsing rules themselves
6//! are unchanged from the hand-rolled tool these replaced.
7
8use std::fmt;
9use std::str::FromStr;
10
11use umsh::core::{PublicKey, RegionCode};
12use umsh::ulcp_wire::items::{Filter, PeerKeyEntry};
13
14pub fn parse_key32(text: &str) -> Result<[u8; 32], String> {
15    text.parse::<PublicKey>()
16        .map(|key| key.0)
17        .map_err(|error| format!("expected 44-char base58 or 64-char hex key: {error}"))
18}
19
20pub fn parse_hex<const N: usize>(text: &str) -> Result<[u8; N], String> {
21    let text = text.trim();
22    if text.len() != 2 * N || !text.chars().all(|c| c.is_ascii_hexdigit()) {
23        return Err(format!("expected {} hex characters, got {text:?}", 2 * N));
24    }
25    let mut out = [0u8; N];
26    for (index, byte) in out.iter_mut().enumerate() {
27        *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16)
28            .map_err(|error| error.to_string())?;
29    }
30    Ok(out)
31}
32
33pub fn parse_bool(text: &str) -> Result<bool, String> {
34    match text {
35        "on" | "true" | "1" => Ok(true),
36        "off" | "false" | "0" => Ok(false),
37        other => Err(format!("expected on or off, got {other:?}")),
38    }
39}
40
41/// A number written in decimal or with an `0x` prefix.
42pub fn parse_u32(text: &str) -> Result<u32, String> {
43    match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
44        Some(hex) => u32::from_str_radix(hex, 16),
45        None => text.parse(),
46    }
47    .map_err(|_| format!("invalid number: {text}"))
48}
49
50/// A 32-byte public key or channel key, written as base58 or hex.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct KeyArg(pub [u8; 32]);
53
54impl FromStr for KeyArg {
55    type Err = String;
56
57    fn from_str(text: &str) -> Result<Self, Self::Err> {
58        parse_key32(text).map(Self)
59    }
60}
61
62/// `PUB,KENC,KMIC` (or whitespace-separated): a peer public key and the
63/// two 16-byte pairwise secrets, hex.
64#[derive(Clone, Copy, PartialEq, Eq)]
65pub struct PeerArg(pub PeerKeyEntry);
66
67impl FromStr for PeerArg {
68    type Err = String;
69
70    fn from_str(text: &str) -> Result<Self, Self::Err> {
71        let fields: Vec<&str> = text
72            .split(|c: char| c == ',' || c.is_whitespace())
73            .filter(|field| !field.is_empty())
74            .collect();
75        let [public_key, k_enc, k_mic] = fields[..] else {
76            return Err(format!(
77                "expected peer as PUB,KENC,KMIC (got {} fields)",
78                fields.len()
79            ));
80        };
81        Ok(Self(PeerKeyEntry {
82            public_key: parse_key32(public_key)?,
83            k_enc: parse_hex::<16>(k_enc)?,
84            k_mic: parse_hex::<16>(k_mic)?,
85        }))
86    }
87}
88
89/// Redacted: a peer entry is two pairwise secrets, and `--help`-adjacent
90/// machinery has no business rendering them.
91impl fmt::Debug for PeerArg {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "PeerArg({}, <secrets>)", PublicKey(self.0.public_key))
94    }
95}
96
97/// `dest-hint:HHHHHH`, `channel-id:HHHH`, or `pkt-type:N` (`:` or
98/// whitespace between type and value).
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct FilterArg(pub Filter);
101
102impl FromStr for FilterArg {
103    type Err = String;
104
105    fn from_str(text: &str) -> Result<Self, Self::Err> {
106        let fields: Vec<&str> = text
107            .split(|c: char| c == ':' || c.is_whitespace())
108            .filter(|field| !field.is_empty())
109            .collect();
110        let [kind, value] = fields[..] else {
111            return Err(format!("expected filter as TYPE:VALUE, got {text:?}"));
112        };
113        let filter = match kind {
114            "dest-hint" => Filter::DestHint(parse_hex::<3>(value)?),
115            "channel-id" => Filter::ChannelId(parse_hex::<2>(value)?),
116            "pkt-type" => Filter::PktType(
117                match value.strip_prefix("0x") {
118                    Some(hex) => u8::from_str_radix(hex, 16),
119                    None => value.parse(),
120                }
121                .map_err(|error| format!("pkt-type: {error}"))?,
122            ),
123            other => {
124                return Err(format!(
125                    "unknown filter type {other:?}; expected dest-hint, channel-id, or pkt-type"
126                ));
127            }
128        };
129        Ok(Self(filter))
130    }
131}
132
133impl fmt::Display for FilterArg {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self.0 {
136            Filter::DestHint(hint) => write!(f, "dest-hint:{}", crate::output::hex(&hint)),
137            Filter::ChannelId(id) => write!(f, "channel-id:{}", crate::output::hex(&id)),
138            Filter::PktType(pkt_type) => write!(f, "pkt-type:{pkt_type}"),
139        }
140    }
141}
142
143/// A 6-digit BLE pairing PIN, or `clear` to remove the stored one.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub struct PinArg(pub Option<u32>);
146
147impl FromStr for PinArg {
148    type Err = String;
149
150    fn from_str(text: &str) -> Result<Self, Self::Err> {
151        if text == "clear" {
152            return Ok(Self(None));
153        }
154        if text.len() == 6 && text.chars().all(|c| c.is_ascii_digit()) {
155            return text
156                .parse::<u32>()
157                .map(|pin| Self(Some(pin)))
158                .map_err(|error| error.to_string());
159        }
160        Err(format!("expected a 6-digit PIN or `clear`, got {text:?}"))
161    }
162}
163
164/// A raw duty limit on the 0-65535 scale, or `off` to stop enforcing one.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub struct DutyLimitArg(pub u16);
167
168impl FromStr for DutyLimitArg {
169    type Err = String;
170
171    fn from_str(text: &str) -> Result<Self, Self::Err> {
172        if text == "off" {
173            return Ok(Self(u16::MAX));
174        }
175        text.parse::<u16>()
176            .map(Self)
177            .map_err(|_| format!("expected 0-65535 or `off`, got {text:?}"))
178    }
179}
180
181/// Parse one region code, rejecting the empty element a stray comma
182/// leaves behind. Anything that is not an airport code or `0x` literal
183/// is hashed as a region name, so this only fails on empty input.
184fn parse_region(text: &str) -> Result<RegionCode, String> {
185    text.parse::<RegionCode>()
186        .map_err(|error| format!("region {text:?}: {error}"))
187}
188
189/// A comma-separated region list, or `none` for the empty list — which
190/// means "no regional restriction", not a region named "none".
191#[derive(Clone, Debug, PartialEq, Eq)]
192pub struct RegionListArg(pub Vec<RegionCode>);
193
194impl FromStr for RegionListArg {
195    type Err = String;
196
197    fn from_str(text: &str) -> Result<Self, Self::Err> {
198        if text.eq_ignore_ascii_case("none") {
199            return Ok(Self(Vec::new()));
200        }
201        text.split(',')
202            .map(parse_region)
203            .collect::<Result<Vec<_>, _>>()
204            .map(Self)
205    }
206}
207
208/// One region code, or `none` to clear the setting.
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub struct OptRegionArg(pub Option<RegionCode>);
211
212impl FromStr for OptRegionArg {
213    type Err = String;
214
215    fn from_str(text: &str) -> Result<Self, Self::Err> {
216        if text.eq_ignore_ascii_case("none") {
217            return Ok(Self(None));
218        }
219        parse_region(text).map(|code| Self(Some(code)))
220    }
221}
222
223/// A received-signal floor in dBm, or `none` to forward regardless.
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub struct MinRssiArg(pub Option<i16>);
226
227impl FromStr for MinRssiArg {
228    type Err = String;
229
230    fn from_str(text: &str) -> Result<Self, Self::Err> {
231        if text.eq_ignore_ascii_case("none") {
232            return Ok(Self(None));
233        }
234        text.parse::<i16>()
235            .map(|dbm| Self(Some(dbm)))
236            .map_err(|_| format!("expected dBm or `none`, got {text:?}"))
237    }
238}
239
240/// A signal-to-noise floor in dB, or `none` to forward regardless.
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
242pub struct MinSnrArg(pub Option<i8>);
243
244impl FromStr for MinSnrArg {
245    type Err = String;
246
247    fn from_str(text: &str) -> Result<Self, Self::Err> {
248        if text.eq_ignore_ascii_case("none") {
249            return Ok(Self(None));
250        }
251        text.parse::<i8>()
252            .map(|db| Self(Some(db)))
253            .map_err(|_| format!("expected dB or `none`, got {text:?}"))
254    }
255}
256
257/// An `on`/`off` switch, also accepting the `true`/`false` and `1`/`0`
258/// spellings the provisioning file has always allowed.
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub struct OnOffArg(pub bool);
261
262impl FromStr for OnOffArg {
263    type Err = String;
264
265    fn from_str(text: &str) -> Result<Self, Self::Err> {
266        parse_bool(text).map(Self)
267    }
268}
269
270/// A 16-bit value written in decimal or hex (`0x1424`).
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct HexU16Arg(pub u16);
273
274impl FromStr for HexU16Arg {
275    type Err = String;
276
277    fn from_str(text: &str) -> Result<Self, Self::Err> {
278        parse_u32(text)?
279            .try_into()
280            .map(Self)
281            .map_err(|_| format!("value out of 16-bit range: {text}"))
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    const KEY_HEX: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
290
291    #[test]
292    fn parses_filters() {
293        assert_eq!(
294            "dest-hint:a1b2c3".parse::<FilterArg>().unwrap().0,
295            Filter::DestHint([0xA1, 0xB2, 0xC3])
296        );
297        assert_eq!(
298            "channel-id 9b68".parse::<FilterArg>().unwrap().0,
299            Filter::ChannelId([0x9B, 0x68])
300        );
301        assert_eq!(
302            "pkt-type:0x0a".parse::<FilterArg>().unwrap().0,
303            Filter::PktType(10)
304        );
305        assert!("src-hint:aabbcc".parse::<FilterArg>().is_err());
306        assert!("dest-hint:zzzzzz".parse::<FilterArg>().is_err());
307    }
308
309    #[test]
310    fn pin_requires_six_digits_or_clear() {
311        assert_eq!("042319".parse::<PinArg>().unwrap(), PinArg(Some(42_319)));
312        assert_eq!("clear".parse::<PinArg>().unwrap(), PinArg(None));
313        assert!("12345".parse::<PinArg>().is_err());
314        assert!("1234567".parse::<PinArg>().is_err());
315        assert!("abcdef".parse::<PinArg>().is_err());
316    }
317
318    #[test]
319    fn duty_limit_accepts_the_raw_scale_and_off() {
320        assert_eq!("655".parse::<DutyLimitArg>().unwrap().0, 655);
321        assert_eq!("off".parse::<DutyLimitArg>().unwrap().0, u16::MAX);
322        assert!("70000".parse::<DutyLimitArg>().is_err());
323    }
324
325    #[test]
326    fn region_lists_parse_every_code_form() {
327        assert_eq!(
328            "SJC,0x7853,Rogue Valley"
329                .parse::<RegionListArg>()
330                .unwrap()
331                .0,
332            vec![
333                RegionCode::from_iata("SJC").unwrap(),
334                RegionCode::from_u16(0x7853),
335                RegionCode::from_name("Rogue Valley"),
336            ]
337        );
338        // `none` is the empty list, which means "no regional
339        // restriction" — not a region literally named "none".
340        assert_eq!("none".parse::<RegionListArg>().unwrap().0, Vec::new());
341        assert!("SJC,".parse::<RegionListArg>().is_err());
342    }
343
344    #[test]
345    fn gates_accept_their_clear_forms() {
346        assert_eq!("-110".parse::<MinRssiArg>().unwrap().0, Some(-110));
347        assert_eq!("none".parse::<MinRssiArg>().unwrap().0, None);
348        assert_eq!("-7".parse::<MinSnrArg>().unwrap().0, Some(-7));
349        assert_eq!("none".parse::<MinSnrArg>().unwrap().0, None);
350        assert!("loud".parse::<MinRssiArg>().is_err());
351        assert!("-40000".parse::<MinRssiArg>().is_err());
352        assert!("-200".parse::<MinSnrArg>().is_err());
353    }
354
355    #[test]
356    fn keys_accept_base58_and_hex() {
357        let hex = KEY_HEX.parse::<KeyArg>().unwrap();
358        assert_eq!(hex.0, [0xC4; 32]);
359        let base58 = PublicKey(hex.0).to_string();
360        assert_eq!(base58.parse::<KeyArg>().unwrap().0, [0xC4; 32]);
361        assert!("nonsense".parse::<KeyArg>().is_err());
362    }
363
364    #[test]
365    fn peer_entries_carry_both_pairwise_secrets() {
366        let peer: PeerArg = format!("{KEY_HEX},{},{}", "e0".repeat(16), "50".repeat(16))
367            .parse()
368            .unwrap();
369        assert_eq!(peer.0.public_key, [0xC4; 32]);
370        assert_eq!(peer.0.k_enc, [0xE0; 16]);
371        assert_eq!(peer.0.k_mic, [0x50; 16]);
372        // Secrets are never rendered, not even by a debug format.
373        assert!(!format!("{peer:?}").contains("e0e0"));
374    }
375
376    #[test]
377    fn numbers_accept_hex_and_decimal() {
378        assert_eq!(parse_u32("915000").unwrap(), 915_000);
379        assert_eq!("0x1234".parse::<HexU16Arg>().unwrap().0, 0x1234);
380        assert!("0x1ffff".parse::<HexU16Arg>().is_err());
381    }
382}