use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use puressh::client::{Client, Config};
use puressh::key::PrivateKey;
use puressh::known_hosts::KnownHosts;
use puressh::shared::{OwnedChannelStream, SharedClient};
use crate::error::{Error, Result};
use crate::oid::HashAlgo;
use crate::protocol::RefAdvertisement;
use crate::protocol::pktline::{self, Packet};
use super::{Service, Transport};
const MAX_ADVERTISEMENT_BYTES: usize = 128 * 1024 * 1024;
const MAX_EXCHANGE_BYTES: usize = 2 * 1024 * 1024 * 1024;
pub struct SshTransport {
host: String,
port: u16,
user: String,
path: String,
algo: HashAlgo,
password: Option<String>,
key: Option<(std::path::PathBuf, Option<String>)>,
shared: Option<SharedClient>,
stream: Option<OwnedChannelStream>,
service: Option<Service>,
}
impl SshTransport {
pub fn new(
user: impl Into<String>,
host: impl Into<String>,
port: u16,
path: impl Into<String>,
algo: HashAlgo,
) -> Self {
SshTransport {
host: host.into(),
port,
user: user.into(),
path: path.into(),
algo,
password: None,
key: None,
shared: None,
stream: None,
service: None,
}
}
pub fn from_url(url: &str, algo: HashAlgo) -> Result<Self> {
let (user, host, port, path) = parse_ssh_url(url)?;
let mut transport = SshTransport::new(user, host, port, path, algo);
if let Some(text) = read_ssh_config() {
transport.apply_config(&text, url);
}
Ok(transport)
}
fn apply_config(&mut self, config_text: &str, url: &str) {
let cfg = super::ssh_config::resolve(config_text, &self.host);
if let Some(hostname) = cfg.hostname
&& validate_host(&hostname).is_ok()
{
self.host = hostname;
}
if !url_has_explicit_user(url)
&& let Some(user) = cfg.user
&& validate_user(&user).is_ok()
{
self.user = user;
}
if !url_has_explicit_port(url)
&& let Some(port) = cfg.port
{
self.port = port;
}
if self.key.is_none()
&& let Some(identity) = cfg.identity_file
{
self.key = Some((expand_tilde(&identity), None));
}
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn with_key(
mut self,
path: impl Into<std::path::PathBuf>,
passphrase: Option<String>,
) -> Self {
self.key = Some((path.into(), passphrase));
self
}
fn exec_command(&self, service: Service) -> String {
alloc::format!("{} {}", service.name(), shell_single_quote(&self.path))
}
fn ssh_config(&self) -> Config {
let known = std::env::var("HOME")
.ok()
.map(|home| std::path::PathBuf::from(home).join(".ssh/known_hosts"))
.and_then(|path| KnownHosts::load(path).ok())
.unwrap_or_default();
Config::with_known_hosts(Arc::new(Mutex::new(known)))
}
fn ensure_connected(&mut self) -> Result<()> {
if self.shared.is_some() {
return Ok(());
}
let mut client = Client::connect_to_host(&self.host, self.port, self.ssh_config())
.map_err(map_ssh_err)?;
if let Some(pw) = &self.password {
client
.authenticate_password(&self.user, pw)
.map_err(map_ssh_err)?;
} else {
let mut creds = agent_credentials();
if let Some(host_key) = self.load_private_key()? {
creds.push(puressh::auth::ClientCredential::PublicKey(host_key));
}
if creds.is_empty() {
return Err(Error::Unsupported(
"SSH transport: no usable credential — set a password (with_password), a key \
(with_key), place an unencrypted OpenSSH key at ~/.ssh/id_ed25519, or run an \
ssh-agent holding your key"
.into(),
));
}
client
.authenticate(&self.user, creds)
.map_err(map_ssh_err)?;
}
self.shared = Some(SharedClient::from(client));
Ok(())
}
fn load_private_key(
&self,
) -> Result<Option<alloc::boxed::Box<dyn puressh::hostkey::HostKey + Send>>> {
if let Some((path, passphrase)) = &self.key {
let pem = std::fs::read_to_string(path).map_err(|e| {
Error::Io(alloc::format!("reading ssh key {}: {e}", path.display()))
})?;
let key = PrivateKey::parse_openssh_pem(&pem, passphrase.as_deref().map(str::as_bytes))
.map_err(map_ssh_err)?;
return Ok(Some(key.into_host_key().map_err(map_ssh_err)?));
}
if let Ok(home) = std::env::var("HOME") {
for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
let path = std::path::PathBuf::from(&home).join(".ssh").join(name);
let Ok(pem) = std::fs::read_to_string(&path) else {
continue;
};
if let Ok(key) = PrivateKey::parse_openssh_pem(&pem, None)
&& let Ok(hk) = key.into_host_key()
{
return Ok(Some(hk));
}
}
}
Ok(None)
}
fn open_service(&mut self, service: Service) -> Result<Vec<u8>> {
self.ensure_connected()?;
let cmd = self.exec_command(service);
let shared = self.shared.as_ref().expect("connected");
let stream = shared.exec_stream(&cmd).map_err(map_ssh_err)?;
self.stream = Some(stream);
self.service = Some(service);
read_until_flush(self.stream.as_mut().unwrap())
}
}
impl Transport for SshTransport {
fn discover(&mut self, service: Service) -> Result<RefAdvertisement> {
let advert = self.open_service(service)?;
let packets = pktline::decode_all_bounded(&advert, pktline::MAX_PACKETS)?;
RefAdvertisement::parse(self.algo, &packets)
}
fn exchange(&mut self, service: Service, request: &[u8]) -> Result<Vec<u8>> {
if self.service != Some(service) || self.stream.is_none() {
self.open_service(service)?;
}
let stream = self
.stream
.as_mut()
.ok_or_else(|| Error::Protocol("ssh: no open channel".into()))?;
stream
.write_all(request)
.map_err(|e| Error::Io(e.to_string()))?;
stream.flush().map_err(|e| Error::Io(e.to_string()))?;
read_capped(stream, MAX_EXCHANGE_BYTES)
}
}
fn read_retrying(stream: &mut OwnedChannelStream, buf: &mut [u8]) -> Result<usize> {
loop {
match stream.read(buf) {
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
other => return other.map_err(|e| Error::Io(e.to_string())),
}
}
}
fn read_capped(stream: &mut OwnedChannelStream, max: usize) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let mut scratch = [0u8; 8192];
loop {
let n = read_retrying(stream, &mut scratch)?;
if n == 0 {
return Ok(buf);
}
if buf.len() + n > max {
return Err(Error::Protocol("ssh: response exceeded size limit".into()));
}
buf.extend_from_slice(&scratch[..n]);
}
}
fn read_until_flush(stream: &mut OwnedChannelStream) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let mut scratch = [0u8; 8192];
loop {
if let Some(end) = scan_for_flush(&buf)? {
return Ok(buf[..end].to_vec());
}
let n = read_retrying(stream, &mut scratch)?;
if n == 0 {
return Ok(buf);
}
if buf.len() + n > MAX_ADVERTISEMENT_BYTES {
return Err(Error::Protocol(
"ssh: advertisement exceeded size limit (no flush)".into(),
));
}
buf.extend_from_slice(&scratch[..n]);
}
}
fn scan_for_flush(buf: &[u8]) -> Result<Option<usize>> {
let mut off = 0;
loop {
match pktline::decode(&buf[off..])? {
Some((Packet::Flush, n)) => return Ok(Some(off + n)),
Some((_, n)) => off += n,
None => return Ok(None),
}
if off >= buf.len() {
return Ok(None);
}
}
}
fn shell_single_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
fn validate_host(host: &str) -> Result<()> {
if host.is_empty() {
return Err(Error::Protocol("ssh url: empty host".into()));
}
if host.starts_with('-') {
return Err(Error::Protocol("ssh url: host starts with '-'".into()));
}
if host
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '\0')
{
return Err(Error::Protocol(
"ssh url: host contains whitespace or control characters".into(),
));
}
Ok(())
}
fn validate_user(user: &str) -> Result<()> {
if user.is_empty() {
return Err(Error::Protocol("ssh url: empty user".into()));
}
if user.starts_with('-') {
return Err(Error::Protocol("ssh url: user starts with '-'".into()));
}
if user
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '\0' || c == '@' || c == ':')
{
return Err(Error::Protocol(
"ssh url: user contains invalid characters".into(),
));
}
Ok(())
}
fn validate_path(path: &str) -> Result<()> {
if path.chars().any(|c| c.is_control() || c == '\0') {
return Err(Error::Protocol(
"ssh url: path contains control characters".into(),
));
}
if path.starts_with('-') {
return Err(Error::Protocol("ssh url: path starts with '-'".into()));
}
Ok(())
}
#[cfg(unix)]
fn agent_credentials() -> Vec<puressh::auth::ClientCredential> {
use puressh::agent::{Agent, AgentHostKey};
let agent = match Agent::connect_env() {
Ok(Some(a)) => a,
_ => return Vec::new(),
};
let agent = Arc::new(Mutex::new(agent));
let identities = match agent.lock() {
Ok(mut a) => a.identities().unwrap_or_default(),
Err(_) => return Vec::new(),
};
identities
.into_iter()
.filter_map(|id| {
AgentHostKey::from_identity(Arc::clone(&agent), id.key_blob)
.ok()
.map(|hk| puressh::auth::ClientCredential::PublicKey(alloc::boxed::Box::new(hk)))
})
.collect()
}
#[cfg(not(unix))]
fn agent_credentials() -> Vec<puressh::auth::ClientCredential> {
Vec::new()
}
fn read_ssh_config() -> Option<String> {
let home = std::env::var("HOME").ok()?;
std::fs::read_to_string(std::path::PathBuf::from(home).join(".ssh/config")).ok()
}
fn expand_tilde(path: &str) -> std::path::PathBuf {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return std::path::PathBuf::from(home).join(rest);
}
std::path::PathBuf::from(path)
}
fn url_authority(url: &str) -> &str {
if let Some(rest) = url.strip_prefix("ssh://") {
rest.split('/').next().unwrap_or(rest)
} else {
url.split_once(':').map(|(a, _)| a).unwrap_or(url)
}
}
fn url_has_explicit_user(url: &str) -> bool {
url_authority(url).contains('@')
}
fn url_has_explicit_port(url: &str) -> bool {
if !url.starts_with("ssh://") {
return false; }
let authority = url_authority(url);
let hostpart = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
hostpart.contains(':')
}
fn parse_ssh_url(url: &str) -> Result<(String, String, u16, String)> {
let (user, host, port, path) = if let Some(rest) = url.strip_prefix("ssh://") {
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, alloc::format!("/{p}")),
None => (rest, String::from("/")),
};
let (user, hostport) = match authority.split_once('@') {
Some((u, h)) => (u.to_string(), h),
None => ("git".to_string(), authority),
};
let (host, port) = match hostport.split_once(':') {
Some((h, p)) => (
h.to_string(),
p.parse()
.map_err(|_| Error::Protocol("ssh url: bad port".into()))?,
),
None => (hostport.to_string(), 22),
};
(user, host, port, path)
} else if let Some((authority, path)) = url.split_once(':') {
let (user, host) = match authority.split_once('@') {
Some((u, h)) => (u.to_string(), h.to_string()),
None => ("git".to_string(), authority.to_string()),
};
(user, host, 22, path.to_string())
} else {
return Err(Error::Protocol(alloc::format!("not an ssh url: {url}")));
};
validate_user(&user)?;
validate_host(&host)?;
validate_path(&path)?;
Ok((user, host, port, path))
}
fn map_ssh_err(e: puressh::Error) -> Error {
Error::Io(alloc::format!("ssh: {e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_config_resolves_alias_and_respects_explicit_url_fields() {
let cfg =
"Host gh\n HostName github.com\n User git\n Port 2222\n IdentityFile /keys/id_gh\n";
let mut t = SshTransport::new("git", "gh", 22, "/r.git", HashAlgo::Sha1);
t.apply_config(cfg, "gh:/r.git");
assert_eq!(t.host, "github.com");
assert_eq!(t.user, "git");
assert_eq!(t.port, 2222);
assert_eq!(
t.key.as_ref().map(|(p, _)| p.as_path()),
Some("/keys/id_gh".as_ref())
);
let mut t2 = SshTransport::new("alice", "gh", 2000, "/r.git", HashAlgo::Sha1);
t2.apply_config(cfg, "ssh://alice@gh:2000/r.git");
assert_eq!(t2.host, "github.com");
assert_eq!(t2.user, "alice");
assert_eq!(t2.port, 2000);
}
#[test]
fn parse_ssh_scheme_url() {
let (u, h, p, path) = parse_ssh_url("ssh://git@example.com:2222/group/repo.git").unwrap();
assert_eq!(u, "git");
assert_eq!(h, "example.com");
assert_eq!(p, 2222);
assert_eq!(path, "/group/repo.git");
}
#[test]
fn parse_scp_style_url() {
let (u, h, p, path) = parse_ssh_url("git@github.com:owner/repo.git").unwrap();
assert_eq!(u, "git");
assert_eq!(h, "github.com");
assert_eq!(p, 22);
assert_eq!(path, "owner/repo.git");
}
#[test]
fn exec_command_quotes_path() {
let t = SshTransport::new("git", "h", 22, "a/b.git", HashAlgo::Sha1);
assert_eq!(
t.exec_command(Service::UploadPack),
"git-upload-pack 'a/b.git'"
);
}
#[test]
fn shell_single_quote_escapes_injection() {
assert_eq!(shell_single_quote("a/b.git"), "'a/b.git'");
assert_eq!(shell_single_quote("foo';id;'"), "'foo'\\'';id;'\\'''");
let q = shell_single_quote("x'y");
assert_eq!(q, "'x'\\''y'");
}
#[test]
fn parse_ssh_url_rejects_injection_path() {
assert!(parse_ssh_url("ssh://git@example.com/foo\0bar").is_err());
assert!(parse_ssh_url("git@example.com:-oProxyCommand=x").is_err());
}
#[test]
fn parse_ssh_url_rejects_bad_host() {
assert!(parse_ssh_url("ssh://git@/repo.git").is_err());
assert!(parse_ssh_url("ssh://git@-h/repo.git").is_err());
assert!(parse_ssh_url("git@bad host:repo.git").is_err());
assert!(parse_ssh_url("ssh://git@example.com:22/repo.git").is_ok());
}
}