From 768dbdc0535b679fd5f8558f5f2d5929c1aca0de Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Thu, 18 Aug 2022 00:09:50 -0500 Subject: [PATCH] Rewrite Destination to no longer use uriparse library --- CHANGELOG.md | 17 + Cargo.lock | 12 - Cargo.toml | 1 - distant-core/Cargo.toml | 1 - distant-core/src/credentials.rs | 243 +++++-- distant-core/src/manager/data/destination.rs | 247 ++----- .../src/manager/data/destination/host.rs | 313 +++++++++ .../src/manager/data/destination/parser.rs | 642 ++++++++++++++++++ distant-core/src/manager/server.rs | 6 +- distant-core/tests/manager_tests.rs | 4 +- distant-ssh2/src/lib.rs | 15 +- src/cli/commands/client.rs | 32 +- src/cli/commands/manager.rs | 22 +- src/cli/commands/manager/handlers.rs | 35 +- src/config/client/connect.rs | 18 - src/config/client/launch.rs | 18 - 16 files changed, 1266 insertions(+), 360 deletions(-) create mode 100644 distant-core/src/manager/data/destination/host.rs create mode 100644 distant-core/src/manager/data/destination/parser.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 953fff7..19bae82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New `ClientConnectConfig` to support connect settings, specifically for ssh +- `Host` with `HostParseError` that follows the + [DoD Internet Host Table Specification](https://www.ietf.org/rfc/rfc0952.txt) + and subsequent [RFC-1123](https://www.rfc-editor.org/rfc/rfc1123) + +### Changed + +- `Destination` now has direct fields for scheme, username, password, host, and + port that are populated from parsing +- `Destination` no longer wraps `uriparse::URI` and all references to + implementing/wrapping have been removed ### Fixed @@ -18,6 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 checks being incorrect (e.g. `backend` instead of `ssh.backend`). This has now been corrected and settings now properly get applied +### Removed + +- The ssh settings of `ssh.user` and `ssh.port` were unused as these were now + being taking from the destination `ssh://[username:]host[:port]`, so they + have now been removed to avoid confusion +- Remove `uriparse` dependency + ## [0.17.2] - 2022-08-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 6605953..ad5a157 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,7 +742,6 @@ dependencies = [ "termwiz", "tokio", "toml_edit", - "uriparse", "which", "whoami", "windows-service", @@ -779,7 +778,6 @@ dependencies = [ "strum", "tokio", "tokio-util", - "uriparse", "walkdir", "winsplit", ] @@ -3090,16 +3088,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "uriparse" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" -dependencies = [ - "fnv", - "lazy_static", -] - [[package]] name = "utf8parse" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index e327644..4acd877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,6 @@ tokio = { version = "1.20.1", features = ["full"] } toml_edit = { version = "0.14.4", features = ["serde"] } terminal_size = "0.2.1" termwiz = "0.17.1" -uriparse = "0.6.4" which = "4.2.5" winsplit = "0.1.0" whoami = "1.2.1" diff --git a/distant-core/Cargo.toml b/distant-core/Cargo.toml index 3f1a131..955e3cc 100644 --- a/distant-core/Cargo.toml +++ b/distant-core/Cargo.toml @@ -34,7 +34,6 @@ shell-words = "1.1.0" strum = { version = "0.24.1", features = ["derive"] } tokio = { version = "1.20.1", features = ["full"] } tokio-util = { version = "0.7.3", features = ["codec"] } -uriparse = "0.6.4" walkdir = "2.3.2" winsplit = "0.1.0" diff --git a/distant-core/src/credentials.rs b/distant-core/src/credentials.rs index 9599ede..572c2b9 100644 --- a/distant-core/src/credentials.rs +++ b/distant-core/src/credentials.rs @@ -4,12 +4,10 @@ use crate::{ }; use distant_net::SecretKey32; use serde::{de::Deserializer, ser::Serializer, Deserialize, Serialize}; -use std::{ - convert::{TryFrom, TryInto}, - fmt, io, - str::FromStr, -}; -use uriparse::{URIReference, URI}; +use std::{convert::TryFrom, fmt, io, str::FromStr}; + +const SCHEME: &str = "distant"; +const SCHEME_WITH_SEP: &str = "distant://"; /// Represents credentials used for a distant server that is maintaining a single key /// across all connections @@ -24,7 +22,7 @@ pub struct DistantSingleKeyCredentials { impl fmt::Display for DistantSingleKeyCredentials { /// Converts credentials into string in the form of `distant://[username]:{key}@{host}:{port}` fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "distant://")?; + write!(f, "{SCHEME}://")?; if let Some(username) = self.username.as_ref() { write!(f, "{}", username)?; } @@ -35,12 +33,37 @@ impl fmt::Display for DistantSingleKeyCredentials { impl FromStr for DistantSingleKeyCredentials { type Err = io::Error; - /// Parse `distant://[username]:{key}@{host}` as credentials. Note that this requires the + /// Parse `distant://[username]:{key}@{host}:{port}` as credentials. Note that this requires the /// `distant` scheme to be included. If parsing without scheme is desired, call the /// [`DistantSingleKeyCredentials::try_from_uri_ref`] method instead with `require_scheme` /// set to false fn from_str(s: &str) -> Result { - Self::try_from_uri_ref(s, true) + let destination: Destination = s + .parse() + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; + + // Can be scheme-less or explicitly distant + if let Some(scheme) = destination.scheme.as_deref() { + if scheme != SCHEME { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Unexpected scheme: {scheme}"), + )); + } + } + + Ok(Self { + host: destination.host.to_string(), + port: destination + .port + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing port"))?, + key: destination + .password + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing key"))? + .parse() + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?, + username: destination.username, + }) } } @@ -63,71 +86,157 @@ impl<'de> Deserialize<'de> for DistantSingleKeyCredentials { } impl DistantSingleKeyCredentials { - /// Converts credentials into a [`Destination`] of the form `distant://[username]:{key}@{host}`, - /// failing if the credentials would not produce a valid [`Destination`] + /// Searches a str for `distant://[username]:{key}@{host}:{port}`, returning the first matching + /// credentials set if found + pub fn find(s: &str) -> Option { + let is_boundary = |c| char::is_whitespace(c) || char::is_control(c); + + for (i, _) in s.match_indices(SCHEME_WITH_SEP) { + // Start at the scheme + let (before, s) = s.split_at(i); + + // Check character preceding the scheme to make sure it isn't a different scheme + // Only whitespace or control characters preceding are okay, anything else is skipped + if !before.is_empty() && !before.ends_with(is_boundary) { + continue; + } + + // Consume until we reach whitespace, which indicates the potential end + let s = match s.find(is_boundary) { + Some(i) => &s[..i], + None => s, + }; + + match s.parse::() { + Ok(this) => return Some(this), + Err(_) => continue, + } + } + + None + } + + /// Converts credentials into a [`Destination`] of the form + /// `distant://[username]:{key}@{host}:{port}`, failing if the credentials would not produce a + /// valid [`Destination`] pub fn try_to_destination(&self) -> io::Result { - let uri = self.try_to_uri()?; - Destination::try_from(uri.as_uri_reference().to_borrowed()) - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x)) + TryFrom::try_from(self.clone()) } +} + +impl TryFrom for Destination { + type Error = io::Error; - /// Converts credentials into a [`URI`] of the form `distant://[username]:{key}@{host}`, - /// failing if the credentials would not produce a valid [`URI`] - pub fn try_to_uri(&self) -> io::Result> { - let uri_string = self.to_string(); - URI::try_from(uri_string.as_str()) - .map(URI::into_owned) - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x)) + fn try_from(credentials: DistantSingleKeyCredentials) -> Result { + Ok(Destination { + scheme: Some("distant".to_string()), + username: credentials.username, + password: Some(credentials.key.to_string()), + host: credentials + .host + .parse() + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?, + port: Some(credentials.port), + }) } +} - /// Parses credentials from a [`URIReference`], failing if the input was not a valid - /// [`URIReference`] or if required parameters like `host` or `password` are missing or bad - /// format - /// - /// If `require_scheme` is true, will enforce that a scheme is provided. Regardless, if a - /// scheme is provided that is not `distant`, this will also fail - pub fn try_from_uri_ref<'a, E>( - uri_ref: impl TryInto, Error = E>, - require_scheme: bool, - ) -> io::Result - where - E: std::error::Error + Send + Sync + 'static, +#[cfg(test)] +mod tests { + use super::*; + use once_cell::sync::Lazy; + + const HOST: &str = "testhost"; + const PORT: u16 = 12345; + + const USER: &str = "testuser"; + static KEY: Lazy = Lazy::new(|| SecretKey32::default().to_string()); + + static CREDENTIALS_STR_NO_USER: Lazy = Lazy::new(|| { + let key = KEY.as_str(); + format!("distant://:{key}@{HOST}:{PORT}") + }); + static CREDENTIALS_STR_USER: Lazy = Lazy::new(|| { + let key = KEY.as_str(); + format!("distant://{USER}:{key}@{HOST}:{PORT}") + }); + + static CREDENTIALS_NO_USER: Lazy = + Lazy::new(|| CREDENTIALS_STR_NO_USER.parse().unwrap()); + static CREDENTIALS_USER: Lazy = + Lazy::new(|| CREDENTIALS_STR_USER.parse().unwrap()); + + #[test] + fn find_should_return_some_key_if_string_is_exact_match() { + let credentials = DistantSingleKeyCredentials::find(CREDENTIALS_STR_NO_USER.as_str()); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + + let credentials = DistantSingleKeyCredentials::find(CREDENTIALS_STR_USER.as_str()); + assert_eq!(credentials.unwrap(), *CREDENTIALS_USER); + } + + #[test] + fn find_should_return_some_key_if_there_is_a_match_with_only_whitespace_on_either_side() { + let s = format!(" {} ", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + + let s = format!("\r{}\r", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + + let s = format!("\t{}\t", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + + let s = format!("\n{}\n", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + } + + #[test] + fn find_should_return_some_key_if_there_is_a_match_with_only_control_characters_on_either_side() { - let uri_ref = uri_ref - .try_into() - .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let s = format!("\x1b{} \x1b", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + } - // Check if the scheme is correct, and if missing if we require it - if let Some(scheme) = uri_ref.scheme() { - if !scheme.as_str().eq_ignore_ascii_case("distant") { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Scheme is not distant", - )); - } - } else if require_scheme { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Missing scheme", - )); - } + #[test] + fn find_should_return_first_match_found_in_str() { + let s = format!( + "{} {}", + CREDENTIALS_STR_NO_USER.as_str(), + CREDENTIALS_STR_USER.as_str() + ); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + } - Ok(Self { - host: uri_ref - .host() - .map(ToString::to_string) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Missing host"))?, - port: uri_ref - .port() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Missing port"))?, - key: uri_ref - .password() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Missing password")) - .and_then(|x| { - x.parse() - .map_err(|x| io::Error::new(io::ErrorKind::InvalidInput, x)) - })?, - username: uri_ref.username().map(ToString::to_string), - }) + #[test] + fn find_should_return_first_valid_match_found_in_str() { + let s = format!( + "a{}a {} b{}b", + CREDENTIALS_STR_NO_USER.as_str(), + CREDENTIALS_STR_NO_USER.as_str(), + CREDENTIALS_STR_NO_USER.as_str() + ); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials.unwrap(), *CREDENTIALS_NO_USER); + } + + #[test] + fn find_should_return_none_if_no_match_found() { + let s = format!("a{}", CREDENTIALS_STR_NO_USER.as_str()); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials, None); + + let s = format!( + "a{} b{}", + CREDENTIALS_STR_NO_USER.as_str(), + CREDENTIALS_STR_NO_USER.as_str() + ); + let credentials = DistantSingleKeyCredentials::find(&s); + assert_eq!(credentials, None); } } diff --git a/distant-core/src/manager/data/destination.rs b/distant-core/src/manager/data/destination.rs index 8d5f537..a58f086 100644 --- a/distant-core/src/manager/data/destination.rs +++ b/distant-core/src/manager/data/destination.rs @@ -1,18 +1,11 @@ use crate::serde_str::{deserialize_from_str, serialize_to_str}; -use derive_more::{Display, Error, From}; use serde::{de::Deserializer, ser::Serializer, Deserialize, Serialize}; -use std::{convert::TryFrom, fmt, hash::Hash, str::FromStr}; -use uriparse::{ - Authority, AuthorityError, Host, Password, Scheme, URIReference, URIReferenceError, Username, - URI, -}; +use std::{fmt, hash::Hash, str::FromStr}; -/// Represents an error that occurs when trying to parse a destination from a str -#[derive(Copy, Clone, Debug, Display, Error, From, PartialEq, Eq)] -pub enum DestinationError { - MissingHost, - URIReferenceError(URIReferenceError), -} +mod host; +mod parser; + +pub use host::{Host, HostParseError}; /// `distant` connects and logs into the specified destination, which may be specified as either /// `hostname:port` where an attempt to connect to a **distant** server will be made, or a URI of @@ -20,93 +13,31 @@ pub enum DestinationError { /// /// * `distant://hostname:port` - connect to a distant server /// * `ssh://[user@]hostname[:port]` - connect to an SSH server +/// +/// **Note:** Due to the limitations of a URI, an IPv6 address is not supported. #[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct Destination(URIReference<'static>); - -impl Destination { - /// Returns a reference to the scheme associated with the destination, if it has one - pub fn scheme(&self) -> Option<&str> { - self.0.scheme().map(Scheme::as_str) - } - - /// Replaces the current scheme of the destination with the provided scheme, returning the old - /// scheme as a string if it existed - pub fn replace_scheme(&mut self, scheme: &str) -> Result, URIReferenceError> { - self.0 - .set_scheme(Some(Scheme::try_from(scheme).map(Scheme::into_owned)?)) - .map(|s| s.map(|s| s.to_string())) - } +pub struct Destination { + /// Sequence of characters beginning with a letter and followed by any combination of letters, + /// digits, plus (+), period (.), or hyphen (-) representing a scheme associated with a + /// destination + pub scheme: Option, - /// Returns the host of the destination as a string - pub fn to_host_string(&self) -> String { - // NOTE: We guarantee that there is a host for a destination during construction - self.0.host().unwrap().to_string() - } + /// Sequence of alphanumeric characters representing a username tied to a destination + pub username: Option, - /// Returns the port tied to the destination, if it has one - pub fn port(&self) -> Option { - self.0.port() - } + /// Sequence of alphanumeric characters representing a password tied to a destination + pub password: Option, - /// Returns the username tied with the destination if it has one - pub fn username(&self) -> Option<&str> { - self.0.username().map(Username::as_str) - } + /// Consisting of either a registered name (including but not limited to a hostname) or an IP + /// address. IPv4 addresses must be in dot-decimal notation, and IPv6 addresses must be + /// enclosed in brackets ([]) + pub host: Host, - /// Returns the password tied with the destination if it has one - pub fn password(&self) -> Option<&str> { - self.0.password().map(Password::as_str) - } - - /// Replaces the host of the destination - pub fn replace_host(&mut self, host: &str) -> Result<(), URIReferenceError> { - let username = self - .0 - .username() - .map(Username::as_borrowed) - .map(Username::into_owned); - let password = self - .0 - .password() - .map(Password::as_borrowed) - .map(Password::into_owned); - let port = self.0.port(); - let _ = self.0.set_authority(Some( - Authority::from_parts( - username, - password, - Host::try_from(host) - .map(Host::into_owned) - .map_err(AuthorityError::from) - .map_err(URIReferenceError::from)?, - port, - ) - .map(Authority::into_owned) - .map_err(URIReferenceError::from)?, - ))?; - Ok(()) - } - - /// Indicates whether the host destination is globally routable - pub fn is_host_global(&self) -> bool { - match self.0.host() { - Some(Host::IPv4Address(x)) => { - !(x.is_broadcast() - || x.is_documentation() - || x.is_link_local() - || x.is_loopback() - || x.is_private() - || x.is_unspecified()) - } - Some(Host::IPv6Address(x)) => { - // NOTE: 14 is the global flag - x.is_multicast() && (x.segments()[0] & 0x000f == 14) - } - Some(Host::RegisteredName(name)) => !name.trim().is_empty(), - None => false, - } - } + /// Port tied to a destination + pub port: Option, +} +impl Destination { /// Returns true if destination represents a distant server pub fn is_distant(&self) -> bool { self.scheme_eq("distant") @@ -118,16 +49,11 @@ impl Destination { } fn scheme_eq(&self, s: &str) -> bool { - match self.0.scheme() { - Some(scheme) => scheme.as_str().eq_ignore_ascii_case(s), + match self.scheme.as_ref() { + Some(scheme) => scheme.eq_ignore_ascii_case(s), None => false, } } - - /// Returns reference to inner [`URIReference`] - pub fn as_uri_ref(&self) -> &URIReference<'static> { - &self.0 - } } impl AsRef for &Destination { @@ -136,68 +62,51 @@ impl AsRef for &Destination { } } -impl AsRef> for Destination { - fn as_ref(&self) -> &URIReference<'static> { - self.as_uri_ref() +impl AsMut for &mut Destination { + fn as_mut(&mut self) -> &mut Destination { + *self } } impl fmt::Display for Destination { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl FromStr for Destination { - type Err = DestinationError; - - fn from_str(s: &str) -> Result { - // Disallow empty (whitespace-only) input as that passes our - // parsing for a URI reference (relative with no scheme or anything) - if s.trim().is_empty() { - return Err(DestinationError::MissingHost); + if let Some(scheme) = self.scheme.as_ref() { + write!(f, "{scheme}://")?; } - let mut destination = URIReference::try_from(s) - .map(URIReference::into_owned) - .map(Destination) - .map_err(DestinationError::URIReferenceError)?; + if let Some(username) = self.username.as_ref() { + write!(f, "{username}")?; + } - // Only support relative reference if it is a path reference as - // we convert that to a relative reference with a host - if destination.0.is_relative_reference() { - let path = destination.0.path().to_string(); - destination.replace_host(path.as_str())?; - let _ = destination.0.set_path("/")?; + if let Some(password) = self.password.as_ref() { + write!(f, ":{password}")?; } - Ok(destination) - } -} + if self.username.is_some() || self.password.is_some() { + write!(f, "@")?; + } -impl<'a> TryFrom> for Destination { - type Error = DestinationError; + write!(f, "{}", self.host)?; - fn try_from(uri_ref: URIReference<'a>) -> Result { - if uri_ref.host().is_none() { - return Err(DestinationError::MissingHost); + if let Some(port) = self.port { + write!(f, ":{port}")?; } - Ok(Self(uri_ref.into_owned())) + Ok(()) } } -impl<'a> TryFrom> for Destination { - type Error = DestinationError; +impl FromStr for Destination { + type Err = &'static str; - fn try_from(uri: URI<'a>) -> Result { - let uri_ref: URIReference<'a> = uri.into(); - Self::try_from(uri_ref) + /// Parses a destination in the form `[scheme://][[username][:password]@]host[:port]` + fn from_str(s: &str) -> Result { + parser::parse(s) } } impl FromStr for Box { - type Err = DestinationError; + type Err = &'static str; fn from_str(s: &str) -> Result { let destination = s.parse::()?; @@ -205,6 +114,13 @@ impl FromStr for Box { } } +impl<'a> PartialEq<&'a str> for Destination { + #[allow(clippy::cmp_owned)] + fn eq(&self, other: &&'a str) -> bool { + self.to_string() == *other + } +} + impl Serialize for Destination { fn serialize(&self, serializer: S) -> Result where @@ -228,47 +144,14 @@ mod tests { use super::*; #[test] - fn parse_should_fail_if_string_is_only_whitespace() { - let err = "".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - - let err = " ".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - - let err = "\t".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - - let err = "\n".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - - let err = "\r".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - - let err = "\r\n".parse::().unwrap_err(); - assert_eq!(err, DestinationError::MissingHost); - } - - #[test] - fn parse_should_succeed_with_valid_uri() { - let destination = "distant://localhost".parse::().unwrap(); - assert_eq!(destination.scheme().unwrap(), "distant"); - assert_eq!(destination.to_host_string(), "localhost"); - assert_eq!(destination.as_uri_ref().path().to_string(), "/"); - } - - #[test] - fn parse_should_fail_if_relative_reference_that_is_not_valid_host() { - let _ = "/".parse::().unwrap_err(); - let _ = "/localhost".parse::().unwrap_err(); - let _ = "my/path".parse::().unwrap_err(); - let _ = "/my/path".parse::().unwrap_err(); - let _ = "//localhost".parse::().unwrap_err(); - } - - #[test] - fn parse_should_succeed_with_nonempty_relative_reference_by_setting_host_to_path() { - let destination = "localhost".parse::().unwrap(); - assert_eq!(destination.to_host_string(), "localhost"); - assert_eq!(destination.as_uri_ref().path().to_string(), "/"); + fn display_should_output_using_available_components() { + let destination = Destination { + scheme: None, + username: None, + password: None, + host: Host::Name("example.com".to_string()), + port: None, + }; + assert_eq!(destination, "example.com"); } } diff --git a/distant-core/src/manager/data/destination/host.rs b/distant-core/src/manager/data/destination/host.rs new file mode 100644 index 0000000..c631533 --- /dev/null +++ b/distant-core/src/manager/data/destination/host.rs @@ -0,0 +1,313 @@ +use crate::serde_str::{deserialize_from_str, serialize_to_str}; +use derive_more::{Display, Error, From}; +use serde::{de::Deserializer, ser::Serializer, Deserialize, Serialize}; +use std::{ + fmt, + net::{Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +/// Represents the host of a destination +#[derive(Clone, Debug, From, Display, Hash, PartialEq, Eq)] +pub enum Host { + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), + + /// Represents a hostname that follows the + /// [DoD Internet Host Table Specification](https://www.ietf.org/rfc/rfc0952.txt): + /// + /// * Hostname can be a maximum of 253 characters including '.' + /// * Each label is a-zA-Z0-9 alongside hyphen ('-') and a maximum size of 63 characters + /// * Labels can be segmented by periods ('.') + Name(String), +} + +impl Host { + /// Indicates whether the host destination is globally routable + pub const fn is_global(&self) -> bool { + match self { + Self::Ipv4(x) => { + !(x.is_broadcast() + || x.is_documentation() + || x.is_link_local() + || x.is_loopback() + || x.is_private() + || x.is_unspecified()) + } + Self::Ipv6(x) => { + // NOTE: 14 is the global flag + x.is_multicast() && (x.segments()[0] & 0x000f == 14) + } + Self::Name(_) => false, + } + } +} + +#[derive(Copy, Clone, Debug, Error, Hash, PartialEq, Eq)] +pub enum HostParseError { + EmptyLabel, + EndsWithHyphen, + EndsWithPeriod, + InvalidLabel, + LargeLabel, + LargeName, + StartsWithHyphen, + StartsWithPeriod, +} + +impl HostParseError { + /// Returns a static `str` describing the error + pub const fn into_static_str(self) -> &'static str { + match self { + Self::EmptyLabel => "Hostname cannot have an empty label", + Self::EndsWithHyphen => "Hostname cannot end with hyphen ('-')", + Self::EndsWithPeriod => "Hostname cannot end with period ('.')", + Self::InvalidLabel => "Hostname label can only be a-zA-Z0-9 or hyphen ('-')", + Self::LargeLabel => "Hostname label larger cannot be larger than 63 characters", + Self::LargeName => "Hostname cannot be larger than 253 characters", + Self::StartsWithHyphen => "Hostname cannot start with hyphen ('-')", + Self::StartsWithPeriod => "Hostname cannot start with period ('.')", + } + } +} + +impl fmt::Display for HostParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.into_static_str()) + } +} + +impl FromStr for Host { + type Err = HostParseError; + + /// Parses a host from a str + /// + /// ### Examples + /// + /// ``` + /// # use distant_core::Host; + /// # use std::net::{Ipv4Addr, Ipv6Addr}; + /// // IPv4 address + /// assert_eq!("127.0.0.1".parse(), Ok(Host::Ipv4(Ipv4Addr::new(127, 0, 0, 1)))); + /// + /// // IPv6 address + /// assert_eq!("::1".parse(), Ok(Host::Ipv6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)))); + /// + /// // Valid hostname + /// assert_eq!("localhost".parse(), Ok(Host::Name("localhost".to_string()))); + /// + /// // Invalid hostname + /// assert!("local_host".parse::().is_err()); + /// ``` + fn from_str(s: &str) -> Result { + // Check if the str is a valid Ipv4 or Ipv6 address first + if let Ok(x) = s.parse::() { + return Ok(Self::Ipv4(x)); + } else if let Ok(x) = s.parse::() { + return Ok(Self::Ipv6(x)); + } + + // NOTE: We have to catch an empty string seprately from invalid label checks + if s.is_empty() { + return Err(HostParseError::InvalidLabel); + } + + // Since it is not, we need to validate the string as a hostname + let mut label_size_cnt = 0; + let mut last_char = None; + for (i, c) in s.char_indices() { + if i >= 253 { + return Err(HostParseError::LargeName); + } + + // Dot and hyphen cannot be first character + if i == 0 && c == '.' { + return Err(HostParseError::StartsWithPeriod); + } else if i == 0 && c == '-' { + return Err(HostParseError::StartsWithHyphen); + } + + if c.is_alphanumeric() { + label_size_cnt += 1; + if label_size_cnt > 63 { + return Err(HostParseError::LargeLabel); + } + } else if c == '.' { + // Back-to-back dots are not allowed (would indicate an empty label, which is + // reserved) + if label_size_cnt == 0 { + return Err(HostParseError::EmptyLabel); + } + + label_size_cnt = 0; + } else if c != '-' { + return Err(HostParseError::InvalidLabel); + } + + last_char = Some(c); + } + + if last_char == Some('.') { + return Err(HostParseError::EndsWithPeriod); + } else if last_char == Some('-') { + return Err(HostParseError::EndsWithHyphen); + } + + Ok(Self::Name(s.to_string())) + } +} + +impl PartialEq for Host { + fn eq(&self, other: &str) -> bool { + match self { + Self::Ipv4(x) => x.to_string() == other, + Self::Ipv6(x) => x.to_string() == other, + Self::Name(x) => x == other, + } + } +} + +impl<'a> PartialEq<&'a str> for Host { + fn eq(&self, other: &&'a str) -> bool { + match self { + Self::Ipv4(x) => x.to_string() == *other, + Self::Ipv6(x) => x.to_string() == *other, + Self::Name(x) => x == other, + } + } +} + +impl Serialize for Host { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serialize_to_str(self, serializer) + } +} + +impl<'de> Deserialize<'de> for Host { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_from_str(deserializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_should_output_ipv4_correctly() { + let host = Host::Ipv4(Ipv4Addr::LOCALHOST); + assert_eq!(host.to_string(), "127.0.0.1"); + } + + #[test] + fn display_should_output_ipv6_correctly() { + let host = Host::Ipv6(Ipv6Addr::LOCALHOST); + assert_eq!(host.to_string(), "::1"); + } + + #[test] + fn display_should_output_hostname_verbatim() { + let host = Host::Name("localhost".to_string()); + assert_eq!(host.to_string(), "localhost"); + } + + #[test] + fn from_str_should_fail_if_str_is_empty() { + let err = "".parse::().unwrap_err(); + assert_eq!(err, HostParseError::InvalidLabel); + } + + #[test] + fn from_str_should_fail_if_str_is_larger_than_253_characters() { + // 63 + 1 + 63 + 1 + 63 + 1 + 62 = 254 characters + let long_name = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "a".repeat(63), + "a".repeat(63), + "a".repeat(62) + ); + let err = long_name.parse::().unwrap_err(); + assert_eq!(err, HostParseError::LargeName); + } + + #[test] + fn from_str_should_fail_if_str_starts_with_period() { + let err = ".localhost".parse::().unwrap_err(); + assert_eq!(err, HostParseError::StartsWithPeriod); + } + + #[test] + fn from_str_should_fail_if_str_ends_with_period() { + let err = "localhost.".parse::().unwrap_err(); + assert_eq!(err, HostParseError::EndsWithPeriod); + } + + #[test] + fn from_str_should_fail_if_str_starts_with_hyphen() { + let err = "-localhost".parse::().unwrap_err(); + assert_eq!(err, HostParseError::StartsWithHyphen); + } + + #[test] + fn from_str_should_fail_if_str_ends_with_hyphen() { + let err = "localhost-".parse::().unwrap_err(); + assert_eq!(err, HostParseError::EndsWithHyphen); + } + + #[test] + fn from_str_should_fail_if_str_has_a_label_larger_than_63_characters() { + let long_label = format!("{}.com", "a".repeat(64)); + let err = long_label.parse::().unwrap_err(); + assert_eq!(err, HostParseError::LargeLabel); + } + + #[test] + fn from_str_should_fail_if_str_has_empty_label() { + let err = "example..com".parse::().unwrap_err(); + assert_eq!(err, HostParseError::EmptyLabel); + } + + #[test] + fn from_str_should_fail_if_str_has_invalid_label() { + let err = "www.exa_mple.com".parse::().unwrap_err(); + assert_eq!(err, HostParseError::InvalidLabel); + } + + #[test] + fn from_str_should_succeed_if_valid_ipv4_address() { + let host = "127.0.0.1".parse::().unwrap(); + assert_eq!(host, Host::Ipv4(Ipv4Addr::new(127, 0, 0, 1))); + } + + #[test] + fn from_str_should_succeed_if_valid_ipv6_address() { + let host = "::1".parse::().unwrap(); + assert_eq!(host, Host::Ipv6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))); + } + + #[test] + fn from_str_should_succeed_if_valid_hostname() { + let host = "localhost".parse::().unwrap(); + assert_eq!(host, Host::Name("localhost".to_string())); + + let host = "example.com".parse::().unwrap(); + assert_eq!(host, Host::Name("example.com".to_string())); + + let host = "w-w-w.example.com".parse::().unwrap(); + assert_eq!(host, Host::Name("w-w-w.example.com".to_string())); + + let host = "w3.example.com".parse::().unwrap(); + assert_eq!(host, Host::Name("w3.example.com".to_string())); + + // Revision of RFC-952 via RFC-1123 allows digit at start of label + let host = "3.example.com".parse::().unwrap(); + assert_eq!(host, Host::Name("3.example.com".to_string())); + } +} diff --git a/distant-core/src/manager/data/destination/parser.rs b/distant-core/src/manager/data/destination/parser.rs new file mode 100644 index 0000000..b6abe92 --- /dev/null +++ b/distant-core/src/manager/data/destination/parser.rs @@ -0,0 +1,642 @@ +use super::{Destination, Host, HostParseError}; + +type PResult<'a, T> = Result<(&'a str, T), PError>; +type PError = &'static str; + +/// Parses `s` into a [`Destination`] +pub fn parse(s: &str) -> Result { + let (s, scheme) = maybe(parse_scheme)(s)?; + let (s, username_password) = maybe(parse_username_password)(s)?; + let (s, host) = parse_and_then(parse_until(|c| c == ':'), parse_host)(s)?; + let (s, port) = maybe(prefixed(parse_char(':'), parse_port))(s)?; + + if !s.is_empty() { + return Err("Str has more characters after destination"); + } + + Ok(Destination { + scheme: scheme.map(ToString::to_string), + username: username_password + .as_ref() + .and_then(|up| up.0) + .map(ToString::to_string), + password: username_password + .as_ref() + .and_then(|up| up.1) + .map(ToString::to_string), + host, + port, + }) +} + +fn parse_scheme(s: &str) -> PResult<&str> { + let (scheme, remaining) = s.split_once("://").ok_or("Scheme missing ://")?; + + if scheme + .chars() + .all(|c| c.is_alphanumeric() || c == '+' || c == '.' || c == '-') + { + Ok((remaining, scheme)) + } else { + Err("Invalid scheme") + } +} + +fn parse_username_password(s: &str) -> PResult<(Option<&str>, Option<&str>)> { + let (auth, remaining) = s.split_once('@').ok_or("Auth missing @")?; + let (auth, username) = maybe(parse_until(|c| !c.is_alphanumeric()))(auth)?; + let (auth, password) = maybe(prefixed( + parse_char(':'), + parse_until(|c| !c.is_alphanumeric()), + ))(auth)?; + + if !auth.is_empty() { + return Err("Dangling characters after username/password"); + } + + Ok((remaining, (username, password))) +} + +fn parse_host(s: &str) -> PResult { + let host = s.parse::().map_err(HostParseError::into_static_str)?; + Ok(("", host)) +} + +fn parse_port(s: &str) -> PResult { + let port = s + .parse::() + .map_err(|_| "Not an unsigned 16-bit integer")?; + + Ok(("", port)) +} + +/// Execute two parsers in a row, failing if either fails, and returns second parser's result +fn prefixed<'a, T1, T2>( + prefix_parser: impl Fn(&'a str) -> PResult<'a, T1>, + parser: impl Fn(&'a str) -> PResult<'a, T2>, +) -> impl Fn(&'a str) -> PResult<'a, T2> { + move |s: &str| { + let (s, _) = prefix_parser(s)?; + let (s, value) = parser(s)?; + Ok((s, value)) + } +} + +/// Execute a parser, returning Some(value) if succeeds and None if fails +fn maybe<'a, T>( + parser: impl Fn(&'a str) -> PResult<'a, T>, +) -> impl Fn(&'a str) -> PResult<'a, Option> { + move |s: &str| match parser(s) { + Ok((remaining, value)) => Ok((remaining, Some(value))), + Err(_) => Ok((s, None)), + } +} + +/// Parses using `first`, and then feeds result into `second`, failing if `second` does not fully +/// parse the result of `first` +fn parse_and_then<'a, T>( + first: impl Fn(&'a str) -> PResult<'a, &'a str>, + second: impl Fn(&'a str) -> PResult<'a, T>, +) -> impl Fn(&'a str) -> PResult<'a, T> { + move |s: &str| { + let (s, first_s) = first(s)?; + let (first_s, value) = second(first_s)?; + + if !first_s.is_empty() { + return Err("Second parser did not fully consume results of first parser"); + } + + Ok((s, value)) + } +} + +/// Parse str until predicate returns true, failing if nothing parsed +fn parse_until(predicate: impl Fn(char) -> bool) -> impl Fn(&str) -> PResult<&str> { + move |s: &str| { + if s.is_empty() { + return Err("Empty str"); + } + + let (s, value) = match s.char_indices().find(|(_, c)| predicate(*c)) { + // Position represents the first character (at boundary) that is not alphanumeric + Some((i, _)) => (&s[i..], &s[..i]), + + // No position means that the remainder of the str was alphanumeric + None => ("", s), + }; + + if value.is_empty() { + return Err("Predicate immediately returned true"); + } + + Ok((s, value)) + } +} + +/// Parse a single character +fn parse_char(c: char) -> impl Fn(&str) -> PResult { + move |s: &str| { + if s.is_empty() { + return Err("Empty str"); + } + + if s.starts_with(c) { + Ok((&s[1..], c)) + } else { + Err("Wrong char") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_should_fail_if_string_is_only_whitespace() { + let _ = parse("").unwrap_err(); + let _ = parse(" ").unwrap_err(); + let _ = parse("\t").unwrap_err(); + let _ = parse("\n").unwrap_err(); + let _ = parse("\r").unwrap_err(); + let _ = parse("\r\n").unwrap_err(); + } + + #[test] + fn parse_should_succeed_when_parsing_valid_destination() { + // Minimal example + let destination = parse("example.com").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username, None); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + + // Full example + let destination = parse("scheme://username:password@example.com:22").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_fail_if_given_path() { + let _ = parse("/").unwrap_err(); + let _ = parse("/localhost").unwrap_err(); + let _ = parse("my/path").unwrap_err(); + let _ = parse("/my/path").unwrap_err(); + let _ = parse("//localhost").unwrap_err(); + } + + mod parsers { + use super::*; + + fn parse_fail(_: &str) -> PResult<&str> { + Err("bad parser") + } + + fn parse_all(s: &str) -> PResult<&str> { + Ok(("", s)) + } + + fn parse_cnt(cnt: usize) -> impl Fn(&str) -> PResult<&str> { + move |s: &str| match s.char_indices().nth(cnt) { + Some((i, _)) => Ok((&s[i..], &s[..i])), + None => Err("Not enough characters"), + } + } + + mod parse_scheme { + use super::*; + + #[test] + fn should_fail_if_not_ending_properly() { + let _ = parse_scheme("scheme").unwrap_err(); + } + + #[test] + fn should_fail_if_scheme_has_invalid_character() { + let _ = parse_scheme("sche_me://").unwrap_err(); + } + + #[test] + fn should_return_scheme_if_valid() { + let (s, scheme) = parse_scheme("scheme+.-://").unwrap(); + assert_eq!(s, ""); + assert_eq!(scheme, "scheme+.-"); + } + + #[test] + fn should_consume_up_to_the_ending_sequence() { + let (s, scheme) = parse_scheme("scheme+.-://example.com").unwrap(); + assert_eq!(s, "example.com"); + assert_eq!(scheme, "scheme+.-"); + } + } + + mod parse_username_password { + use super::*; + + #[test] + fn should_fail_if_not_ending_properly() { + let _ = parse_username_password("username:password").unwrap_err(); + } + + #[test] + fn should_fail_if_username_not_alphanumeric() { + let _ = parse_username_password("us\x1bername:password@").unwrap_err(); + } + + #[test] + fn should_fail_if_password_not_alphanumeric() { + let _ = parse_username_password("username:pas\x1bsword@").unwrap_err(); + } + + #[test] + fn should_return_username_if_available() { + let (s, username_password) = parse_username_password("username@").unwrap(); + assert_eq!(s, ""); + assert_eq!(username_password.0, Some("username")); + assert_eq!(username_password.1, None); + } + + #[test] + fn should_return_password_if_available() { + let (s, username_password) = parse_username_password(":password@").unwrap(); + assert_eq!(s, ""); + assert_eq!(username_password.0, None); + assert_eq!(username_password.1, Some("password")); + } + + #[test] + fn should_return_username_and_password_if_available() { + let (s, username_password) = parse_username_password("username:password@").unwrap(); + assert_eq!(s, ""); + assert_eq!(username_password.0, Some("username")); + assert_eq!(username_password.1, Some("password")); + } + + #[test] + fn should_consume_up_to_the_ending_sequence() { + let (s, username_password) = + parse_username_password("username:password@example.com").unwrap(); + assert_eq!(s, "example.com"); + assert_eq!(username_password.0, Some("username")); + assert_eq!(username_password.1, Some("password")); + } + } + + mod parse_host { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn should_fail_if_domain_name_is_invalid() { + let _ = parse_host("").unwrap_err(); + let _ = parse_host(".").unwrap_err(); + } + + #[test] + fn should_succeed_if_ipv4_address() { + let (s, host) = parse_host("127.0.0.1").unwrap(); + assert_eq!(s, ""); + assert_eq!(host, Host::Ipv4(Ipv4Addr::new(127, 0, 0, 1))); + } + + #[test] + fn should_succeed_if_ipv6_address() { + let (s, host) = parse_host("::1").unwrap(); + assert_eq!(s, ""); + assert_eq!(host, Host::Ipv6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))); + } + + #[test] + fn should_succeed_if_domain_name_is_valid() { + let (s, host) = parse_host("example.com").unwrap(); + assert_eq!(s, ""); + assert_eq!(host, Host::Name("example.com".to_string())); + } + } + + mod parse_port { + use super::*; + + #[test] + fn should_fail_if_input_cannot_be_parsed_as_a_u16() { + let _ = parse_port("").unwrap_err(); + let _ = parse_port("a").unwrap_err(); + let _ = parse_port("-1").unwrap_err(); + let _ = parse_port("0.1").unwrap_err(); + let _ = parse_port(&(u16::MAX as u32 + 1u32).to_string()).unwrap_err(); + } + + #[test] + fn should_succeed_if_input_can_be_parsed_as_a_u16() { + let (s, value) = parse_port("12345").unwrap(); + assert_eq!(s, ""); + assert_eq!(value, 12345); + } + } + + mod prefixed { + use super::*; + + #[test] + fn should_fail_if_prefix_parser_fails() { + let _ = prefixed(parse_fail, parse_all)("abc").unwrap_err(); + } + + #[test] + fn should_fail_if_main_parser_fails() { + let _ = prefixed(parse_cnt(1), parse_fail)("abc").unwrap_err(); + } + + #[test] + fn should_return_value_of_main_parser_when_succeeds() { + let (s, value) = prefixed(parse_cnt(1), parse_cnt(1))("abc").unwrap(); + assert_eq!(s, "c"); + assert_eq!(value, "b"); + } + } + + mod maybe { + use super::*; + + #[test] + fn should_return_some_value_if_wrapped_parser_succeeds() { + let (s, value) = maybe(parse_cnt(2))("abc").unwrap(); + assert_eq!(s, "c"); + assert_eq!(value, Some("ab")); + } + + #[test] + fn should_return_none_if_wrapped_parser_fails() { + let (s, value) = maybe(parse_fail)("abc").unwrap(); + assert_eq!(s, "abc"); + assert_eq!(value, None); + } + } + + mod parse_and_then { + use super::*; + + #[test] + fn should_fail_if_first_parser_fails() { + let _ = parse_and_then(parse_fail, parse_all)("abc").unwrap_err(); + } + + #[test] + fn should_fail_if_second_parser_fails() { + let _ = parse_and_then(parse_all, parse_fail)("abc").unwrap_err(); + } + + #[test] + fn should_fail_if_second_parser_does_not_fully_consume_first_parser_output() { + let _ = parse_and_then(parse_all, parse_cnt(2))("abc").unwrap_err(); + } + + #[test] + fn should_consume_with_first_parser_and_then_return_results_of_feeding_into_second_parser( + ) { + let (s, text) = parse_and_then(parse_cnt(2), parse_all)("abc").unwrap(); + assert_eq!(s, "c"); + assert_eq!(text, "ab"); + } + } + + mod parse_until { + use super::*; + + #[test] + fn should_consume_until_predicate_matches() { + let (s, text) = parse_until(|c| c == 'b')("abc").unwrap(); + assert_eq!(s, "bc"); + assert_eq!(text, "a"); + } + + #[test] + fn should_consume_completely_if_predicate_never_matches() { + let (s, text) = parse_until(|c| c == 'z')("abc").unwrap(); + assert_eq!(s, ""); + assert_eq!(text, "abc"); + } + + #[test] + fn should_fail_if_nothing_consumed() { + let _ = parse_until(|c| c == 'a')("abc").unwrap_err(); + } + + #[test] + fn should_fail_if_input_is_empty() { + let _ = parse_until(|c| c == 'a')("").unwrap_err(); + } + } + + mod parse_char { + use super::*; + + #[test] + fn should_succeed_if_next_char_matches() { + let (s, c) = parse_char('a')("abc").unwrap(); + assert_eq!(s, "bc"); + assert_eq!(c, 'a'); + } + + #[test] + fn should_fail_if_next_char_does_not_match() { + let _ = parse_char('b')("abc").unwrap_err(); + } + + #[test] + fn should_fail_if_input_is_empty() { + let _ = parse_char('a')("").unwrap_err(); + } + } + } + + mod examples { + use super::*; + + #[test] + fn parse_should_succeed_if_given_just_host() { + let destination = parse("example.com").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username, None); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_scheme_and_host() { + let destination = parse("scheme://example.com").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username, None); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_username_and_host() { + let destination = parse("username@example.com").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_password_and_host() { + let destination = parse(":password@example.com").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username, None); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_host_and_port() { + let destination = parse("example.com:22").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username, None); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_scheme_username_and_host() { + let destination = parse("scheme://username@example.com").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_scheme_password_and_host() { + let destination = parse("scheme://:password@example.com").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username, None); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_scheme_host_and_port() { + let destination = parse("scheme://example.com:22").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username, None); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_scheme_username_password_and_host() { + let destination = parse("scheme://username:password@example.com").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_scheme_username_host_and_port() { + let destination = parse("scheme://username@example.com:22").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_scheme_password_host_and_port() { + let destination = parse("scheme://:password@example.com:22").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username, None); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_scheme_username_password_host_and_port() { + let destination = parse("scheme://username:password@example.com:22").unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("scheme")); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_username_password_and_host() { + let destination = parse("username:password@example.com").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, None); + } + + #[test] + fn parse_should_succeed_if_given_username_host_and_port() { + let destination = parse("username@example.com:22").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password, None); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_password_host_and_port() { + let destination = parse(":password@example.com:22").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username, None); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_if_given_username_password_host_and_port() { + let destination = parse("username:password@example.com:22").unwrap(); + assert_eq!(destination.scheme, None); + assert_eq!(destination.username.as_deref(), Some("username")); + assert_eq!(destination.password.as_deref(), Some("password")); + assert_eq!(destination.host, "example.com"); + assert_eq!(destination.port, Some(22)); + } + + #[test] + fn parse_should_succeed_with_distant_server_output() { + // This is an example of what a server might output that includes a 32-byte key + let destination = parse(concat!( + "distant://", + ":d561d38251700a5ac0b162c19e0c961832a64990ee19e33f7a5728f0615b2013@", + "localhost", + ":59699", + )) + .unwrap(); + assert_eq!(destination.scheme.as_deref(), Some("distant")); + assert_eq!(destination.username.as_deref(), None); + assert_eq!( + destination.password.as_deref(), + Some("d561d38251700a5ac0b162c19e0c961832a64990ee19e33f7a5728f0615b2013") + ); + assert_eq!(destination.host, "localhost"); + assert_eq!(destination.port, Some(59699)); + } + } +} diff --git a/distant-core/src/manager/server.rs b/distant-core/src/manager/server.rs index 8e6e17a..81587b6 100644 --- a/distant-core/src/manager/server.rs +++ b/distant-core/src/manager/server.rs @@ -140,7 +140,7 @@ impl DistantManager { ) })?; - let scheme = match destination.scheme() { + let scheme = match destination.scheme.as_deref() { Some(scheme) => { trace!("Using scheme {}", scheme); scheme @@ -185,7 +185,7 @@ impl DistantManager { ) })?; - let scheme = match destination.scheme() { + let scheme = match destination.scheme.as_deref() { Some(scheme) => { trace!("Using scheme {}", scheme); scheme @@ -588,7 +588,7 @@ mod tests { let lock = server.connections.read().await; let connection = lock.get(&id).unwrap(); assert_eq!(connection.id, id); - assert_eq!(connection.destination, "scheme://host".parse().unwrap()); + assert_eq!(connection.destination, "scheme://host"); assert_eq!(connection.extra, "key=value".parse().unwrap()); } diff --git a/distant-core/tests/manager_tests.rs b/distant-core/tests/manager_tests.rs index ee8abc0..6dbdd2c 100644 --- a/distant-core/tests/manager_tests.rs +++ b/distant-core/tests/manager_tests.rs @@ -65,7 +65,7 @@ async fn should_be_able_to_establish_a_single_connection_and_communicate() { .await .expect("Failed to get list of connections"); assert_eq!(list.len(), 1); - assert_eq!(list.get(&id).unwrap().to_string(), "scheme://host/"); + assert_eq!(list.get(&id).unwrap().to_string(), "scheme://host"); // Test retrieving information let info = client @@ -73,7 +73,7 @@ async fn should_be_able_to_establish_a_single_connection_and_communicate() { .await .expect("Failed to get info about connection"); assert_eq!(info.id, id); - assert_eq!(info.destination.to_string(), "scheme://host/"); + assert_eq!(info.destination.to_string(), "scheme://host"); assert_eq!(info.extra, "key=value".parse::().unwrap()); // Create a new channel and request some data diff --git a/distant-ssh2/src/lib.rs b/distant-ssh2/src/lib.rs index 24b1dab..ee95597 100644 --- a/distant-ssh2/src/lib.rs +++ b/distant-ssh2/src/lib.rs @@ -513,7 +513,7 @@ impl Ssh { /// Consume [`Ssh`] and produce a [`DistantClient`] that is connected to a remote /// distant server that is spawned using the ssh client pub async fn launch_and_connect(self, opts: DistantLaunchOpts) -> io::Result { - trace!("ssh::launch_and_connect({:?})", opts); + trace!("ssh::launch_and_colnnnect({:?})", opts); // Exit early if not authenticated as this is a requirement if !self.authenticated { @@ -533,6 +533,7 @@ impl Ssh { // IP address of the end machine it is connected to, but that probably isn't // possible with ssh. So, for now, connecting to a distant server from an // established ssh connection requires that we can resolve the specified host + debug!("Looking up host {} @ port {}", self.host, self.port); let mut candidate_ips = tokio::net::lookup_host(format!("{}:{}", self.host, self.port)) .await .map_err(|x| { @@ -640,19 +641,17 @@ impl Ssh { if output.success { // Iterate over output as individual lines, looking for client info trace!("Searching for credentials"); - let maybe_info = output - .stdout - .split(|&b| b == b'\n') - .map(String::from_utf8_lossy) - .find_map(|line| line.parse::().ok()); - match maybe_info { + match DistantSingleKeyCredentials::find(&String::from_utf8_lossy(&output.stdout)) { Some(mut info) => { info.host = host; Ok(info) } None => Err(io::Error::new( io::ErrorKind::InvalidData, - "Missing launch information", + format!( + "Missing launch information: '{}'", + String::from_utf8_lossy(&output.stdout) + ), )), } } else { diff --git a/src/cli/commands/client.rs b/src/cli/commands/client.rs index 73bcdc9..65fc0fa 100644 --- a/src/cli/commands/client.rs +++ b/src/cli/commands/client.rs @@ -14,7 +14,7 @@ use distant_core::{ data::{ChangeKindSet, Environment}, net::{IntoSplit, Request, Response, TypedAsyncRead, TypedAsyncWrite}, ConnectionId, Destination, DistantManagerClient, DistantMsg, DistantRequestData, - DistantResponseData, Extra, RemoteCommand, Watcher, + DistantResponseData, Extra, Host, RemoteCommand, Watcher, }; use log::*; use serde_json::{json, Value}; @@ -443,17 +443,15 @@ impl ClientSubcommand { extra.extend(Extra::from(launcher_config).into_map()); // Grab the host we are connecting to for later use - let host = destination.to_host_string(); + let host = destination.host.to_string(); // If we have no scheme on launch, we need to fill it in with something // // TODO: Can we have the server support this instead of the client? Right now, the // server is failing because it cannot parse //localhost/ as it fails with // an invalid IPv4 or registered name character error on host - if destination.scheme().is_none() { - destination - .replace_scheme("ssh") - .context("Failed to set a default scheme for a scheme-less destination")?; + if destination.scheme.is_none() { + destination.scheme = Some("ssh".to_string()); } // Start the server using our manager @@ -465,17 +463,18 @@ impl ClientSubcommand { // Update the new destination with our previously-used host if the // new host is not globally-accessible - if !new_destination.is_host_global() { + if !new_destination.host.is_global() { trace!( "Updating host to {:?} from non-global {:?}", host, - new_destination.to_host_string() + new_destination.host.to_string() ); - new_destination - .replace_host(host.as_str()) + new_destination.host = host + .parse::() + .map_err(|x| anyhow::anyhow!(x)) .context("Failed to replace host")?; } else { - trace!("Host {:?} is global", new_destination.to_host_string()); + trace!("Host {:?} is global", new_destination.host.to_string()); } // Trigger our manager to connect to the launched server @@ -664,13 +663,14 @@ impl ClientSubcommand { format!( "{}{}{}", destination - .scheme() - .map(|x| format!(r"{}://", x)) + .scheme + .as_ref() + .map(|scheme| format!(r"{scheme}://")) .unwrap_or_default(), - destination.to_host_string(), + destination.host, destination - .port() - .map(|x| format!(":{}", x)) + .port + .map(|port| format!(":{port}")) .unwrap_or_default() ) }) diff --git a/src/cli/commands/manager.rs b/src/cli/commands/manager.rs index e37e12f..d1e6175 100644 --- a/src/cli/commands/manager.rs +++ b/src/cli/commands/manager.rs @@ -350,15 +350,11 @@ impl ManagerSubcommand { "{}", Table::new(vec![InfoRow { id: info.id, - scheme: info - .destination - .scheme() - .map(ToString::to_string) - .unwrap_or_default(), - host: info.destination.to_host_string(), + scheme: info.destination.scheme.unwrap_or_default(), + host: info.destination.host.to_string(), port: info .destination - .port() + .port .map(|x| x.to_string()) .unwrap_or_default(), extra: info.extra.to_string() @@ -400,15 +396,9 @@ impl ManagerSubcommand { ListRow { selected: *selected == id, id, - scheme: destination - .scheme() - .map(ToString::to_string) - .unwrap_or_default(), - host: destination.to_host_string(), - port: destination - .port() - .map(|x| x.to_string()) - .unwrap_or_default(), + scheme: destination.scheme.unwrap_or_default(), + host: destination.host.to_string(), + port: destination.port.map(|x| x.to_string()).unwrap_or_default(), } })) ); diff --git a/src/cli/commands/manager/handlers.rs b/src/cli/commands/manager/handlers.rs index a9974b2..10d0d62 100644 --- a/src/cli/commands/manager/handlers.rs +++ b/src/cli/commands/manager/handlers.rs @@ -23,12 +23,12 @@ use tokio::{ #[inline] fn missing(label: &str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, format!("Missing {}", label)) + io::Error::new(io::ErrorKind::InvalidInput, format!("Missing {label}")) } #[inline] fn invalid(label: &str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid {}", label)) + io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid {label}")) } /// Supports launching locally through the manager as defined by `manager://...` @@ -52,7 +52,7 @@ impl LaunchHandler for ManagerLaunchHandler { extra: &Extra, _auth_client: &mut AuthClient, ) -> io::Result { - trace!("Handling launch of {destination} with {extra}"); + trace!("Handling launch of {destination} with extra '{extra}'"); let config = ClientLaunchConfig::from(extra.clone()); // Get the path to the distant binary, ensuring it exists and is executable @@ -81,7 +81,7 @@ impl LaunchHandler for ManagerLaunchHandler { .unwrap_or_else(|| String::from("any")), ]; - if let Some(port) = destination.port() { + if let Some(port) = destination.port { args.push("--port".to_string()); args.push(port.to_string()); } @@ -165,7 +165,7 @@ impl LaunchHandler for SshLaunchHandler { extra: &Extra, auth_client: &mut AuthClient, ) -> io::Result { - trace!("Handling launch of {destination} with {extra}"); + trace!("Handling launch of {destination} with extra '{extra}'"); let config = ClientLaunchConfig::from(extra.clone()); use distant_ssh2::DistantLaunchOpts; @@ -221,15 +221,17 @@ impl ConnectHandler for DistantConnectHandler { extra: &Extra, auth_client: &mut AuthClient, ) -> io::Result { - trace!("Handling connect of {destination} with {extra}"); - let host = destination.to_host_string(); - let port = destination.port().ok_or_else(|| missing("port"))?; - let mut candidate_ips = tokio::net::lookup_host(format!("{}:{}", host, port)) + trace!("Handling connect of {destination} with extra '{extra}'"); + let host = destination.host.to_string(); + let port = destination.port.ok_or_else(|| missing("port"))?; + + debug!("Looking up host {host} @ port {port}"); + let mut candidate_ips = tokio::net::lookup_host(format!("{host}:{port}")) .await .map_err(|x| { io::Error::new( x.kind(), - format!("{} needs to be resolvable outside of ssh: {}", host, x), + format!("{host} needs to be resolvable outside of ssh: {x}"), ) })? .into_iter() @@ -240,7 +242,7 @@ impl ConnectHandler for DistantConnectHandler { if candidate_ips.is_empty() { return Err(io::Error::new( io::ErrorKind::AddrNotAvailable, - format!("Unable to resolve {}:{}", host, port), + format!("Unable to resolve {host}:{port}"), )); } @@ -248,7 +250,8 @@ impl ConnectHandler for DistantConnectHandler { // codec using the key let codec = { let key = destination - .password() + .password + .as_deref() .or_else(|| extra.get("key").map(|s| s.as_str())); let key = match key { @@ -290,7 +293,7 @@ impl ConnectHandler for SshConnectHandler { extra: &Extra, auth_client: &mut AuthClient, ) -> io::Result { - trace!("Handling connect of {destination} with {extra}"); + trace!("Handling connect of {destination} with extra '{extra}'"); let mut ssh = load_ssh(destination, extra)?; let handler = AuthClientSshAuthHandler::new(auth_client); let _ = ssh.authenticate(handler).await?; @@ -365,7 +368,7 @@ fn load_ssh(destination: &Destination, extra: &Extra) -> io::Result io::Result None, }, - port: destination.port(), + port: destination.port, proxy_command: extra .get("proxy_command") .or_else(|| extra.get("ssh.proxy_command")) .cloned(), - user: destination.username().map(ToString::to_string), + user: destination.username.clone(), user_known_hosts_files: extra .get("user_known_hosts_files") diff --git a/src/config/client/connect.rs b/src/config/client/connect.rs index e735731..4904add 100644 --- a/src/config/client/connect.rs +++ b/src/config/client/connect.rs @@ -19,11 +19,9 @@ impl From for ClientConnectConfig { backend: map .remove("ssh.backend") .and_then(|x| x.parse::().ok()), - username: map.remove("ssh.username"), identity_file: map .remove("ssh.identity_file") .and_then(|x| x.parse::().ok()), - port: map.remove("ssh.port").and_then(|x| x.parse::().ok()), }, } } @@ -39,20 +37,12 @@ impl From for Map { this.insert("ssh.backend".to_string(), x.to_string()); } - if let Some(x) = config.ssh.username { - this.insert("ssh.username".to_string(), x); - } - if let Some(x) = config.ssh.identity_file { this.insert( "ssh.identity_file".to_string(), x.to_string_lossy().to_string(), ); } - - if let Some(x) = config.ssh.port { - this.insert("ssh.port".to_string(), x.to_string()); - } } this @@ -66,15 +56,7 @@ pub struct ClientConnectSshConfig { #[clap(name = "ssh-backend", long)] pub backend: Option, - /// Username to use when sshing into remote machine - #[clap(name = "ssh-username", short = 'u', long)] - pub username: Option, - /// Explicit identity file to use with ssh #[clap(name = "ssh-identity-file", short = 'i', long)] pub identity_file: Option, - - /// Port to use for sshing into the remote machine - #[clap(name = "ssh-port", short = 'p', long)] - pub port: Option, } diff --git a/src/config/client/launch.rs b/src/config/client/launch.rs index 6bcdc55..21a8864 100644 --- a/src/config/client/launch.rs +++ b/src/config/client/launch.rs @@ -39,11 +39,9 @@ impl From for ClientLaunchConfig { .remove("ssh.external") .and_then(|x| x.parse::().ok()) .unwrap_or_default(), - username: map.remove("ssh.username"), identity_file: map .remove("ssh.identity_file") .and_then(|x| x.parse::().ok()), - port: map.remove("ssh.port").and_then(|x| x.parse::().ok()), }, } } @@ -81,10 +79,6 @@ impl From for Map { this.insert("ssh.external".to_string(), config.ssh.external.to_string()); - if let Some(x) = config.ssh.username { - this.insert("ssh.username".to_string(), x); - } - if let Some(x) = config.ssh.identity_file { this.insert( "ssh.identity_file".to_string(), @@ -92,10 +86,6 @@ impl From for Map { ); } - if let Some(x) = config.ssh.port { - this.insert("ssh.port".to_string(), x.to_string()); - } - this } } @@ -148,15 +138,7 @@ pub struct ClientLaunchSshConfig { #[clap(name = "ssh-external", long)] pub external: bool, - /// Username to use when sshing into remote machine - #[clap(name = "ssh-username", short = 'u', long)] - pub username: Option, - /// Explicit identity file to use with ssh #[clap(name = "ssh-identity-file", short = 'i', long)] pub identity_file: Option, - - /// Port to use for sshing into the remote machine - #[clap(name = "ssh-port", short = 'p', long)] - pub port: Option, }