Rewrite Destination to no longer use uriparse library

pull/137/head
Chip Senkbeil 2 years ago
parent 86b34d23c6
commit 768dbdc053
No known key found for this signature in database
GPG Key ID: 35EF1F8EC72A4131

@ -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

12
Cargo.lock generated

@ -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"

@ -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"

@ -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"

@ -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, Self::Err> {
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<DistantSingleKeyCredentials> {
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::<Self>() {
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<Destination> {
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<DistantSingleKeyCredentials> 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<URI<'static>> {
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<Self, Self::Error> {
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<URIReference<'a>, Error = E>,
require_scheme: bool,
) -> io::Result<Self>
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<String> = Lazy::new(|| SecretKey32::default().to_string());
static CREDENTIALS_STR_NO_USER: Lazy<String> = Lazy::new(|| {
let key = KEY.as_str();
format!("distant://:{key}@{HOST}:{PORT}")
});
static CREDENTIALS_STR_USER: Lazy<String> = Lazy::new(|| {
let key = KEY.as_str();
format!("distant://{USER}:{key}@{HOST}:{PORT}")
});
static CREDENTIALS_NO_USER: Lazy<DistantSingleKeyCredentials> =
Lazy::new(|| CREDENTIALS_STR_NO_USER.parse().unwrap());
static CREDENTIALS_USER: Lazy<DistantSingleKeyCredentials> =
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);
}
}

@ -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<Option<String>, 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<String>,
/// 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<String>,
/// Returns the port tied to the destination, if it has one
pub fn port(&self) -> Option<u16> {
self.0.port()
}
/// Sequence of alphanumeric characters representing a password tied to a destination
pub password: Option<String>,
/// 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<u16>,
}
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<Destination> for &Destination {
@ -136,68 +62,51 @@ impl AsRef<Destination> for &Destination {
}
}
impl AsRef<URIReference<'static>> for Destination {
fn as_ref(&self) -> &URIReference<'static> {
self.as_uri_ref()
impl AsMut<Destination> 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<Self, Self::Err> {
// 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<URIReference<'a>> for Destination {
type Error = DestinationError;
write!(f, "{}", self.host)?;
fn try_from(uri_ref: URIReference<'a>) -> Result<Self, Self::Error> {
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<URI<'a>> for Destination {
type Error = DestinationError;
impl FromStr for Destination {
type Err = &'static str;
fn try_from(uri: URI<'a>) -> Result<Self, Self::Error> {
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<Self, Self::Err> {
parser::parse(s)
}
}
impl FromStr for Box<Destination> {
type Err = DestinationError;
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let destination = s.parse::<Destination>()?;
@ -205,6 +114,13 @@ impl FromStr for Box<Destination> {
}
}
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
@ -228,47 +144,14 @@ mod tests {
use super::*;
#[test]
fn parse_should_fail_if_string_is_only_whitespace() {
let err = "".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
let err = " ".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
let err = "\t".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
let err = "\n".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
let err = "\r".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
let err = "\r\n".parse::<Destination>().unwrap_err();
assert_eq!(err, DestinationError::MissingHost);
}
#[test]
fn parse_should_succeed_with_valid_uri() {
let destination = "distant://localhost".parse::<Destination>().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::<Destination>().unwrap_err();
let _ = "/localhost".parse::<Destination>().unwrap_err();
let _ = "my/path".parse::<Destination>().unwrap_err();
let _ = "/my/path".parse::<Destination>().unwrap_err();
let _ = "//localhost".parse::<Destination>().unwrap_err();
}
#[test]
fn parse_should_succeed_with_nonempty_relative_reference_by_setting_host_to_path() {
let destination = "localhost".parse::<Destination>().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");
}
}

@ -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::<Host>().is_err());
/// ```
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Check if the str is a valid Ipv4 or Ipv6 address first
if let Ok(x) = s.parse::<Ipv4Addr>() {
return Ok(Self::Ipv4(x));
} else if let Ok(x) = s.parse::<Ipv6Addr>() {
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<str> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serialize_to_str(self, serializer)
}
}
impl<'de> Deserialize<'de> for Host {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<Host>().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::<Host>().unwrap_err();
assert_eq!(err, HostParseError::LargeName);
}
#[test]
fn from_str_should_fail_if_str_starts_with_period() {
let err = ".localhost".parse::<Host>().unwrap_err();
assert_eq!(err, HostParseError::StartsWithPeriod);
}
#[test]
fn from_str_should_fail_if_str_ends_with_period() {
let err = "localhost.".parse::<Host>().unwrap_err();
assert_eq!(err, HostParseError::EndsWithPeriod);
}
#[test]
fn from_str_should_fail_if_str_starts_with_hyphen() {
let err = "-localhost".parse::<Host>().unwrap_err();
assert_eq!(err, HostParseError::StartsWithHyphen);
}
#[test]
fn from_str_should_fail_if_str_ends_with_hyphen() {
let err = "localhost-".parse::<Host>().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::<Host>().unwrap_err();
assert_eq!(err, HostParseError::LargeLabel);
}
#[test]
fn from_str_should_fail_if_str_has_empty_label() {
let err = "example..com".parse::<Host>().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::<Host>().unwrap_err();
assert_eq!(err, HostParseError::InvalidLabel);
}
#[test]
fn from_str_should_succeed_if_valid_ipv4_address() {
let host = "127.0.0.1".parse::<Host>().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::<Host>().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::<Host>().unwrap();
assert_eq!(host, Host::Name("localhost".to_string()));
let host = "example.com".parse::<Host>().unwrap();
assert_eq!(host, Host::Name("example.com".to_string()));
let host = "w-w-w.example.com".parse::<Host>().unwrap();
assert_eq!(host, Host::Name("w-w-w.example.com".to_string()));
let host = "w3.example.com".parse::<Host>().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::<Host>().unwrap();
assert_eq!(host, Host::Name("3.example.com".to_string()));
}
}

@ -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<Destination, &'static str> {
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<Host> {
let host = s.parse::<Host>().map_err(HostParseError::into_static_str)?;
Ok(("", host))
}
fn parse_port(s: &str) -> PResult<u16> {
let port = s
.parse::<u16>()
.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<T>> {
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<char> {
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));
}
}
}

@ -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());
}

@ -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::<Extra>().unwrap());
// Create a new channel and request some data

@ -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<DistantClient> {
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::<DistantSingleKeyCredentials>().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 {

@ -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::<Host>()
.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()
)
})

@ -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(),
}
}))
);

@ -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<Destination> {
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<Destination> {
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<BoxedDistantWriterReader> {
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<BoxedDistantWriterReader> {
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<distant_ssh2
trace!("load_ssh({destination}, {extra}");
use distant_ssh2::{Ssh, SshOpts};
let host = destination.to_host_string();
let host = destination.host.to_string();
let opts = SshOpts {
backend: match extra.get("backend").or_else(|| extra.get("ssh.backend")) {
@ -387,14 +390,14 @@ fn load_ssh(destination: &Destination, extra: &Extra) -> io::Result<distant_ssh2
None => 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")

@ -19,11 +19,9 @@ impl From<Map> for ClientConnectConfig {
backend: map
.remove("ssh.backend")
.and_then(|x| x.parse::<distant_ssh2::SshBackend>().ok()),
username: map.remove("ssh.username"),
identity_file: map
.remove("ssh.identity_file")
.and_then(|x| x.parse::<PathBuf>().ok()),
port: map.remove("ssh.port").and_then(|x| x.parse::<u16>().ok()),
},
}
}
@ -39,20 +37,12 @@ impl From<ClientConnectConfig> 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<distant_ssh2::SshBackend>,
/// Username to use when sshing into remote machine
#[clap(name = "ssh-username", short = 'u', long)]
pub username: Option<String>,
/// Explicit identity file to use with ssh
#[clap(name = "ssh-identity-file", short = 'i', long)]
pub identity_file: Option<PathBuf>,
/// Port to use for sshing into the remote machine
#[clap(name = "ssh-port", short = 'p', long)]
pub port: Option<u16>,
}

@ -39,11 +39,9 @@ impl From<Map> for ClientLaunchConfig {
.remove("ssh.external")
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or_default(),
username: map.remove("ssh.username"),
identity_file: map
.remove("ssh.identity_file")
.and_then(|x| x.parse::<PathBuf>().ok()),
port: map.remove("ssh.port").and_then(|x| x.parse::<u16>().ok()),
},
}
}
@ -81,10 +79,6 @@ impl From<ClientLaunchConfig> 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<ClientLaunchConfig> 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<String>,
/// Explicit identity file to use with ssh
#[clap(name = "ssh-identity-file", short = 'i', long)]
pub identity_file: Option<PathBuf>,
/// Port to use for sshing into the remote machine
#[clap(name = "ssh-port", short = 'p', long)]
pub port: Option<u16>,
}

Loading…
Cancel
Save