1use std::fmt;
12
13use crate::error::Error;
14use crate::identity::Fingerprint;
15
16pub const SCHEME: &str = "weida";
18
19pub const MAX_PATH_BYTES: usize = 512;
21
22pub 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
40pub const DEFAULT_PORT: u16 = 7443;
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct EndpointAddr {
52 pub host: String,
54 pub port: Option<u16>,
61 pub path: String,
63 pub peer: Option<Fingerprint>,
68}
69
70impl EndpointAddr {
71 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 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 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 (false, None) => write!(f, "{}{}", self.host, self.path),
167 }
168 }
169}
170
171pub const SCHEME_INPROC: &str = "weida+inproc";
173
174pub const MAX_BUS_BYTES: usize = 256;
177
178#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct InprocAddr {
181 pub bus: String,
183 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#[derive(Clone, Debug, PartialEq, Eq)]
199pub enum Address {
200 Quic(EndpointAddr),
202 Inproc(InprocAddr),
204 Unix(UnixAddr),
207 Pipe(PipeAddr),
210}
211
212impl Address {
213 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 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 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
268pub const SCHEME_UNIX: &str = "weida+unix";
270
271pub const MAX_SOCKET_PATH_BYTES: usize = if cfg!(target_os = "macos") { 104 } else { 107 };
280
281#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct UnixAddr {
284 pub socket: String,
286 pub path: String,
288}
289
290impl UnixAddr {
291 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 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
352pub const SCHEME_PIPE: &str = "weida+pipe";
354
355pub const MAX_PIPE_NAME_BYTES: usize = 256;
361
362pub const PIPE_NAMESPACE: &str = r"\\.\pipe\";
368
369#[derive(Clone, Debug, PartialEq, Eq)]
371pub struct PipeAddr {
372 pub name: String,
374 pub path: String,
376}
377
378impl PipeAddr {
379 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 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
429fn 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 #[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 #[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 #[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: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 assert_eq!(UnixAddr::parse(&a.to_string()).expect("reparse"), a);
617 }
618
619 #[test]
620 fn a_unix_address_is_validated_after_decoding() {
621 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 "weida+unix://sha256:0000000000000000000000000000000000000000000000000000000000000000@%2Ftmp%2Fs/jobs",
636 "weida+unix://%00abstract/jobs",
639 "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 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 "weida+pipe://sha256:0000000000000000000000000000000000000000000000000000000000000000@x/jobs",
670 r"weida+pipe://..\..\c$\boot.ini/jobs",
673 "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}