1use std::num::NonZeroU16;
20use std::path::{Path, PathBuf};
21
22use serde::de::Error as _;
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25#[derive(Debug, Clone, Eq)]
30pub struct RemoteFile {
31 user: Option<String>,
32 host: String,
34 port: Option<NonZeroU16>,
35 path: PathBuf,
36}
37
38impl PartialEq for RemoteFile {
39 fn eq(&self, other: &Self) -> bool {
40 (&self.user, &self.host, self.port, self.path.as_os_str())
41 == (&other.user, &other.host, other.port, other.path.as_os_str())
42 }
43}
44impl std::hash::Hash for RemoteFile {
45 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
46 (&self.user, &self.host, self.port, self.path.as_os_str()).hash(state);
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum AddressError {
54 #[error("remote locations use the form `ssh://[user@]host[:port]/absolute/path`")]
55 NotSshUri,
56 #[error(
57 "SSH locations cannot carry a password (`user:pass@`); authenticate with keys or an agent instead"
58 )]
59 PasswordInUri,
60 #[error("empty username before `@`")]
61 EmptyUser,
62 #[error("host is empty")]
63 EmptyHost,
64 #[error("port must be 1-65535 written in digits, e.g. `:2222`")]
65 InvalidPort,
66 #[error(
67 "refusing an option-shaped username/host (leading `-` or any `=`): SSH would read it as an option"
68 )]
69 OptionShapedEndpoint,
70 #[error("IPv6 hosts need brackets, e.g. `ssh://[2001:db8::1]:22/var/log/app.log`")]
71 UnbracketedIpv6,
72 #[error("bracketed IPv6 host is missing its closing `]`")]
73 UnclosedIpv6,
74 #[error("only `:port` may follow a bracketed IPv6 host")]
75 JunkAfterIpv6,
76 #[error("{literal:?} is not a valid IPv6 address")]
77 InvalidIpv6 { literal: String },
78 #[error("{literal:?} looks like an IPv4 address but is not a valid one")]
79 InvalidIpv4 { literal: String },
80 #[error(
81 "remote path is missing: the location must end in an absolute path like `/var/log/app.log`"
82 )]
83 AbsentPath,
84 #[error(
85 "SSH locations carry no query string or fragment; percent-encode those bytes in the path instead (`?` -> `%3F`, `#` -> `%23`)"
86 )]
87 QueryOrFragment,
88 #[error("percent escapes need two hexadecimal digits, e.g. `%20`")]
89 MalformedPercentEscape,
90 #[error("path cannot contain a NUL byte")]
91 NulInPath,
92 #[error("path contains a byte that must be percent-encoded (a space is `%20`)")]
93 UnencodedPathByte,
94 #[cfg(not(unix))]
95 #[error("the decoded path bytes are not representable as a native filename on this platform")]
96 UnrepresentablePath,
97 #[error("username/host may only contain ASCII letters, digits and `-._+`")]
98 InvalidAuthorityCharacter,
99}
100
101impl RemoteFile {
102 pub fn parse(value: &str) -> Result<Self, AddressError> {
104 let rest = value
105 .strip_prefix("ssh://")
106 .ok_or(AddressError::NotSshUri)?;
107 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
108 let (user, host_port) = split_user(&rest[..authority_end])?;
109 let (host, port) = split_host(host_port)?;
110 let tail = &rest[authority_end..];
111 let path_text = match tail.as_bytes().first() {
112 None => return Err(AddressError::AbsentPath),
113 Some(b'/') => tail,
114 Some(_) => return Err(AddressError::QueryOrFragment),
115 };
116 if path_text.contains(['?', '#']) {
117 return Err(AddressError::QueryOrFragment);
118 }
119 let path = decode_path(path_text)?;
120 Ok(Self {
121 user,
122 host,
123 port,
124 path,
125 })
126 }
127
128 pub fn host(&self) -> &str {
130 &self.host
131 }
132
133 pub fn user(&self) -> Option<&str> {
134 self.user.as_deref()
135 }
136
137 pub fn port(&self) -> Option<NonZeroU16> {
138 self.port
139 }
140
141 pub fn path(&self) -> &Path {
143 &self.path
144 }
145
146 fn canonical(&self) -> String {
148 use std::fmt::Write as _;
149 let mut out = String::with_capacity(
150 "ssh://".len()
151 + self.host.len()
152 + self.user.as_deref().map_or(0, str::len)
153 + 6
154 + path_bytes(&self.path).len().saturating_mul(3),
155 );
156 out.push_str("ssh://");
157 if let Some(user) = &self.user {
158 out.push_str(user);
159 out.push('@');
160 }
161 if self.host.contains(':') {
162 out.push('[');
163 out.push_str(&self.host);
164 out.push(']');
165 } else {
166 out.push_str(&self.host);
167 }
168 if let Some(port) = self.port {
169 let _ = write!(out, ":{port}");
170 }
171 for &byte in path_bytes(&self.path) {
172 if is_raw_path_byte(byte) {
173 out.push(byte as char);
174 } else {
175 out.push('%');
176 out.push(HEX[(byte >> 4) as usize] as char);
177 out.push(HEX[(byte & 0x0F) as usize] as char);
178 }
179 }
180 out
181 }
182}
183
184impl std::fmt::Display for RemoteFile {
185 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 formatter.write_str(&self.canonical())
187 }
188}
189
190impl Serialize for RemoteFile {
193 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
194 serializer.serialize_str(&self.canonical())
195 }
196}
197
198impl<'de> Deserialize<'de> for RemoteFile {
199 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
200 let text = String::deserialize(deserializer)?;
201 RemoteFile::parse(&text).map_err(D::Error::custom)
202 }
203}
204
205const HEX: &[u8; 16] = b"0123456789ABCDEF";
206
207fn split_user(authority: &str) -> Result<(Option<String>, &str), AddressError> {
208 let Some(at) = authority.find('@') else {
209 return Ok((None, authority));
210 };
211 let (user, host_port) = (&authority[..at], &authority[at + 1..]);
212 if host_port.contains('@') {
213 return Err(AddressError::InvalidAuthorityCharacter);
214 }
215 if user.is_empty() {
216 return Err(AddressError::EmptyUser);
217 }
218 if user.contains(':') {
219 return Err(AddressError::PasswordInUri);
220 }
221 check_endpoint_token(user)?;
222 Ok((Some(user.to_owned()), host_port))
223}
224
225fn split_host(host_port: &str) -> Result<(String, Option<NonZeroU16>), AddressError> {
226 if let Some(bracketed) = host_port.strip_prefix('[') {
227 let close = bracketed.find(']').ok_or(AddressError::UnclosedIpv6)?;
228 let inner = &bracketed[..close];
229 let after = &bracketed[close + 1..];
230 let port = if after.is_empty() {
231 None
232 } else if let Some(digits) = after.strip_prefix(':') {
233 Some(parse_port(digits)?)
234 } else {
235 return Err(AddressError::JunkAfterIpv6);
236 };
237 let address: std::net::Ipv6Addr = inner.parse().map_err(|_| AddressError::InvalidIpv6 {
238 literal: inner.to_owned(),
239 })?;
240 return Ok((address.to_string(), port));
241 }
242 if host_port.matches(':').count() > 1 {
243 return Err(AddressError::UnbracketedIpv6);
244 }
245 let (host, port) = match host_port.split_once(':') {
246 Some((host, digits)) => (host, Some(parse_port(digits)?)),
247 None => (host_port, None),
248 };
249 if host.is_empty() {
250 return Err(AddressError::EmptyHost);
251 }
252 if host.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
255 let address: std::net::Ipv4Addr = host.parse().map_err(|_| AddressError::InvalidIpv4 {
256 literal: host.to_owned(),
257 })?;
258 return Ok((address.to_string(), port));
259 }
260 check_endpoint_token(host)?;
261 Ok((host.to_owned(), port))
262}
263
264fn check_endpoint_token(token: &str) -> Result<(), AddressError> {
265 if token.starts_with('-') || token.contains('=') {
266 return Err(AddressError::OptionShapedEndpoint);
267 }
268 if !token.bytes().all(is_endpoint_byte) {
269 return Err(AddressError::InvalidAuthorityCharacter);
270 }
271 Ok(())
272}
273
274fn is_endpoint_byte(byte: u8) -> bool {
275 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'+')
278}
279
280fn parse_port(digits: &str) -> Result<NonZeroU16, AddressError> {
281 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
282 return Err(AddressError::InvalidPort);
283 }
284 match digits.parse::<u16>() {
285 Ok(value) => NonZeroU16::new(value).ok_or(AddressError::InvalidPort),
286 Err(_) => Err(AddressError::InvalidPort),
287 }
288}
289
290fn decode_path(text: &str) -> Result<PathBuf, AddressError> {
294 let bytes = text.as_bytes();
295 let mut out = Vec::with_capacity(bytes.len());
296 let mut i = 0;
297 while i < bytes.len() {
298 let byte = bytes[i];
299 if byte == b'%' {
300 let high = bytes.get(i + 1).copied().filter(|b| b.is_ascii_hexdigit());
301 let low = bytes.get(i + 2).copied().filter(|b| b.is_ascii_hexdigit());
302 match (high, low) {
303 (Some(high), Some(low)) => {
304 out.push((hex_value(high) << 4) | hex_value(low));
305 i += 3;
306 }
307 _ => return Err(AddressError::MalformedPercentEscape),
308 }
309 } else if byte == 0 {
310 return Err(AddressError::NulInPath);
311 } else if is_raw_path_byte(byte) || byte >= 0x80 {
312 out.push(byte);
313 i += 1;
314 } else {
315 return Err(AddressError::UnencodedPathByte);
316 }
317 }
318 if out.contains(&0) {
319 return Err(AddressError::NulInPath);
320 }
321 bytes_to_path(out)
322}
323
324fn hex_value(byte: u8) -> u8 {
326 match byte {
327 b'0'..=b'9' => byte - b'0',
328 b'a'..=b'f' => byte - b'a' + 10,
329 b'A'..=b'F' => byte - b'A' + 10,
330 _ => unreachable!("hex_value called on a non-hex byte"),
331 }
332}
333
334fn is_raw_path_byte(byte: u8) -> bool {
336 matches!(
337 byte,
338 b'a'..=b'z'
339 | b'A'..=b'Z'
340 | b'0'..=b'9'
341 | b'-'
342 | b'.'
343 | b'_'
344 | b'~'
345 | b'!'
346 | b'$'
347 | b'&'
348 | b'\''
349 | b'('
350 | b')'
351 | b'*'
352 | b'+'
353 | b','
354 | b';'
355 | b'='
356 | b':'
357 | b'@'
358 | b'/'
359 )
360}
361
362#[cfg(unix)]
363fn bytes_to_path(bytes: Vec<u8>) -> Result<PathBuf, AddressError> {
364 use std::ffi::OsString;
365 use std::os::unix::ffi::OsStringExt;
366 Ok(PathBuf::from(OsString::from_vec(bytes)))
367}
368
369#[cfg(unix)]
370fn path_bytes(path: &Path) -> &[u8] {
371 use std::os::unix::ffi::OsStrExt;
372 path.as_os_str().as_bytes()
373}
374
375#[cfg(not(unix))]
378fn bytes_to_path(bytes: Vec<u8>) -> Result<PathBuf, AddressError> {
379 let text = String::from_utf8(bytes).map_err(|_| AddressError::UnrepresentablePath)?;
380 Ok(PathBuf::from(text))
381}
382
383#[cfg(not(unix))]
384fn path_bytes(path: &Path) -> &[u8] {
385 path.as_os_str().as_encoded_bytes()
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn ok(uri: &str) -> RemoteFile {
394 RemoteFile::parse(uri).unwrap_or_else(|error| panic!("expected parse: {uri}: {error}"))
395 }
396
397 fn refused(uri: &str) -> AddressError {
398 RemoteFile::parse(uri).unwrap_err()
399 }
400
401 #[test]
402 fn alias_with_user_and_port() {
403 let file = ok("ssh://dev@bbgithub:2222/var/log/app.log");
404 assert_eq!(file.user(), Some("dev"));
405 assert_eq!(file.host(), "bbgithub");
406 assert_eq!(file.port(), NonZeroU16::new(2222));
407 assert_eq!(file.path(), Path::new("/var/log/app.log"));
408 assert_eq!(file.to_string(), "ssh://dev@bbgithub:2222/var/log/app.log");
409 }
410
411 #[test]
412 fn aliases_keep_their_spelling() {
413 assert_ne!(ok("ssh://Box/x"), ok("ssh://box/x"));
416 assert_eq!(ok("ssh://dev_01.a/x").host(), "dev_01.a");
417 }
418
419 #[test]
420 fn ipv6_brackets_canonicalize_and_roundtrip() {
421 let file = ok("ssh://root@[2001:0db8:0000::0001]:22/var/log/a.log");
422 assert_eq!(file.user(), Some("root"));
423 assert_eq!(file.host(), "2001:db8::1");
424 assert_eq!(file.port(), NonZeroU16::new(22));
425 assert_eq!(
426 file.to_string(),
427 "ssh://root@[2001:db8::1]:22/var/log/a.log"
428 );
429 assert_eq!(
430 file,
431 ok("ssh://root@[2001:db8:0:0:0:0:0:1]:0022/var/log/a.log")
432 );
433 let bare = ok("ssh://[::1]/x");
434 assert_eq!(bare.host(), "::1");
435 assert_eq!(bare.port(), None);
436 assert_eq!(bare.to_string(), "ssh://[::1]/x");
437 }
438
439 #[test]
440 fn ipv6_hostility() {
441 assert!(matches!(
442 refused("ssh://fe80::1/x"),
443 AddressError::UnbracketedIpv6
444 ));
445 assert!(matches!(
446 refused("ssh://user@::1/x"),
447 AddressError::UnbracketedIpv6
448 ));
449 assert!(matches!(
450 refused("ssh://[::1/x"),
451 AddressError::UnclosedIpv6
452 ));
453 assert!(matches!(
454 refused("ssh://[::1]extra/x"),
455 AddressError::JunkAfterIpv6
456 ));
457 assert!(matches!(
458 refused("ssh://[zz]/x"),
459 AddressError::InvalidIpv6 { .. }
460 ));
461 }
462
463 #[test]
464 fn ipv4_shapes_must_be_addresses() {
465 assert_eq!(ok("ssh://127.0.0.1/x").host(), "127.0.0.1");
466 for uri in [
467 "ssh://999.1.1.1/x",
468 "ssh://1.2.3.4.5/x",
469 "ssh://1.2.3/x",
470 "ssh://0/x",
471 ] {
472 assert!(
473 matches!(refused(uri), AddressError::InvalidIpv4 { .. }),
474 "{uri}"
475 );
476 }
477 }
478
479 #[test]
480 fn port_boundaries() {
481 assert_eq!(ok("ssh://h:022/x").port(), NonZeroU16::new(22));
482 for uri in [
483 "ssh://h:0/x",
484 "ssh://h:/x",
485 "ssh://h:65536/x",
486 "ssh://h:notaport/x",
487 "ssh://h:+2/x",
488 "ssh://h: 2/x",
489 ] {
490 assert!(matches!(refused(uri), AddressError::InvalidPort), "{uri}");
491 }
492 }
493
494 #[test]
495 fn hostile_authorities() {
496 assert!(matches!(
497 refused("ssh://user:secret@host/x"),
498 AddressError::PasswordInUri
499 ));
500 assert!(matches!(
501 refused("ssh://-oProxyCommand=evil@host/x"),
502 AddressError::OptionShapedEndpoint
503 ));
504 assert!(matches!(
505 refused("ssh://name=-x@host/x"),
506 AddressError::OptionShapedEndpoint
507 ));
508 assert!(matches!(
509 refused("ssh://-flag/x"),
510 AddressError::OptionShapedEndpoint
511 ));
512 assert!(matches!(refused("ssh://@host/x"), AddressError::EmptyUser));
513 assert!(matches!(refused("ssh://h@/x"), AddressError::EmptyHost));
514 assert!(matches!(refused("ssh:///x"), AddressError::EmptyHost));
515 assert!(matches!(
516 refused("ssh://a@b@c/x"),
517 AddressError::InvalidAuthorityCharacter
518 ));
519 assert!(matches!(
520 refused("ssh://ho%st/x"),
521 AddressError::InvalidAuthorityCharacter
522 ));
523 assert!(matches!(
524 refused("ssh://ho st/x"),
525 AddressError::InvalidAuthorityCharacter
526 ));
527 for uri in ["host/x", "scp://host/x", "", "SSH://host/x"] {
528 assert!(matches!(refused(uri), AddressError::NotSshUri), "{uri}");
529 }
530 }
531
532 #[test]
533 fn absent_path_is_refused() {
534 for uri in ["ssh://host", "ssh://host:22"] {
535 assert!(matches!(refused(uri), AddressError::AbsentPath), "{uri}");
536 }
537 }
538
539 #[test]
540 fn query_and_fragment_are_refused() {
541 for uri in [
542 "ssh://h/a?b",
543 "ssh://h/a#b",
544 "ssh://h?q",
545 "ssh://h#f",
546 "ssh://h/a%3Fb?c",
547 ] {
548 assert!(
549 matches!(refused(uri), AddressError::QueryOrFragment),
550 "{uri}"
551 );
552 }
553 }
554
555 #[test]
556 fn percent_decoding_and_canonical_display() {
557 let file = ok("ssh://h/var%20log/a%23b%25c%3Fd");
558 assert_eq!(file.path(), Path::new("/var log/a#b%c?d"));
559 assert_eq!(file.to_string(), "ssh://h/var%20log/a%23b%25c%3Fd");
560 assert_eq!(file, ok(&file.to_string()));
561 }
562
563 #[test]
564 fn malformed_percent_and_nul() {
565 for uri in [
566 "ssh://h/%",
567 "ssh://h/%2",
568 "ssh://h/%G1",
569 "ssh://h/a%zz",
570 "ssh://h/a%2G",
571 ] {
572 assert!(
573 matches!(refused(uri), AddressError::MalformedPercentEscape),
574 "{uri}"
575 );
576 }
577 assert!(matches!(refused("ssh://h/%00"), AddressError::NulInPath));
578 assert!(matches!(
579 refused("ssh://h/a\u{0}b"),
580 AddressError::NulInPath
581 ));
582 }
583
584 #[test]
585 fn shell_metacharacters_are_path_data() {
586 let uri = "ssh://h/log/$x&(rm);'q'*+,.~!@:x";
587 let file = ok(uri);
588 assert_eq!(file.path(), Path::new("/log/$x&(rm);'q'*+,.~!@:x"));
589 assert_eq!(file.to_string(), uri);
590 }
591
592 #[test]
593 fn unencodable_raw_bytes_are_refused() {
594 for uri in [
595 "ssh://h/a b",
596 "ssh://h/a`b",
597 "ssh://h/a\"b",
598 "ssh://h/a\x01b",
599 "ssh://h/a\x7fb",
600 "ssh://h/a\\b",
601 ] {
602 assert!(
603 matches!(refused(uri), AddressError::UnencodedPathByte),
604 "{uri}"
605 );
606 }
607 }
608
609 #[test]
610 fn unicode_path_data_roundtrips_through_escapes() {
611 let file = ok("ssh://h/日本語-légère.log");
612 assert_eq!(file.path(), Path::new("/日本語-légère.log"));
613 let displayed = file.to_string();
614 assert_eq!(
615 displayed,
616 "ssh://h/%E6%97%A5%E6%9C%AC%E8%AA%9E-l%C3%A9g%C3%A8re.log"
617 );
618 assert_eq!(ok(&displayed), file);
619 }
620
621 #[test]
622 fn paths_are_never_trimmed_or_normalized() {
623 let file = ok("ssh://h/a/../b//c/./d/");
624 assert_eq!(file.path(), Path::new("/a/../b//c/./d/"));
625 assert_eq!(file.to_string(), "ssh://h/a/../b//c/./d/");
626 assert_eq!(ok("ssh://h/").path(), Path::new("/"));
627 }
628
629 #[test]
630 fn identity_follows_the_decoded_endpoint() {
631 assert_eq!(ok("ssh://h/%61%62"), ok("ssh://h/ab"));
632 assert_eq!(ok("ssh://h/a%2Fb"), ok("ssh://h/a/b"));
633 assert_eq!(ok("ssh://h:022/x"), ok("ssh://h:22/x"));
634 assert_eq!(ok("ssh://[::1]/x"), ok("ssh://[0:0:0:0:0:0:0:1]/x"));
635 assert_ne!(ok("ssh://h/log"), ok("ssh://h/log/"));
636 assert_ne!(ok("ssh://h/a/b"), ok("ssh://h/a//b"));
637 let mut seen = std::collections::HashSet::new();
638 seen.insert(ok("ssh://h:022/x"));
639 seen.insert(ok("ssh://h:22/x"));
640 seen.insert(ok("ssh://[::1]/x"));
641 seen.insert(ok("ssh://[0::1]/x"));
642 assert_eq!(seen.len(), 2);
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn native_filename_bytes_roundtrip() {
648 use std::os::unix::ffi::OsStrExt;
649 let file = ok("ssh://h/l%FCg%FF");
650 assert_eq!(file.path().as_os_str().as_bytes(), b"/l\xFCg\xFF");
651 assert_eq!(file.to_string(), "ssh://h/l%FCg%FF");
652 assert_eq!(ok(&file.to_string()), file);
653 }
654
655 #[cfg(not(unix))]
656 #[test]
657 fn unrepresentable_native_bytes_are_refused() {
658 assert!(matches!(
659 refused("ssh://h/l%FCg"),
660 AddressError::UnrepresentablePath
661 ));
662 }
663
664 #[test]
665 fn serde_roundtrips_the_canonical_uri() {
666 let file = ok("ssh://dev@box:2222/var%20log/a.log");
667 let json = serde_json::to_string(&file).expect("serialize");
668 assert_eq!(json, "\"ssh://dev@box:2222/var%20log/a.log\"");
669 assert_eq!(
670 serde_json::from_str::<RemoteFile>(&json).expect("deserialize"),
671 file
672 );
673 }
674
675 #[test]
676 fn serde_rejects_invalid_values() {
677 assert!(serde_json::from_str::<RemoteFile>("\"ssh://h/%zz\"").is_err());
678 assert!(serde_json::from_str::<RemoteFile>("\"/local/path\"").is_err());
679 let shaped = serde_json::json!({"host": "h", "path": "/x"});
681 assert!(serde_json::from_value::<RemoteFile>(shaped).is_err());
682 }
683}