use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::clone::ssh::drop_scp_scheme;
use crate::errors::SshHostError;
use crate::local_address::{local_address, LocalAddress};
#[derive(Debug, Clone, Copy, Default)]
pub struct AllowedLocal {
pub localhost: bool,
pub local_network: bool,
}
impl AllowedLocal {
const fn lets_through(self, local: LocalAddress) -> bool {
match local {
LocalAddress::Loopback => self.localhost,
LocalAddress::LocalNetwork => self.local_network,
}
}
}
enum Host<'a> {
Ip(IpAddr),
Name(&'a str),
}
impl fmt::Display for Host<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ip(ip) => ip.fmt(f),
Self::Name(name) => f.write_str(name),
}
}
}
impl Host<'_> {
async fn addresses(&self) -> Result<Vec<IpAddr>, SshHostError> {
match self {
Self::Ip(ip) => Ok(vec![*ip]),
Self::Name(name) => resolve(name).await,
}
}
}
pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), SshHostError> {
let host = stored_host(url).ok_or(SshHostError::Malformed)?;
let ips = host.addresses().await?;
if ips.is_empty() {
return Err(SshHostError::NoAddresses {
host: host.to_string(),
});
}
ips.into_iter()
.filter_map(local_address)
.find(|local| !allowed.lets_through(*local))
.map_or(Ok(()), |reason| {
Err(SshHostError::Local {
host: host.to_string(),
reason,
})
})
}
async fn resolve(name: &str) -> Result<Vec<IpAddr>, SshHostError> {
let addresses = tokio::net::lookup_host((name, 22))
.await
.map_err(|source| SshHostError::Unresolved {
host: name.to_owned(),
source,
})?;
Ok(addresses.map(|address| address.ip()).collect())
}
fn stored_host(url: &str) -> Option<Host<'_>> {
let url = drop_scp_scheme(url);
let (scp, rest) = url
.strip_prefix("ssh://")
.map_or((true, url), |rest| (false, rest));
let (user, rest) = rest.split_once('@')?;
if !is_user(user) || rest.contains('@') {
return None;
}
let (host, rest) = split_host(rest)?;
let path_follows = if scp {
rest.starts_with(':')
} else {
drop_port(rest).starts_with('/')
};
path_follows.then_some(host)
}
fn is_user(user: &str) -> bool {
let mut chars = user.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& !chars.as_str().is_empty()
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
fn split_host(rest: &str) -> Option<(Host<'_>, &str)> {
if let Some(bracketed) = rest.strip_prefix('[') {
let (ip, after) = bracketed.split_once(']')?;
return Some((Host::Ip(IpAddr::V6(ip.parse::<Ipv6Addr>().ok()?)), after));
}
let end = rest.find([':', '/']).unwrap_or(rest.len());
let (host, rest) = rest.split_at(end);
let host = match host.parse::<Ipv4Addr>() {
Ok(ip) => Host::Ip(IpAddr::V4(ip)),
Err(_) => Host::Name(is_name(host).then_some(host)?),
};
Some((host, rest))
}
fn drop_port(rest: &str) -> &str {
rest.strip_prefix(':').map_or(rest, |port| {
port.trim_start_matches(|c: char| c.is_ascii_digit())
})
}
fn is_name(host: &str) -> bool {
let name = host.strip_suffix('.').unwrap_or(host);
name.len() <= 253
&& name.split('.').all(|label| {
(1..=63).contains(&label.len())
&& label
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
})
}
#[cfg(test)]
mod tests {
use super::{validate_ssh_host, AllowedLocal};
use crate::errors::SshHostError;
use crate::local_address::LocalAddress;
async fn check(url: &str) -> Result<(), SshHostError> {
validate_ssh_host(url, AllowedLocal::default()).await
}
#[tokio::test]
async fn each_form_integrates_stores_is_checked_on_its_host() {
for url in [
"ssh://git@10.0.0.1/org/repo",
"ssh://git@10.0.0.1:2222/org/repo",
"ssh://git@10.0.0.1:v3/org/project/repo",
"ssh://git@10.0.0.1:org/repo",
"git@10.0.0.1:org/repo.git",
"ssh://git@[fc00::1]:22/org/repo",
"git@[fc00::1]:org/repo.git",
] {
assert!(
matches!(check(url).await, Err(SshHostError::Local { .. })),
"{url}"
);
}
}
#[tokio::test]
async fn a_url_outside_the_stored_forms_is_malformed() {
for url in [
"ssh:///org/repo",
"git@10.0.0.1:22@8.8.8.8:22/org/repo",
"ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
"ssh://8.8.8.8/org/repo",
"8.8.8.8:org/repo",
"g@8.8.8.8:org/repo",
"1git@8.8.8.8:org/repo",
"git@8.8.8.8/org/repo",
"ssh://git@8.8.8.8",
"git@[::1:org/repo",
"git@[not-an-ip]:org/repo",
"git@exa mple.com:org/repo",
"ssh://git@%31%30.0.0.1/org/repo",
"ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
"ssh://git@10.0.0.1%3a6379/org/repo",
"https://git@8.8.8.8/org/repo",
] {
assert!(
matches!(check(url).await, Err(SshHostError::Malformed)),
"{url}"
);
}
}
#[tokio::test]
async fn a_host_past_the_dns_limits_is_malformed() {
let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
let empty_label = "git@example..com:org/repo".to_owned();
for url in [long_label, long_name, empty_label] {
assert!(
matches!(check(&url).await, Err(SshHostError::Malformed)),
"{url}"
);
}
}
#[tokio::test]
async fn a_public_address_passes() {
check("git@8.8.8.8:org/repo.git").await.unwrap();
check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
}
#[tokio::test]
async fn a_name_that_does_not_resolve_is_unresolved() {
let result = check("git@does-not-exist.invalid:org/repo.git").await;
assert!(matches!(result, Err(SshHostError::Unresolved { .. })));
}
#[tokio::test]
async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
let result = check("git@localhost:org/repo.git").await;
assert!(matches!(
result,
Err(SshHostError::Local {
reason: LocalAddress::Loopback,
..
})
));
}
#[tokio::test]
async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
let allowed = AllowedLocal {
localhost: true,
local_network: false,
};
validate_ssh_host("git@127.0.0.1:org/repo", allowed)
.await
.unwrap();
assert!(matches!(
validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
Err(SshHostError::Local {
reason: LocalAddress::LocalNetwork,
..
})
));
}
#[tokio::test]
async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
let allowed = AllowedLocal {
localhost: false,
local_network: true,
};
validate_ssh_host("git@10.0.0.1:org/repo", allowed)
.await
.unwrap();
assert!(matches!(
validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
Err(SshHostError::Local {
reason: LocalAddress::Loopback,
..
})
));
}
}