umsh_uri/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2
3//! UMSH URI parsing and formatting helpers.
4
5extern crate alloc;
6
7use alloc::string::String;
8use core::fmt;
9
10use lwuri::prelude::*;
11
12/// Error returned when parsing or formatting `umsh:` URIs.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Error {
15    InvalidUtf8,
16    InvalidUri,
17    InvalidBase58,
18    InvalidLength { expected: usize, actual: usize },
19    BufferTooSmall,
20}
21
22impl fmt::Display for Error {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "{self:?}")
25    }
26}
27
28#[cfg(feature = "std")]
29impl std::error::Error for Error {}
30
31/// Parsed `umsh:` URI.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub enum UmshUri<'a> {
34    Node(NodeUri<'a>),
35    ChannelByName(ChannelNameUri<'a>),
36    ChannelByKey(ChannelKeyUri<'a>),
37}
38
39/// Parsed node URI.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct NodeUri<'a> {
42    pub public_key: umsh_core::PublicKey,
43    pub identity_data: Option<&'a str>,
44}
45
46/// Advisory channel metadata decoded from a URI query string.
47///
48/// String fields are the raw, still-percent-encoded slices borrowed from the
49/// URI, because decoding needs an owned buffer this borrow cannot provide. Run
50/// them through [`decode_percent`] before display.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct ChannelParams<'a> {
53    pub display_name: Option<&'a str>,
54    pub max_flood_hops: Option<u8>,
55    pub region: Option<&'a str>,
56    pub raw_query: Option<&'a str>,
57}
58
59/// Parsed named-channel URI.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub struct ChannelNameUri<'a> {
62    pub name: &'a str,
63    pub params: ChannelParams<'a>,
64}
65
66/// Parsed direct-key channel URI.
67#[derive(Clone)]
68pub struct ChannelKeyUri<'a> {
69    pub key: umsh_core::ChannelKey,
70    pub params: ChannelParams<'a>,
71}
72
73impl core::fmt::Debug for ChannelKeyUri<'_> {
74    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75        f.debug_struct("ChannelKeyUri")
76            .field("key", &&self.key.0[..])
77            .field("params", &self.params)
78            .finish()
79    }
80}
81
82impl PartialEq for ChannelKeyUri<'_> {
83    fn eq(&self, other: &Self) -> bool {
84        self.key.0 == other.key.0 && self.params == other.params
85    }
86}
87
88impl Eq for ChannelKeyUri<'_> {}
89
90/// Parse a `umsh:` URI reference.
91pub fn parse_umsh_uri<'a>(uri: &'a UriRef) -> Result<UmshUri<'a>, Error> {
92    if uri.scheme() != Some("umsh") {
93        return Err(Error::InvalidUri);
94    }
95
96    let mut parts = uri.raw_path().splitn(3, ':');
97    let kind = parts.next().ok_or(Error::InvalidUri)?;
98    let value = parts.next().ok_or(Error::InvalidUri)?;
99    let tail = parts.next();
100    let params = parse_channel_params(uri)?;
101
102    match kind {
103        "n" => Ok(UmshUri::Node(NodeUri {
104            public_key: umsh_core::PublicKey(decode_base58_32(value)?),
105            identity_data: tail,
106        })),
107        "cs" => Ok(UmshUri::ChannelByName(ChannelNameUri {
108            name: value,
109            params,
110        })),
111        "ck" => Ok(UmshUri::ChannelByKey(ChannelKeyUri {
112            key: umsh_core::ChannelKey(decode_base58_32(value)?),
113            params,
114        })),
115        _ => Err(Error::InvalidUri),
116    }
117}
118
119fn parse_channel_params<'a>(uri: &'a UriRef) -> Result<ChannelParams<'a>, Error> {
120    let mut display_name = None;
121    let mut max_flood_hops = None;
122    let mut region = None;
123
124    for (key, value) in uri.raw_query_key_values() {
125        match key {
126            "n" => display_name = Some(value),
127            "mh" => {
128                max_flood_hops = Some(value.parse::<u8>().map_err(|_| Error::InvalidUri)?);
129            }
130            "r" => region = Some(value),
131            _ => {}
132        }
133    }
134
135    Ok(ChannelParams {
136        display_name,
137        max_flood_hops,
138        region,
139        raw_query: uri.raw_query(),
140    })
141}
142
143fn decode_base58_32(input: &str) -> Result<[u8; 32], Error> {
144    umsh_core::base58::decode(input.as_bytes()).map_err(|err| match err {
145        umsh_core::AddressParseError::InvalidLength => Error::InvalidLength {
146            expected: umsh_core::base58::ENCODED_LEN,
147            actual: input.len(),
148        },
149        _ => Error::InvalidBase58,
150    })
151}
152
153fn encode_base58_32(bytes: &[u8; 32]) -> String {
154    umsh_core::base58::encode(bytes)
155        .iter()
156        .map(|&digit| char::from(digit))
157        .collect()
158}
159
160/// Base58 digit alphabet (Bitcoin variant), matching `umsh_core::base58`.
161const BASE58_ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
162
163/// Encode arbitrary-length bytes as base58 with no fixed width; leading
164/// zero bytes render as leading `1` digits. This is the encoding of the
165/// optional node-identity bundle segment of a node URI (the 32-byte key
166/// segment keeps its fixed 44-digit form).
167pub fn encode_base58_bytes(bytes: &[u8]) -> String {
168    let zeros = bytes.iter().take_while(|&&byte| byte == 0).count();
169    let mut digits: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
170    for &byte in &bytes[zeros..] {
171        let mut carry = u32::from(byte);
172        for digit in digits.iter_mut() {
173            let acc = (u32::from(*digit) << 8) + carry;
174            *digit = (acc % 58) as u8;
175            carry = acc / 58;
176        }
177        while carry > 0 {
178            digits.push((carry % 58) as u8);
179            carry /= 58;
180        }
181    }
182    let mut out = String::with_capacity(zeros + digits.len());
183    for _ in 0..zeros {
184        out.push('1');
185    }
186    for &digit in digits.iter().rev() {
187        out.push(char::from(BASE58_ALPHABET[usize::from(digit)]));
188    }
189    out
190}
191
192/// Decode a variable-length base58 string produced by
193/// [`encode_base58_bytes`].
194pub fn decode_base58_bytes(input: &str) -> Result<alloc::vec::Vec<u8>, Error> {
195    let bytes = input.as_bytes();
196    let zeros = bytes.iter().take_while(|&&byte| byte == b'1').count();
197    let mut out: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
198    for &digit in &bytes[zeros..] {
199        let value = BASE58_ALPHABET
200            .iter()
201            .position(|&c| c == digit)
202            .ok_or(Error::InvalidBase58)? as u32;
203        let mut carry = value;
204        for byte in out.iter_mut() {
205            let acc = u32::from(*byte) * 58 + carry;
206            *byte = acc as u8;
207            carry = acc >> 8;
208        }
209        while carry > 0 {
210            out.push((carry & 0xFF) as u8);
211            carry >>= 8;
212        }
213    }
214    let mut result = alloc::vec![0u8; zeros];
215    result.extend(out.iter().rev());
216    Ok(result)
217}
218
219/// Parse a base58-encoded public key.
220pub fn parse_public_key_base58(input: &str) -> Result<umsh_core::PublicKey, Error> {
221    Ok(umsh_core::PublicKey(decode_base58_32(input)?))
222}
223
224/// Parse a base58-encoded channel key.
225pub fn parse_channel_key_base58(input: &str) -> Result<umsh_core::ChannelKey, Error> {
226    Ok(umsh_core::ChannelKey(decode_base58_32(input)?))
227}
228
229/// Encode a public key as fixed-width base58.
230pub fn encode_public_key_base58(key: &umsh_core::PublicKey) -> String {
231    encode_base58_32(&key.0)
232}
233
234/// Encode a channel key as fixed-width base58.
235pub fn encode_channel_key_base58(key: &umsh_core::ChannelKey) -> String {
236    encode_base58_32(&key.0)
237}
238
239pub fn format_node_uri(key: &umsh_core::PublicKey, buf: &mut [u8]) -> Result<usize, Error> {
240    let mut pos = 0usize;
241    copy_into(buf, &mut pos, b"umsh:n:")?;
242    copy_into(buf, &mut pos, &umsh_core::base58::encode(&key.0))?;
243    Ok(pos)
244}
245
246/// Format a named-channel URI.
247///
248/// The name is percent-encoded, so a name containing a space or other
249/// non-unreserved character still yields a parseable URI. Parsers recover the
250/// original with [`decode_percent`] before canonicalizing it.
251pub fn format_channel_name_uri(name: &str, buf: &mut [u8]) -> Result<usize, Error> {
252    let mut pos = 0usize;
253    copy_into(buf, &mut pos, b"umsh:cs:")?;
254    write_percent_encoded(buf, &mut pos, name)?;
255    Ok(pos)
256}
257
258pub fn format_channel_key_uri(key: &umsh_core::ChannelKey, buf: &mut [u8]) -> Result<usize, Error> {
259    let mut pos = 0usize;
260    copy_into(buf, &mut pos, b"umsh:ck:")?;
261    copy_into(buf, &mut pos, &umsh_core::base58::encode(&key.0))?;
262    Ok(pos)
263}
264
265pub fn format_channel_name_uri_with_params(
266    name: &str,
267    params: &ChannelParams<'_>,
268    buf: &mut [u8],
269) -> Result<usize, Error> {
270    let mut pos = format_channel_name_uri(name, buf)?;
271    write_params(params, buf, &mut pos)?;
272    Ok(pos)
273}
274
275pub fn format_channel_key_uri_with_params(
276    key: &umsh_core::ChannelKey,
277    params: &ChannelParams<'_>,
278    buf: &mut [u8],
279) -> Result<usize, Error> {
280    let mut pos = format_channel_key_uri(key, buf)?;
281    write_params(params, buf, &mut pos)?;
282    Ok(pos)
283}
284
285fn write_params(params: &ChannelParams<'_>, buf: &mut [u8], pos: &mut usize) -> Result<(), Error> {
286    let mut wrote = false;
287    if let Some(display_name) = params.display_name {
288        push_byte(buf, pos, if wrote { b';' } else { b'?' })?;
289        wrote = true;
290        copy_into(buf, pos, b"n=")?;
291        write_percent_encoded(buf, pos, display_name)?;
292    }
293    if let Some(max_flood_hops) = params.max_flood_hops {
294        push_byte(buf, pos, if wrote { b';' } else { b'?' })?;
295        wrote = true;
296        copy_into(buf, pos, b"mh=")?;
297        let mut tmp = [0u8; 3];
298        let digits = write_decimal_u8(max_flood_hops, &mut tmp);
299        copy_into(buf, pos, &tmp[..digits])?;
300    }
301    if let Some(region) = params.region {
302        push_byte(buf, pos, if wrote { b';' } else { b'?' })?;
303        copy_into(buf, pos, b"r=")?;
304        write_percent_encoded(buf, pos, region)?;
305    }
306    Ok(())
307}
308
309/// Whether `byte` may appear literally in a parameter value.
310///
311/// RFC 3986 unreserved only. That is stricter than the query grammar allows,
312/// but a channel display name is arbitrary user text and the separators this
313/// crate writes (`?`, `;`, `=`) must never appear unescaped inside a value.
314fn is_unreserved(byte: u8) -> bool {
315    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
316}
317
318fn write_percent_encoded(buf: &mut [u8], pos: &mut usize, value: &str) -> Result<(), Error> {
319    const HEX: &[u8; 16] = b"0123456789ABCDEF";
320    for &byte in value.as_bytes() {
321        if is_unreserved(byte) {
322            push_byte(buf, pos, byte)?;
323        } else {
324            copy_into(
325                buf,
326                pos,
327                &[b'%', HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]],
328            )?;
329        }
330    }
331    Ok(())
332}
333
334/// Decode percent-escapes in a URI component.
335///
336/// Parsed [`ChannelParams`] borrow raw slices from the URI; this turns one into
337/// displayable text. An escape that is truncated or not valid hex is preserved
338/// verbatim rather than rejected, and invalid UTF-8 is reported.
339pub fn decode_percent(value: &str) -> Result<String, Error> {
340    let bytes = value.as_bytes();
341    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
342    let mut index = 0usize;
343    while index < bytes.len() {
344        match (bytes[index], bytes.get(index + 1), bytes.get(index + 2)) {
345            (b'%', Some(high), Some(low)) => match (hex_value(*high), hex_value(*low)) {
346                (Some(high), Some(low)) => {
347                    out.push((high << 4) | low);
348                    index += 3;
349                }
350                _ => {
351                    out.push(b'%');
352                    index += 1;
353                }
354            },
355            (byte, _, _) => {
356                out.push(byte);
357                index += 1;
358            }
359        }
360    }
361    String::from_utf8(out).map_err(|_| Error::InvalidUtf8)
362}
363
364fn hex_value(byte: u8) -> Option<u8> {
365    match byte {
366        b'0'..=b'9' => Some(byte - b'0'),
367        b'a'..=b'f' => Some(byte - b'a' + 10),
368        b'A'..=b'F' => Some(byte - b'A' + 10),
369        _ => None,
370    }
371}
372
373fn write_decimal_u8(value: u8, out: &mut [u8; 3]) -> usize {
374    if value >= 100 {
375        out[0] = b'0' + value / 100;
376        out[1] = b'0' + (value / 10) % 10;
377        out[2] = b'0' + value % 10;
378        3
379    } else if value >= 10 {
380        out[0] = b'0' + value / 10;
381        out[1] = b'0' + value % 10;
382        2
383    } else {
384        out[0] = b'0' + value;
385        1
386    }
387}
388
389fn copy_into(dst: &mut [u8], pos: &mut usize, src: &[u8]) -> Result<(), Error> {
390    if dst.len().saturating_sub(*pos) < src.len() {
391        return Err(Error::BufferTooSmall);
392    }
393    dst[*pos..*pos + src.len()].copy_from_slice(src);
394    *pos += src.len();
395    Ok(())
396}
397
398fn push_byte(dst: &mut [u8], pos: &mut usize, byte: u8) -> Result<(), Error> {
399    copy_into(dst, pos, &[byte])
400}
401
402#[cfg(test)]
403mod tests {
404    use lwuri::UriRef;
405
406    use super::*;
407
408    #[test]
409    fn variable_base58_round_trips_and_matches_fixed_width() {
410        for bytes in [
411            &[][..],
412            &[0u8][..],
413            &[0, 0, 1, 2, 3][..],
414            &[0xFF; 100][..],
415            b"role-caps-options-and-signature".as_slice(),
416        ] {
417            let encoded = encode_base58_bytes(bytes);
418            assert_eq!(decode_base58_bytes(&encoded).unwrap(), bytes);
419        }
420
421        // A 32-byte value with no leading zeros must agree with the
422        // fixed-width address codec (which only differs by `1` padding).
423        let key = [0xFFu8; 32];
424        let fixed: alloc::string::String = umsh_core::base58::encode(&key)
425            .into_iter()
426            .map(char::from)
427            .collect();
428        assert_eq!(encode_base58_bytes(&key), fixed);
429
430        assert_eq!(
431            decode_base58_bytes("not-base58!"),
432            Err(Error::InvalidBase58)
433        );
434    }
435
436    #[test]
437    fn uri_parse_and_format_cover_node_channel_name_and_key() {
438        let key = umsh_core::PublicKey([0x33; 32]);
439        let mut buf = [0u8; 128];
440        let node_len = format_node_uri(&key, &mut buf).unwrap();
441        let node_uri = UriRef::from_str(core::str::from_utf8(&buf[..node_len]).unwrap()).unwrap();
442        match parse_umsh_uri(node_uri).unwrap() {
443            UmshUri::Node(parsed) => assert_eq!(parsed.public_key, key),
444            _ => panic!("expected node uri"),
445        }
446
447        let params = ChannelParams {
448            display_name: Some("Local"),
449            max_flood_hops: Some(6),
450            region: Some("Eugine"),
451            raw_query: None,
452        };
453        let channel_name_len =
454            format_channel_name_uri_with_params("Public", &params, &mut buf).unwrap();
455        let channel_name_uri =
456            UriRef::from_str(core::str::from_utf8(&buf[..channel_name_len]).unwrap()).unwrap();
457        match parse_umsh_uri(channel_name_uri).unwrap() {
458            UmshUri::ChannelByName(parsed) => {
459                assert_eq!(parsed.name, "Public");
460                assert_eq!(parsed.params.display_name, Some("Local"));
461                assert_eq!(parsed.params.max_flood_hops, Some(6));
462                assert_eq!(parsed.params.region, Some("Eugine"));
463            }
464            _ => panic!("expected channel name uri"),
465        }
466
467        let channel_key = umsh_core::ChannelKey([0x44; 32]);
468        let channel_key_len = format_channel_key_uri(&channel_key, &mut buf).unwrap();
469        let channel_key_uri =
470            UriRef::from_str(core::str::from_utf8(&buf[..channel_key_len]).unwrap()).unwrap();
471        match parse_umsh_uri(channel_key_uri).unwrap() {
472            UmshUri::ChannelByKey(parsed) => assert_eq!(parsed.key.0, channel_key.0),
473            _ => panic!("expected channel key uri"),
474        }
475    }
476
477    #[test]
478    fn channel_key_uri_carries_invitation_params() {
479        let channel_key = umsh_core::ChannelKey([0x55; 32]);
480        let params = ChannelParams {
481            display_name: Some("Trail Crew"),
482            max_flood_hops: Some(3),
483            region: Some("SJC"),
484            raw_query: None,
485        };
486        let mut buf = [0u8; 128];
487        let len = format_channel_key_uri_with_params(&channel_key, &params, &mut buf).unwrap();
488        let uri = UriRef::from_str(core::str::from_utf8(&buf[..len]).unwrap()).unwrap();
489        match parse_umsh_uri(uri).unwrap() {
490            UmshUri::ChannelByKey(parsed) => {
491                assert_eq!(parsed.key.0, channel_key.0);
492                // Parsed params borrow the raw slice, so the space stays escaped
493                // until the consumer decodes it.
494                assert_eq!(parsed.params.display_name, Some("Trail%20Crew"));
495                assert_eq!(
496                    decode_percent(parsed.params.display_name.unwrap()).unwrap(),
497                    "Trail Crew"
498                );
499                assert_eq!(parsed.params.max_flood_hops, Some(3));
500                assert_eq!(parsed.params.region, Some("SJC"));
501            }
502            _ => panic!("expected channel key uri"),
503        }
504    }
505
506    #[test]
507    fn param_values_escape_separators_and_round_trip() {
508        // A display name containing this crate's own separators must not be
509        // able to forge additional parameters.
510        let channel_key = umsh_core::ChannelKey([0x88; 32]);
511        let hostile = "a?b;c=d&e/f";
512        let params = ChannelParams {
513            display_name: Some(hostile),
514            max_flood_hops: None,
515            region: None,
516            raw_query: None,
517        };
518        let mut buf = [0u8; 160];
519        let len = format_channel_key_uri_with_params(&channel_key, &params, &mut buf).unwrap();
520        let uri = UriRef::from_str(core::str::from_utf8(&buf[..len]).unwrap()).unwrap();
521        match parse_umsh_uri(uri).unwrap() {
522            UmshUri::ChannelByKey(parsed) => {
523                assert_eq!(parsed.params.max_flood_hops, None);
524                assert_eq!(parsed.params.region, None);
525                assert_eq!(
526                    decode_percent(parsed.params.display_name.unwrap()).unwrap(),
527                    hostile
528                );
529            }
530            _ => panic!("expected channel key uri"),
531        }
532    }
533
534    #[test]
535    fn decode_percent_preserves_malformed_escapes() {
536        assert_eq!(decode_percent("plain").unwrap(), "plain");
537        assert_eq!(decode_percent("a%2Fb").unwrap(), "a/b");
538        assert_eq!(decode_percent("100%").unwrap(), "100%");
539        assert_eq!(decode_percent("%zz").unwrap(), "%zz");
540        assert_eq!(decode_percent("%e2%82%ac").unwrap(), "€");
541        assert_eq!(decode_percent("%ff"), Err(Error::InvalidUtf8));
542    }
543
544    #[test]
545    fn channel_key_uri_without_params_matches_the_bare_form() {
546        let channel_key = umsh_core::ChannelKey([0x66; 32]);
547        let empty = ChannelParams {
548            display_name: None,
549            max_flood_hops: None,
550            region: None,
551            raw_query: None,
552        };
553        let mut with_params = [0u8; 128];
554        let mut bare = [0u8; 128];
555        let with_len =
556            format_channel_key_uri_with_params(&channel_key, &empty, &mut with_params).unwrap();
557        let bare_len = format_channel_key_uri(&channel_key, &mut bare).unwrap();
558        assert_eq!(&with_params[..with_len], &bare[..bare_len]);
559    }
560
561    #[test]
562    fn channel_key_uri_with_params_reports_a_short_buffer() {
563        let channel_key = umsh_core::ChannelKey([0x77; 32]);
564        let params = ChannelParams {
565            display_name: Some("Trail Crew"),
566            max_flood_hops: None,
567            region: None,
568            raw_query: None,
569        };
570        // Room for `umsh:ck:` and the 44-character key, but not the query.
571        let mut buf = [0u8; 52];
572        assert_eq!(
573            format_channel_key_uri_with_params(&channel_key, &params, &mut buf),
574            Err(Error::BufferTooSmall)
575        );
576    }
577
578    #[test]
579    fn base58_key_helpers_round_trip() {
580        let public_key = umsh_core::PublicKey([0x33; 32]);
581        let channel_key = umsh_core::ChannelKey([0x77; 32]);
582
583        let public_text = encode_public_key_base58(&public_key);
584        let channel_text = encode_channel_key_base58(&channel_key);
585
586        assert_eq!(parse_public_key_base58(&public_text).unwrap(), public_key);
587        assert_eq!(
588            parse_channel_key_base58(&channel_text).unwrap(),
589            channel_key
590        );
591    }
592}