Skip to main content

weida_core/
addr.rs

1//! Endpoint addressing: `weida://[fingerprint@]host:port/path`.
2//!
3//! The path is an **opaque identifier** (master doc §4). Nothing in the core
4//! interprets its structure: no hierarchy, no wildcards, no topic semantics.
5//!
6//! The optional fingerprint in the userinfo position names the peer expected to
7//! answer: `weida://sha256:9f86…@10.0.0.8:7443/samples` is a complete
8//! description of *where* to dial and *whom* to accept, in one string that a
9//! discovery record, a config line or a pasted terminal line can carry.
10
11use std::fmt;
12
13use crate::error::Error;
14use crate::identity::Fingerprint;
15
16/// URL scheme of the native transport.
17pub const SCHEME: &str = "weida";
18
19/// Maximum endpoint path length in bytes (also the wire cap for DATA key 0).
20pub const MAX_PATH_BYTES: usize = 512;
21
22/// Validates an endpoint path.
23///
24/// A path MUST start with `/`, be 1..=[`MAX_PATH_BYTES`] bytes long and contain
25/// no byte below `0x20`. Every other byte sequence is accepted: the path is an
26/// opaque identifier, so `*`, `..` and `%20` carry no meaning here.
27pub fn validate_endpoint_path(path: &str) -> Result<(), Error> {
28    if path.is_empty() || path.len() > MAX_PATH_BYTES {
29        return Err(Error::InvalidEndpointPath);
30    }
31    if !path.starts_with('/') {
32        return Err(Error::InvalidEndpointPath);
33    }
34    if path.bytes().any(|b| b < 0x20) {
35        return Err(Error::InvalidEndpointPath);
36    }
37    Ok(())
38}
39
40/// The port a `weida://` URL means when it writes none.
41///
42/// A portless URL does not mean "guess": it means the authority names a **set**
43/// of nodes rather than one, which is what a Kubernetes headless service
44/// answers with, and every node of such a set listens on the same port
45/// ([decisions/0020](../../../docs/decisions/0020-cluster-and-discovery.md)
46/// §4.2).
47pub const DEFAULT_PORT: u16 = 7443;
48
49/// A parsed `weida://[fingerprint@]host[:port]/path` address.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct EndpointAddr {
52    /// Host as written: a DNS name or an IP literal without brackets.
53    pub host: String,
54    /// Port as written, or `None` when the URL names none.
55    ///
56    /// `None` is not a missing value: it is the statement that the authority
57    /// names a **set** of equivalent nodes, resolved through DNS and dialled
58    /// on [`DEFAULT_PORT`] ([decisions/0020](../../../docs/decisions/0020-cluster-and-discovery.md)
59    /// §4.2). A written port means exactly one endpoint and no discovery.
60    pub port: Option<u16>,
61    /// Opaque endpoint identifier, starting with `/`.
62    pub path: String,
63    /// The peer's public-key fingerprint, when the address names one.
64    ///
65    /// When present it is the only identity the dialling side accepts for this
66    /// address, whatever else it trusts.
67    pub peer: Option<Fingerprint>,
68}
69
70impl EndpointAddr {
71    /// Parses a `weida://[fingerprint@]host[:port]/path` URL.
72    ///
73    /// The port is **optional**, and its absence is meaningful rather than
74    /// lenient: it selects discovery, where the host names a set of nodes and
75    /// [`DEFAULT_PORT`] is the port (0020 §4.2). IPv6 literals are bracketed,
76    /// the fingerprint is in [`Fingerprint`]'s text form, and the path is
77    /// validated by [`validate_endpoint_path`].
78    pub fn parse(input: &str) -> Result<EndpointAddr, Error> {
79        let invalid = |m: &str| Error::InvalidAddress(format!("{m}: {input:?}"));
80
81        let rest = input
82            .strip_prefix(SCHEME)
83            .and_then(|r| r.strip_prefix("://"))
84            .ok_or_else(|| invalid("expected scheme weida://"))?;
85
86        let (authority, path) = match rest.find('/') {
87            Some(i) => rest.split_at(i),
88            None => return Err(invalid("missing endpoint path")),
89        };
90
91        let (peer, authority) = match authority.rsplit_once('@') {
92            Some((fp, rest)) => {
93                let peer = fp
94                    .parse::<Fingerprint>()
95                    .map_err(|_| invalid("expected sha256:<64 hex digits> before '@'"))?;
96                (Some(peer), rest)
97            }
98            None => (None, authority),
99        };
100
101        let (host, port_str) = if let Some(after) = authority.strip_prefix('[') {
102            let close = after
103                .find(']')
104                .ok_or_else(|| invalid("unterminated IPv6 literal"))?;
105            let host = &after[..close];
106            let rest = &after[close + 1..];
107            match rest.strip_prefix(':') {
108                Some(port) => (host, Some(port)),
109                None if rest.is_empty() => (host, None),
110                None => return Err(invalid("expected ':port' after an IPv6 literal")),
111            }
112        } else {
113            match authority.rsplit_once(':') {
114                Some((h, p)) => (h, Some(p)),
115                // No colon at all: a bare name, which is the discovery form.
116                None => (authority, None),
117            }
118        };
119
120        if host.is_empty() {
121            return Err(invalid("empty host"));
122        }
123        if host.bytes().any(|b| b < 0x20 || b == b'/' || b == b'@') {
124            return Err(invalid("invalid byte in host"));
125        }
126        let port = match port_str {
127            Some(text) => {
128                let port: u16 = text.parse().map_err(|_| invalid("port is not a u16"))?;
129                if port == 0 {
130                    return Err(invalid("port 0 is not connectable"));
131                }
132                Some(port)
133            }
134            None => None,
135        };
136
137        validate_endpoint_path(path).map_err(|_| invalid("invalid endpoint path"))?;
138
139        Ok(EndpointAddr {
140            host: host.to_owned(),
141            port,
142            path: path.to_owned(),
143            peer,
144        })
145    }
146
147    /// True if the host is an IPv6 literal and must be bracketed when printed.
148    fn host_needs_brackets(&self) -> bool {
149        self.host.contains(':')
150    }
151}
152
153impl fmt::Display for EndpointAddr {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str(SCHEME)?;
156        f.write_str("://")?;
157        if let Some(peer) = &self.peer {
158            write!(f, "{peer}@")?;
159        }
160        match (self.host_needs_brackets(), self.port) {
161            (true, Some(port)) => write!(f, "[{}]:{}{}", self.host, port, self.path),
162            (true, None) => write!(f, "[{}]{}", self.host, self.path),
163            (false, Some(port)) => write!(f, "{}:{}{}", self.host, port, self.path),
164            // Round-trips: a printed address without a port parses back to one
165            // without a port, and means the same set.
166            (false, None) => write!(f, "{}{}", self.host, self.path),
167        }
168    }
169}
170
171/// URL scheme of the in-process transport.
172pub const SCHEME_INPROC: &str = "weida+inproc";
173
174/// Longest in-process bus name, in bytes: libzmq's budget for the same thing
175/// ([decisions/0010](../../../docs/decisions/0010-local-transport.md) §4.8).
176pub const MAX_BUS_BYTES: usize = 256;
177
178/// A parsed `weida+inproc://<bus>/<path>` address.
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct InprocAddr {
181    /// Bus name, unique within the process.
182    pub bus: String,
183    /// Opaque endpoint identifier, starting with `/`.
184    pub path: String,
185}
186
187impl fmt::Display for InprocAddr {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        write!(f, "{SCHEME_INPROC}://{}{}", self.bus, self.path)
190    }
191}
192
193/// An address of any transport.
194///
195/// The transport is part of the address and nothing falls back from one to
196/// another on its own: that would change who may connect and what proves
197/// them without saying so [0010 §4.6].
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub enum Address {
200    /// `weida://[fingerprint@]host:port/path` — the network transport.
201    Quic(EndpointAddr),
202    /// `weida+inproc://bus/path` — in process, no socket and no identity.
203    Inproc(InprocAddr),
204    /// `weida+unix://<percent-encoded>/path` — `AF_UNIX`, the kernel proves
205    /// the peer.
206    Unix(UnixAddr),
207    /// `weida+pipe://<name>/path` — a Windows named pipe, the kernel proves
208    /// the peer.
209    Pipe(PipeAddr),
210}
211
212impl Address {
213    /// Parses an address of any transport, by scheme.
214    pub fn parse(input: &str) -> Result<Address, Error> {
215        if let Some(rest) = input
216            .strip_prefix(SCHEME_INPROC)
217            .and_then(|r| r.strip_prefix("://"))
218        {
219            return parse_inproc(input, rest).map(Address::Inproc);
220        }
221        if input.starts_with(SCHEME_UNIX) {
222            return UnixAddr::parse(input).map(Address::Unix);
223        }
224        if input.starts_with(SCHEME_PIPE) {
225            return PipeAddr::parse(input).map(Address::Pipe);
226        }
227        EndpointAddr::parse(input).map(Address::Quic)
228    }
229
230    /// The endpoint path, whatever the transport.
231    pub fn path(&self) -> &str {
232        match self {
233            Address::Quic(a) => &a.path,
234            Address::Inproc(a) => &a.path,
235            Address::Unix(a) => &a.path,
236            Address::Pipe(a) => &a.path,
237        }
238    }
239}
240
241fn parse_inproc(input: &str, rest: &str) -> Result<InprocAddr, Error> {
242    let invalid = |m: &str| Error::InvalidAddress(format!("{m}: {input:?}"));
243
244    let (bus, path) = match rest.find('/') {
245        Some(i) => rest.split_at(i),
246        None => return Err(invalid("missing endpoint path")),
247    };
248    // There is no key to pin on a local transport, and an address that looks
249    // like it authenticates but does not is worse than one that plainly does
250    // not [0010 §4.8].
251    if bus.contains('@') {
252        return Err(invalid("a local address carries no fingerprint"));
253    }
254    if bus.is_empty() || bus.len() > MAX_BUS_BYTES {
255        return Err(invalid("bus name must be 1..=256 bytes"));
256    }
257    if bus.bytes().any(|b| b < 0x20) {
258        return Err(invalid("invalid byte in bus name"));
259    }
260    validate_endpoint_path(path).map_err(|_| invalid("invalid endpoint path"))?;
261
262    Ok(InprocAddr {
263        bus: bus.to_owned(),
264        path: path.to_owned(),
265    })
266}
267
268/// URL scheme of the `AF_UNIX` transport.
269pub const SCHEME_UNIX: &str = "weida+unix";
270
271/// Longest socket path this platform accepts, in bytes, after decoding.
272///
273/// `sun_path` is `char[108]` on Linux including its NUL — 107 usable — and
274/// exactly 104 characters on macOS, where an App Group container path plus a
275/// team-ID-prefixed group name consumes most of it, so the budget is checked
276/// against the *expanded* path
277/// ([decisions/0010](../../../docs/decisions/0010-local-transport.md) §4.5,
278/// `docs/research/ipc.md` §1.1, §2.1, §6.1).
279pub const MAX_SOCKET_PATH_BYTES: usize = if cfg!(target_os = "macos") { 104 } else { 107 };
280
281/// A parsed `weida+unix://<percent-encoded-socket-path>/<path>` address.
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct UnixAddr {
284    /// Filesystem path of the socket, **decoded**.
285    pub socket: String,
286    /// Opaque endpoint identifier, starting with `/`.
287    pub path: String,
288}
289
290impl UnixAddr {
291    /// Parses `weida+unix://<percent-encoded-socket-path>/<path>`.
292    ///
293    /// The socket path is percent-encoded because it contains the same
294    /// separator the endpoint path uses, and it is validated against this
295    /// platform's `sun_path` budget **after** decoding [0010 §4.8]. The
296    /// `sha256:…@` userinfo form is refused: there is no key to pin on a
297    /// local transport, and an address that looks like it authenticates but
298    /// does not is worse than one that plainly does not.
299    pub fn parse(input: &str) -> Result<UnixAddr, Error> {
300        let invalid = |m: &str| Error::InvalidAddress(format!("{m}: {input:?}"));
301        let rest = input
302            .strip_prefix(SCHEME_UNIX)
303            .and_then(|r| r.strip_prefix("://"))
304            .ok_or_else(|| invalid("expected scheme weida+unix://"))?;
305
306        let (authority, path) = match rest.find('/') {
307            Some(i) => rest.split_at(i),
308            None => return Err(invalid("missing endpoint path")),
309        };
310        if authority.contains('@') {
311            return Err(invalid("a local address carries no fingerprint"));
312        }
313        let socket = percent_decode(authority).ok_or_else(|| invalid("invalid percent escape"))?;
314        if socket.is_empty() {
315            return Err(invalid("empty socket path"));
316        }
317        if socket.len() > MAX_SOCKET_PATH_BYTES {
318            return Err(invalid(&format!(
319                "socket path exceeds this platform's {MAX_SOCKET_PATH_BYTES}-byte sun_path budget after decoding"
320            )));
321        }
322        // A NUL would truncate `sun_path` where the kernel reads it, and the
323        // abstract namespace — a leading NUL — is deliberately not this
324        // transport's [0010 §4.5].
325        if socket.bytes().any(|b| b == 0) {
326            return Err(invalid("a socket path contains no NUL"));
327        }
328        validate_endpoint_path(path).map_err(|_| invalid("invalid endpoint path"))?;
329
330        Ok(UnixAddr {
331            socket,
332            path: path.to_owned(),
333        })
334    }
335}
336
337impl fmt::Display for UnixAddr {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        write!(f, "{SCHEME_UNIX}://")?;
340        for byte in self.socket.bytes() {
341            match byte {
342                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
343                    f.write_str(std::str::from_utf8(&[byte]).expect("ascii"))?;
344                }
345                other => write!(f, "%{other:02X}")?,
346            }
347        }
348        f.write_str(&self.path)
349    }
350}
351
352/// URL scheme of the Windows named-pipe transport.
353pub const SCHEME_PIPE: &str = "weida+pipe";
354
355/// Longest pipe name accepted, in bytes.
356///
357/// The "entire pipe name string can be up to 256 characters long" — the
358/// name after `\\.\pipe\`, which is the part an address carries
359/// (`docs/research/ipc.md` §3.1).
360pub const MAX_PIPE_NAME_BYTES: usize = 256;
361
362/// The local pipe namespace every [`PipeAddr`] is mapped into.
363///
364/// Always `\\.\pipe\` and never a UNC path with a computer name: an address
365/// names a pipe on this machine only, which is the address-level half of
366/// `PIPE_REJECT_REMOTE_CLIENTS` [0010 §4.8].
367pub const PIPE_NAMESPACE: &str = r"\\.\pipe\";
368
369/// A parsed `weida+pipe://<pipe-name>/<path>` address.
370#[derive(Clone, Debug, PartialEq, Eq)]
371pub struct PipeAddr {
372    /// The pipe's name **without** the `\\.\pipe\` prefix.
373    pub name: String,
374    /// Opaque endpoint identifier, starting with `/`.
375    pub path: String,
376}
377
378impl PipeAddr {
379    /// Parses `weida+pipe://<pipe-name>/<path>`.
380    ///
381    /// The name is everything up to the first `/`, so it cannot contain the
382    /// endpoint separator; a backslash is refused too, because in the pipe
383    /// namespace it is the path separator and would let an address name
384    /// something outside `\\.\pipe\`. The `sha256:…@` userinfo form is
385    /// refused for the same reason as on every local transport: there is no
386    /// key to pin [0010 §4.8].
387    pub fn parse(input: &str) -> Result<PipeAddr, Error> {
388        let invalid = |m: &str| Error::InvalidAddress(format!("{m}: {input:?}"));
389        let rest = input
390            .strip_prefix(SCHEME_PIPE)
391            .and_then(|r| r.strip_prefix("://"))
392            .ok_or_else(|| invalid("expected scheme weida+pipe://"))?;
393
394        let (name, path) = match rest.find('/') {
395            Some(i) => rest.split_at(i),
396            None => return Err(invalid("missing endpoint path")),
397        };
398        if name.contains('@') {
399            return Err(invalid("a local address carries no fingerprint"));
400        }
401        if name.is_empty() || name.len() > MAX_PIPE_NAME_BYTES {
402            return Err(invalid(&format!(
403                "pipe name must be 1..={MAX_PIPE_NAME_BYTES} bytes"
404            )));
405        }
406        if name.bytes().any(|b| b < 0x20 || b == b'\\') {
407            return Err(invalid("invalid byte in pipe name"));
408        }
409        validate_endpoint_path(path).map_err(|_| invalid("invalid endpoint path"))?;
410
411        Ok(PipeAddr {
412            name: name.to_owned(),
413            path: path.to_owned(),
414        })
415    }
416
417    /// The OS path of the pipe: `\\.\pipe\<name>`.
418    pub fn os_path(&self) -> String {
419        format!("{PIPE_NAMESPACE}{}", self.name)
420    }
421}
422
423impl fmt::Display for PipeAddr {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        write!(f, "{SCHEME_PIPE}://{}{}", self.name, self.path)
426    }
427}
428
429/// Decodes percent escapes, returning `None` on a malformed one.
430///
431/// Deliberately small: what has to round-trip here is a filesystem path, and
432/// the encoding exists only so that the socket path's separators cannot be
433/// mistaken for the endpoint path's [0010 §4.8].
434fn percent_decode(input: &str) -> Option<String> {
435    let bytes = input.as_bytes();
436    let mut out = Vec::with_capacity(bytes.len());
437    let mut i = 0;
438    while i < bytes.len() {
439        match bytes[i] {
440            b'%' => {
441                let hex = bytes.get(i + 1..i + 3)?;
442                let hex = std::str::from_utf8(hex).ok()?;
443                out.push(u8::from_str_radix(hex, 16).ok()?);
444                i += 3;
445            }
446            byte => {
447                out.push(byte);
448                i += 1;
449            }
450        }
451    }
452    String::from_utf8(out).ok()
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    const FP: &str = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
460
461    #[test]
462    fn parses_ipv4_authority() {
463        let a = EndpointAddr::parse("weida://127.0.0.1:7443/transform").unwrap();
464        assert_eq!(a.host, "127.0.0.1");
465        assert_eq!(a.port, Some(7443));
466        assert_eq!(a.path, "/transform");
467        assert_eq!(a.peer, None);
468        assert_eq!(a.to_string(), "weida://127.0.0.1:7443/transform");
469    }
470
471    #[test]
472    fn parses_a_peer_fingerprint_before_the_authority() {
473        let s = format!("weida://{FP}@[::1]:7443/x");
474        let a = EndpointAddr::parse(&s).unwrap();
475        assert_eq!(a.host, "::1");
476        assert_eq!(a.peer, Some(FP.parse().unwrap()));
477        assert_eq!(a.to_string(), s);
478    }
479
480    #[test]
481    fn parses_bracketed_ipv6_authority() {
482        let a = EndpointAddr::parse("weida://[::1]:7443/x").unwrap();
483        assert_eq!(a.host, "::1");
484        assert_eq!(a.port, Some(7443));
485        assert_eq!(a.path, "/x");
486        assert_eq!(a.to_string(), "weida://[::1]:7443/x");
487    }
488
489    /// Claim: a URL without a port is legal and means a **set** — the
490    /// discovery form of
491    /// [0020](../../../docs/decisions/0020-cluster-and-discovery.md) §4.2 —
492    /// and it round-trips through `Display` unchanged, because a printed
493    /// address that gained a port would mean something else.
494    #[test]
495    fn a_portless_url_is_the_discovery_form() {
496        let a = EndpointAddr::parse("weida://jobs.prod.svc.cluster.local/queue").expect("parse");
497        assert_eq!(a.host, "jobs.prod.svc.cluster.local");
498        assert_eq!(a.port, None);
499        assert_eq!(a.path, "/queue");
500        assert_eq!(a.peer, None);
501        assert_eq!(
502            a.to_string(),
503            "weida://jobs.prod.svc.cluster.local/queue",
504            "a portless address prints without a port"
505        );
506        assert_eq!(EndpointAddr::parse(&a.to_string()).expect("reparse"), a);
507    }
508
509    /// A bracketed IPv6 literal without a port is the same form, and a
510    /// bracket followed by anything but `:port` is still an error rather than
511    /// a host.
512    #[test]
513    fn a_portless_ipv6_literal_round_trips_and_a_malformed_one_does_not() {
514        let a = EndpointAddr::parse("weida://[::1]/x").expect("parse");
515        assert_eq!(a.host, "::1");
516        assert_eq!(a.port, None);
517        assert_eq!(a.to_string(), "weida://[::1]/x");
518        assert_eq!(EndpointAddr::parse(&a.to_string()).expect("reparse"), a);
519
520        assert!(EndpointAddr::parse("weida://[::1]x/y").is_err());
521    }
522
523    /// The pinned form works without a port too: the fingerprint identifies
524    /// the peer, the name identifies the set (0020 §4.4).
525    #[test]
526    fn a_portless_url_may_still_pin_a_fingerprint() {
527        let a = EndpointAddr::parse(&format!("weida://{FP}@jobs.example/queue")).expect("parse");
528        assert_eq!(a.port, None);
529        assert_eq!(a.peer, Some(FP.parse().expect("fingerprint")));
530    }
531
532    #[test]
533    fn parses_dns_name_and_deep_path() {
534        let a = EndpointAddr::parse("weida://broker.example.com:443/a/b/c").unwrap();
535        assert_eq!(a.host, "broker.example.com");
536        assert_eq!(a.path, "/a/b/c");
537    }
538
539    #[test]
540    fn path_stays_opaque() {
541        let a = EndpointAddr::parse("weida://h:1/important*/../%20").unwrap();
542        assert_eq!(a.path, "/important*/../%20");
543    }
544
545    #[test]
546    fn rejects_bad_addresses() {
547        let cases = [
548            "mq://127.0.0.1:7443/x",
549            // `weida://127.0.0.1/x` used to be here, and is now the
550            // discovery form of 0020 §4.2: an authority without a port names
551            // a set, dialled on `DEFAULT_PORT`. For a literal there is
552            // nothing to resolve, so it is one node on the default port —
553            // coherent, and the rule that changed is a rule, not a leniency.
554            "weida://127.0.0.1:7443",
555            "weida://:7443/x",
556            "weida://127.0.0.1:0/x",
557            "weida://127.0.0.1:99999/x",
558            "weida://127.0.0.1:abc/x",
559            "weida://[::1:7443/x",
560            "weida://[::1]7443/x",
561            "127.0.0.1:7443/x",
562            "weida://127.0.0.1:7443/a\u{0}b",
563            "weida://127.0.0.1:7443/a\u{1f}b",
564            "weida://user@host:1/x",
565            "weida://sha256:abc@host:1/x",
566            "weida://@host:1/x",
567            "weida://sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08@@host:1/x",
568        ];
569        for c in cases {
570            assert!(
571                EndpointAddr::parse(c).is_err(),
572                "expected rejection of {c:?}"
573            );
574        }
575    }
576
577    #[test]
578    fn path_length_bounds() {
579        let ok = format!("weida://h:1/{}", "a".repeat(MAX_PATH_BYTES - 1));
580        assert!(EndpointAddr::parse(&ok).is_ok());
581        let too_long = format!("weida://h:1/{}", "a".repeat(MAX_PATH_BYTES));
582        assert!(EndpointAddr::parse(&too_long).is_err());
583    }
584
585    #[test]
586    fn path_validation_rules() {
587        assert!(validate_endpoint_path("/").is_ok());
588        assert!(validate_endpoint_path("").is_err());
589        assert!(validate_endpoint_path("no-leading-slash").is_err());
590        assert!(validate_endpoint_path("/\t").is_err());
591        assert!(validate_endpoint_path(&"/".repeat(MAX_PATH_BYTES)).is_ok());
592        assert!(validate_endpoint_path(&"/".repeat(MAX_PATH_BYTES + 1)).is_err());
593    }
594
595    #[test]
596    fn display_parse_roundtrip() {
597        let pinned = format!("weida://{FP}@host:1/a");
598        for s in [
599            "weida://127.0.0.1:7443/transform",
600            "weida://[::1]:7443/x",
601            "weida://host:1/a",
602            pinned.as_str(),
603        ] {
604            let a = EndpointAddr::parse(s).unwrap();
605            assert_eq!(EndpointAddr::parse(&a.to_string()).unwrap(), a);
606        }
607    }
608
609    #[test]
610    fn a_unix_socket_path_round_trips_through_percent_encoding() {
611        let a = UnixAddr::parse("weida+unix://%2Frun%2Fweida.sock/jobs").expect("parse");
612        assert_eq!(a.socket, "/run/weida.sock");
613        assert_eq!(a.path, "/jobs");
614        // The encoding exists so that the socket path's separators cannot be
615        // read as the endpoint path's, so it must survive a round trip.
616        assert_eq!(UnixAddr::parse(&a.to_string()).expect("reparse"), a);
617    }
618
619    #[test]
620    fn a_unix_address_is_validated_after_decoding() {
621        // One byte past this platform's `sun_path` budget, written encoded:
622        // the check has to happen on the decoded form or it would pass.
623        let long: String = std::iter::repeat_n("%61", MAX_SOCKET_PATH_BYTES + 1).collect();
624        let err = UnixAddr::parse(&format!("weida+unix://{long}/jobs")).unwrap_err();
625        assert!(matches!(err, Error::InvalidAddress(_)), "{err:?}");
626
627        let ok: String = std::iter::repeat_n("%61", MAX_SOCKET_PATH_BYTES).collect();
628        assert!(UnixAddr::parse(&format!("weida+unix://{ok}/jobs")).is_ok());
629    }
630
631    #[test]
632    fn a_unix_address_refuses_what_would_look_authenticated() {
633        for case in [
634            // No key can be pinned on a local transport [0010 §4.8].
635            "weida+unix://sha256:0000000000000000000000000000000000000000000000000000000000000000@%2Ftmp%2Fs/jobs",
636            // A NUL would truncate `sun_path`, and the abstract namespace is
637            // deliberately not this transport.
638            "weida+unix://%00abstract/jobs",
639            // Malformed escape, empty socket path, missing endpoint path.
640            "weida+unix://%2/jobs",
641            "weida+unix:///jobs",
642            "weida+unix://%2Ftmp%2Fs",
643        ] {
644            assert!(
645                UnixAddr::parse(case).is_err(),
646                "expected rejection of {case:?}"
647            );
648        }
649    }
650
651    #[test]
652    fn a_pipe_address_names_a_local_pipe_only() {
653        let a = PipeAddr::parse("weida+pipe://weida-jobs.v1/jobs").expect("parse");
654        assert_eq!(a.name, "weida-jobs.v1");
655        assert_eq!(a.path, "/jobs");
656        // Always the local namespace, never a UNC path with a computer name.
657        assert_eq!(a.os_path(), r"\\.\pipe\weida-jobs.v1");
658        assert_eq!(PipeAddr::parse(&a.to_string()).expect("reparse"), a);
659        assert!(matches!(
660            Address::parse("weida+pipe://x/y").expect("by scheme"),
661            Address::Pipe(_)
662        ));
663    }
664
665    #[test]
666    fn a_pipe_address_refuses_what_would_escape_or_authenticate() {
667        for case in [
668            // No key can be pinned on a local transport [0010 §4.8].
669            "weida+pipe://sha256:0000000000000000000000000000000000000000000000000000000000000000@x/jobs",
670            // A backslash is the pipe namespace's separator: it would name
671            // something outside `\\.\pipe\`.
672            r"weida+pipe://..\..\c$\boot.ini/jobs",
673            // Empty name, control byte, over the 256-byte cap, missing
674            // endpoint path.
675            "weida+pipe:///jobs",
676            "weida+pipe://a\u{1}b/jobs",
677            "weida+pipe://x",
678        ] {
679            assert!(
680                PipeAddr::parse(case).is_err(),
681                "expected rejection of {case:?}"
682            );
683        }
684        let long = "a".repeat(MAX_PIPE_NAME_BYTES + 1);
685        assert!(PipeAddr::parse(&format!("weida+pipe://{long}/jobs")).is_err());
686        let ok = "a".repeat(MAX_PIPE_NAME_BYTES);
687        assert!(PipeAddr::parse(&format!("weida+pipe://{ok}/jobs")).is_ok());
688    }
689}