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::{ChannelKey, MicSize, PublicKey, RegionCode, RouterHint};
12use umsh::crypto::{ChannelNameError, MAX_CHANNEL_NAME_LEN};
13use umsh::node::Channel;
14use umsh::ulcp_wire::items::Filter;
15
16pub fn parse_key32(text: &str) -> Result<[u8; 32], String> {
17    text.parse::<PublicKey>()
18        .map(|key| key.0)
19        .map_err(|error| format!("expected 44-char base58 or 64-char hex key: {error}"))
20}
21
22pub fn parse_hex<const N: usize>(text: &str) -> Result<[u8; N], String> {
23    let text = text.trim();
24    if text.len() != 2 * N || !text.chars().all(|c| c.is_ascii_hexdigit()) {
25        return Err(format!("expected {} hex characters, got {text:?}", 2 * N));
26    }
27    let mut out = [0u8; N];
28    for (index, byte) in out.iter_mut().enumerate() {
29        *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16)
30            .map_err(|error| error.to_string())?;
31    }
32    Ok(out)
33}
34
35/// A number written in decimal or with an `0x` prefix.
36pub fn parse_u32(text: &str) -> Result<u32, String> {
37    match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
38        Some(hex) => u32::from_str_radix(hex, 16),
39        None => text.parse(),
40    }
41    .map_err(|_| format!("invalid number: {text}"))
42}
43
44/// The leading bytes of a node hint, as one to three hex octets.
45///
46/// A whole hint is three bytes; a prefix of one or two narrows a
47/// discovery to a part of the address space without naming one node.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct HintPrefixArg(pub Vec<u8>);
50
51impl FromStr for HintPrefixArg {
52    type Err = String;
53
54    fn from_str(text: &str) -> Result<Self, Self::Err> {
55        let bytes = BytesArg::from_str(text)?.0;
56        if bytes.is_empty() || bytes.len() > 3 {
57            return Err(format!(
58                "a hint prefix is one to three hex octets, got {} in {text:?}",
59                bytes.len()
60            ));
61        }
62        Ok(Self(bytes))
63    }
64}
65
66/// A property value written as hex, of whatever length the property
67/// takes. Empty is a value too — it is how a string property is cleared.
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct BytesArg(pub Vec<u8>);
70
71impl FromStr for BytesArg {
72    type Err = String;
73
74    fn from_str(text: &str) -> Result<Self, Self::Err> {
75        let text = text.trim();
76        if !text.len().is_multiple_of(2) || !text.chars().all(|c| c.is_ascii_hexdigit()) {
77            return Err(format!(
78                "expected an even number of hex digits, got {text:?}"
79            ));
80        }
81        text.as_bytes()
82            .chunks_exact(2)
83            .map(|pair| {
84                u8::from_str_radix(core::str::from_utf8(pair).unwrap_or_default(), 16)
85                    .map_err(|error| error.to_string())
86            })
87            .collect::<Result<Vec<u8>, String>>()
88            .map(Self)
89    }
90}
91
92/// `PROP=HEX`: one entry of a write sequence.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct AssignArg(pub u32, pub Vec<u8>);
95
96impl FromStr for AssignArg {
97    type Err = String;
98
99    fn from_str(text: &str) -> Result<Self, Self::Err> {
100        let Some((key, value)) = text.split_once('=') else {
101            return Err(format!("expected PROP=VALUE, got {text:?}"));
102        };
103        // The same names and the same value spellings the rest of the
104        // tool takes; a sequence of writes is no place for a second
105        // vocabulary.
106        let key = key.trim().parse::<super::props::PropArg>()?;
107        let value = super::props::encode_value(key.0, value).map_err(|error| error.to_string())?;
108        Ok(Self(key.0, value))
109    }
110}
111
112/// A 32-byte public key or channel key, written as base58 or hex.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub struct KeyArg(pub [u8; 32]);
115
116impl FromStr for KeyArg {
117    type Err = String;
118
119    fn from_str(text: &str) -> Result<Self, Self::Err> {
120        parse_key32(text).map(Self)
121    }
122}
123
124/// `dest-hint:HHHHHH`, `channel-id:HHHH`, or `pkt-type:N` (`:` or
125/// whitespace between type and value).
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub struct FilterArg(pub Filter);
128
129impl FromStr for FilterArg {
130    type Err = String;
131
132    fn from_str(text: &str) -> Result<Self, Self::Err> {
133        let fields: Vec<&str> = text
134            .split(|c: char| c == ':' || c.is_whitespace())
135            .filter(|field| !field.is_empty())
136            .collect();
137        let [kind, value] = fields[..] else {
138            return Err(format!("expected filter as TYPE:VALUE, got {text:?}"));
139        };
140        let filter = match kind {
141            "dest-hint" => Filter::DestHint(parse_hex::<3>(value)?),
142            "channel-id" => Filter::ChannelId(parse_hex::<2>(value)?),
143            "pkt-type" => Filter::PktType(
144                match value.strip_prefix("0x") {
145                    Some(hex) => u8::from_str_radix(hex, 16),
146                    None => value.parse(),
147                }
148                .map_err(|error| format!("pkt-type: {error}"))?,
149            ),
150            other => {
151                return Err(format!(
152                    "unknown filter type {other:?}; expected dest-hint, channel-id, or pkt-type"
153                ));
154            }
155        };
156        Ok(Self(filter))
157    }
158}
159
160impl fmt::Display for FilterArg {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self.0 {
163            Filter::DestHint(hint) => write!(f, "dest-hint:{}", crate::output::hex(&hint)),
164            Filter::ChannelId(id) => write!(f, "channel-id:{}", crate::output::hex(&id)),
165            Filter::PktType(pkt_type) => write!(f, "pkt-type:{pkt_type}"),
166        }
167    }
168}
169
170/// A 6-digit BLE pairing PIN, or `clear` to remove the stored one.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub struct PinArg(pub Option<u32>);
173
174impl FromStr for PinArg {
175    type Err = String;
176
177    fn from_str(text: &str) -> Result<Self, Self::Err> {
178        if text == "clear" {
179            return Ok(Self(None));
180        }
181        if text.len() == 6 && text.chars().all(|c| c.is_ascii_digit()) {
182            return text
183                .parse::<u32>()
184                .map(|pin| Self(Some(pin)))
185                .map_err(|error| error.to_string());
186        }
187        Err(format!("expected a 6-digit PIN or `clear`, got {text:?}"))
188    }
189}
190
191/// A raw duty limit on the 0-65535 scale, or `off` to stop enforcing one.
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193pub struct DutyLimitArg(pub u16);
194
195impl FromStr for DutyLimitArg {
196    type Err = String;
197
198    fn from_str(text: &str) -> Result<Self, Self::Err> {
199        if text == "off" {
200            return Ok(Self(u16::MAX));
201        }
202        text.parse::<u16>()
203            .map(Self)
204            .map_err(|_| format!("expected 0-65535 or `off`, got {text:?}"))
205    }
206}
207
208/// Parse one region string — a short code, a name, or a `0x` literal.
209/// The string is what the device stores; deriving it here only proves it
210/// is within the bounds a device will accept, which rejects the empty
211/// element a stray comma leaves behind and anything past 24 octets.
212fn parse_region(text: &str) -> Result<String, String> {
213    text.parse::<RegionCode>()
214        .map_err(|error| format!("region {text:?}: {error}"))?;
215    Ok(text.to_owned())
216}
217
218/// A comma-separated region list, or `none` for the empty list — which
219/// means "no regional restriction", not a region named "none".
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct RegionListArg(pub Vec<String>);
222
223impl FromStr for RegionListArg {
224    type Err = String;
225
226    fn from_str(text: &str) -> Result<Self, Self::Err> {
227        if text.eq_ignore_ascii_case("none") {
228            return Ok(Self(Vec::new()));
229        }
230        text.split(',')
231            .map(parse_region)
232            .collect::<Result<Vec<_>, _>>()
233            .map(Self)
234    }
235}
236
237/// One region string, for the entry-at-a-time edits.
238#[derive(Clone, Debug, PartialEq, Eq)]
239pub struct RegionArg(pub String);
240
241impl FromStr for RegionArg {
242    type Err = String;
243
244    fn from_str(text: &str) -> Result<Self, Self::Err> {
245        parse_region(text).map(Self)
246    }
247}
248
249/// One region code, or `none` to clear the setting.
250#[derive(Clone, Copy, Debug, PartialEq, Eq)]
251pub struct OptRegionArg(pub Option<RegionCode>);
252
253impl FromStr for OptRegionArg {
254    type Err = String;
255
256    fn from_str(text: &str) -> Result<Self, Self::Err> {
257        if text.eq_ignore_ascii_case("none") {
258            return Ok(Self(None));
259        }
260        // The default region is stored as a code, so it derives here.
261        text.parse::<RegionCode>()
262            .map(|code| Self(Some(code)))
263            .map_err(|error| format!("region {text:?}: {error}"))
264    }
265}
266
267/// A received-signal floor in dBm, or `none` to forward regardless.
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub struct MinRssiArg(pub Option<i16>);
270
271impl FromStr for MinRssiArg {
272    type Err = String;
273
274    fn from_str(text: &str) -> Result<Self, Self::Err> {
275        if text.eq_ignore_ascii_case("none") {
276            return Ok(Self(None));
277        }
278        text.parse::<i16>()
279            .map(|dbm| Self(Some(dbm)))
280            .map_err(|_| format!("expected dBm or `none`, got {text:?}"))
281    }
282}
283
284/// A signal-to-noise floor in dB, or `none` to forward regardless.
285#[derive(Clone, Copy, Debug, PartialEq, Eq)]
286pub struct MinSnrArg(pub Option<i8>);
287
288impl FromStr for MinSnrArg {
289    type Err = String;
290
291    fn from_str(text: &str) -> Result<Self, Self::Err> {
292        if text.eq_ignore_ascii_case("none") {
293            return Ok(Self(None));
294        }
295        text.parse::<i8>()
296            .map(|db| Self(Some(db)))
297            .map_err(|_| format!("expected dB or `none`, got {text:?}"))
298    }
299}
300
301/// A 16-bit value written in decimal or hex (`0x1424`).
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub struct HexU16Arg(pub u16);
304
305impl FromStr for HexU16Arg {
306    type Err = String;
307
308    fn from_str(text: &str) -> Result<Self, Self::Err> {
309        parse_u32(text)?
310            .try_into()
311            .map(Self)
312            .map_err(|_| format!("value out of 16-bit range: {text}"))
313    }
314}
315
316/// A MIC size, written as its byte length.
317#[derive(Clone, Copy, Debug, PartialEq, Eq)]
318pub struct MicArg(pub MicSize);
319
320impl FromStr for MicArg {
321    type Err = String;
322
323    fn from_str(text: &str) -> Result<Self, Self::Err> {
324        match text {
325            "4" => Ok(Self(MicSize::Mic4)),
326            "8" => Ok(Self(MicSize::Mic8)),
327            "12" => Ok(Self(MicSize::Mic12)),
328            "16" => Ok(Self(MicSize::Mic16)),
329            other => Err(format!("expected 4, 8, 12, or 16, got {other:?}")),
330        }
331    }
332}
333
334/// One region code to stamp on an outgoing frame.
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub struct RegionCodeArg(pub RegionCode);
337
338impl FromStr for RegionCodeArg {
339    type Err = String;
340
341    fn from_str(text: &str) -> Result<Self, Self::Err> {
342        text.parse::<RegionCode>()
343            .map(Self)
344            .map_err(|error| format!("region {text:?}: {error}"))
345    }
346}
347
348/// A comma-separated source route, first hop first.
349///
350/// Each hop is either the four hex digits of a router hint, as a capture or
351/// a trace route renders it, or a full public key to derive the hint from.
352/// An empty route is not a way to say "flood"—`--flood` is—so it is
353/// rejected rather than quietly meaning something else.
354#[derive(Clone, Debug, PartialEq, Eq)]
355pub struct RouteArg(pub Vec<RouterHint>);
356
357impl FromStr for RouteArg {
358    type Err = String;
359
360    fn from_str(text: &str) -> Result<Self, Self::Err> {
361        let hops = text
362            .split(|c: char| c == ',' || c.is_whitespace())
363            .filter(|hop| !hop.is_empty())
364            .map(|hop| {
365                if hop.len() == 4 {
366                    parse_hex::<2>(hop).map(RouterHint)
367                } else {
368                    parse_key32(hop)
369                        .map(|key| RouterHint::from_public_key(&PublicKey(key)))
370                        .map_err(|error| {
371                            format!("hop {hop:?}: 4 hex digits or a full key: {error}")
372                        })
373                }
374            })
375            .collect::<Result<Vec<_>, String>>()?;
376        if hops.is_empty() {
377            return Err(String::from("a source route needs at least one hop"));
378        }
379        // The MAC rejects a longer route too; catching it here attributes the
380        // error to the flag that carried it.
381        if hops.len() > MAX_ROUTE_HOPS {
382            return Err(format!(
383                "a source route carries at most {MAX_ROUTE_HOPS} hops, got {}",
384                hops.len()
385            ));
386        }
387        Ok(Self(hops))
388    }
389}
390
391/// The MAC's ceiling on an explicit source route.
392const MAX_ROUTE_HOPS: usize = 15;
393
394/// A channel, named or given by its raw 32-byte key.
395///
396/// A key is spelled the same way a public key is, so anything that parses as
397/// one is taken for a private channel key and everything else is a channel
398/// name. The name of a key-given channel is its own derived identifier, since
399/// there is no name to show and the key must never be printed.
400#[derive(Clone, Debug, PartialEq, Eq)]
401pub struct ChannelArg(pub Channel);
402
403impl FromStr for ChannelArg {
404    type Err = String;
405
406    fn from_str(text: &str) -> Result<Self, Self::Err> {
407        if let Ok(key) = parse_key32(text) {
408            let channel = Channel::private(ChannelKey(key), "");
409            let named = Channel::private(
410                ChannelKey(key),
411                &crate::output::hex(&channel.channel_id().0),
412            );
413            return Ok(Self(named));
414        }
415        Channel::named(text).map(Self).map_err(|error| match error {
416            ChannelNameError::NotAscii => {
417                format!("channel {text:?}: names must be ASCII")
418            }
419            ChannelNameError::TooLong => {
420                format!("channel {text:?}: names are at most {MAX_CHANNEL_NAME_LEN} characters")
421            }
422        })
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    const KEY_HEX: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
431
432    #[test]
433    fn parses_filters() {
434        assert_eq!(
435            "dest-hint:a1b2c3".parse::<FilterArg>().unwrap().0,
436            Filter::DestHint([0xA1, 0xB2, 0xC3])
437        );
438        assert_eq!(
439            "channel-id 9b68".parse::<FilterArg>().unwrap().0,
440            Filter::ChannelId([0x9B, 0x68])
441        );
442        assert_eq!(
443            "pkt-type:0x0a".parse::<FilterArg>().unwrap().0,
444            Filter::PktType(10)
445        );
446        assert!("src-hint:aabbcc".parse::<FilterArg>().is_err());
447        assert!("dest-hint:zzzzzz".parse::<FilterArg>().is_err());
448    }
449
450    #[test]
451    fn pin_requires_six_digits_or_clear() {
452        assert_eq!("042319".parse::<PinArg>().unwrap(), PinArg(Some(42_319)));
453        assert_eq!("clear".parse::<PinArg>().unwrap(), PinArg(None));
454        assert!("12345".parse::<PinArg>().is_err());
455        assert!("1234567".parse::<PinArg>().is_err());
456        assert!("abcdef".parse::<PinArg>().is_err());
457    }
458
459    #[test]
460    fn duty_limit_accepts_the_raw_scale_and_off() {
461        assert_eq!("655".parse::<DutyLimitArg>().unwrap().0, 655);
462        assert_eq!("off".parse::<DutyLimitArg>().unwrap().0, u16::MAX);
463        assert!("70000".parse::<DutyLimitArg>().is_err());
464    }
465
466    #[test]
467    fn region_lists_keep_every_string_form_as_written() {
468        assert_eq!(
469            "SJC,0x7853,Rogue Valley"
470                .parse::<RegionListArg>()
471                .unwrap()
472                .0,
473            vec!["SJC", "0x7853", "Rogue Valley"]
474        );
475        // `none` is the empty list, which means "no regional
476        // restriction" — not a region literally named "none".
477        assert_eq!(
478            "none".parse::<RegionListArg>().unwrap().0,
479            Vec::<String>::new()
480        );
481        assert!("SJC,".parse::<RegionListArg>().is_err());
482        // The device's bounds are enforced here, not a round trip later.
483        assert!("A".repeat(25).parse::<RegionArg>().is_err());
484        assert_eq!(
485            "Rogue Valley".parse::<RegionArg>().unwrap().0,
486            "Rogue Valley"
487        );
488    }
489
490    #[test]
491    fn gates_accept_their_clear_forms() {
492        assert_eq!("-110".parse::<MinRssiArg>().unwrap().0, Some(-110));
493        assert_eq!("none".parse::<MinRssiArg>().unwrap().0, None);
494        assert_eq!("-7".parse::<MinSnrArg>().unwrap().0, Some(-7));
495        assert_eq!("none".parse::<MinSnrArg>().unwrap().0, None);
496        assert!("loud".parse::<MinRssiArg>().is_err());
497        assert!("-40000".parse::<MinRssiArg>().is_err());
498        assert!("-200".parse::<MinSnrArg>().is_err());
499    }
500
501    #[test]
502    fn keys_accept_base58_and_hex() {
503        let hex = KEY_HEX.parse::<KeyArg>().unwrap();
504        assert_eq!(hex.0, [0xC4; 32]);
505        let base58 = PublicKey(hex.0).to_string();
506        assert_eq!(base58.parse::<KeyArg>().unwrap().0, [0xC4; 32]);
507        assert!("nonsense".parse::<KeyArg>().is_err());
508    }
509
510    #[test]
511    fn mic_sizes_are_named_by_their_byte_length() {
512        assert_eq!("4".parse::<MicArg>().unwrap().0, MicSize::Mic4);
513        assert_eq!("16".parse::<MicArg>().unwrap().0, MicSize::Mic16);
514        assert!("10".parse::<MicArg>().is_err());
515        assert!("mic8".parse::<MicArg>().is_err());
516    }
517
518    #[test]
519    fn routes_take_hints_or_whole_keys() {
520        let route = format!("a1b2,{KEY_HEX}").parse::<RouteArg>().unwrap();
521        assert_eq!(
522            route.0,
523            vec![RouterHint([0xA1, 0xB2]), RouterHint([0xC4, 0xC4])]
524        );
525        // An empty route would silently mean "flood", which is a separate
526        // flag, so it is an error rather than a shorthand.
527        assert!("".parse::<RouteArg>().is_err());
528        assert!("a1b".parse::<RouteArg>().is_err());
529        assert!(vec!["a1b2"; 16].join(",").parse::<RouteArg>().is_err());
530    }
531
532    #[test]
533    fn channels_come_from_a_name_or_a_raw_key() {
534        let named = "public".parse::<ChannelArg>().unwrap();
535        assert_eq!(named.0, Channel::named("public").unwrap());
536        assert_eq!(named.0.name(), "public");
537
538        let private = KEY_HEX.parse::<ChannelArg>().unwrap();
539        assert_eq!(private.0.key().0, [0xC4; 32]);
540        // The key is never the display name.
541        assert!(!private.0.name().contains("c4c4"));
542        assert_eq!(
543            private.0.name(),
544            crate::output::hex(&private.0.channel_id().0)
545        );
546    }
547
548    #[test]
549    fn numbers_accept_hex_and_decimal() {
550        assert_eq!(parse_u32("915000").unwrap(), 915_000);
551        assert_eq!("0x1234".parse::<HexU16Arg>().unwrap().0, 0x1234);
552        assert!("0x1ffff".parse::<HexU16Arg>().is_err());
553    }
554}