use std::io::{Read, Write as _};
use std::process::{Command, Stdio};
use anyhow::Context;
use camino::Utf8PathBuf;
use globset::GlobSet;
use crate::diff::walker::Entry;
#[derive(Debug, Clone)]
pub struct SshUri {
pub user: String,
pub host: String,
pub port: u16,
pub path: String,
}
impl SshUri {
pub fn parse(s: &str) -> anyhow::Result<Self> {
let rest = s
.strip_prefix("ssh://")
.ok_or_else(|| anyhow::anyhow!("not an SSH URI: {s:?}"))?;
let (userhost, path_tail) = rest.split_once('/').unwrap_or((rest, ""));
let path = format!("/{path_tail}");
let (user, hostport) = if let Some((u, h)) = userhost.rsplit_once('@') {
(u.to_owned(), h)
} else {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_owned());
(user, userhost)
};
let (host, port) = if let Some((h, p)) = hostport.rsplit_once(':') {
let port: u16 = p.parse().context("invalid port in SSH URI")?;
(h.to_owned(), port)
} else {
(hostport.to_owned(), 22)
};
Ok(Self {
user,
host,
port,
path,
})
}
pub fn is_ssh(s: &str) -> bool {
s.starts_with("ssh://")
}
}
#[derive(Debug, Clone)]
pub struct SshSession {
user: String,
host: String,
port: u16,
pub remote_root: String,
}
impl SshSession {
pub fn connect(uri: &SshUri) -> anyhow::Result<Self> {
let status = ssh_cmd(&uri.user, &uri.host, uri.port)
.arg("true")
.status()
.context("cannot run `ssh` — is it installed and in PATH?")?;
if !status.success() {
anyhow::bail!(
"SSH connection to {}@{}:{} failed (exit {}). \
Ensure your public key is in the remote's authorized_keys \
and the host is reachable.",
uri.user,
uri.host,
uri.port,
status.code().unwrap_or(-1),
);
}
tracing::debug!(
user = uri.user,
host = uri.host,
port = uri.port,
path = uri.path,
"SSH connection verified"
);
Ok(Self {
user: uri.user.clone(),
host: uri.host.clone(),
port: uri.port,
remote_root: uri.path.clone(),
})
}
pub fn walk(&self, exclude: &GlobSet, include: Option<&GlobSet>) -> anyhow::Result<Vec<Entry>> {
let find_cmd = format!(
"find {} -type f -printf '%P\\t%s\\t%T@\\n'",
shell_quote(&self.remote_root)
);
let output = self
.ssh_cmd()
.arg(&find_cmd)
.output()
.context("failed to run remote `find`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("remote `find` failed: {stderr}");
}
let text = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(3, '\t');
let (Some(rel_raw), Some(size_s), Some(mtime_s)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
let rel_str = rel_raw.replace('\\', "/");
let size: u64 = size_s.parse().unwrap_or(0);
let mtime_ns = mtime_s
.trim()
.parse::<f64>()
.map(|s| (s * 1_000_000_000.0) as i128)
.unwrap_or(0);
let rel = Utf8PathBuf::from(&rel_str);
if exclude.is_match(&rel_str) {
continue;
}
if let Some(inc) = include {
if !inc.is_match(&rel_str) {
continue;
}
}
entries.push(Entry {
rel,
size,
mtime_ns,
hash: None, src_ext: None,
});
}
entries.sort_unstable_by(|a, b| a.rel.cmp(&b.rel));
Ok(entries)
}
pub fn download(
&self,
rel_path: &str,
dst_tmp: &camino::Utf8Path,
mut on_progress: impl FnMut(u64),
) -> anyhow::Result<u64> {
let remote_path = format!("{}/{}", self.remote_root.trim_end_matches('/'), rel_path);
let cat_cmd = format!("cat {}", shell_quote(&remote_path));
let mut child = self
.ssh_cmd()
.arg(&cat_cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn ssh download")?;
let mut stdout = child.stdout.take().expect("stdout is piped");
let dst_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(dst_tmp.as_std_path())
.with_context(|| format!("cannot create {dst_tmp}"))?;
let mut writer = std::io::BufWriter::with_capacity(SFTP_BUF, dst_file);
let mut buf = vec![0u8; SFTP_BUF];
let mut total = 0u64;
loop {
let n = stdout.read(&mut buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
total += n as u64;
on_progress(n as u64);
}
writer.flush()?;
writer
.into_inner()
.context("SSH download flush error")?
.sync_data()?;
let status = child.wait()?;
if !status.success() {
anyhow::bail!("SSH download exited with error for {rel_path:?}");
}
Ok(total)
}
fn ssh_cmd(&self) -> Command {
ssh_cmd(&self.user, &self.host, self.port)
}
}
const SFTP_BUF: usize = 256 * 1024;
fn ssh_cmd(user: &str, host: &str, port: u16) -> Command {
let mut cmd = Command::new("ssh");
cmd.args([
"-p",
&port.to_string(),
"-o",
"StrictHostKeyChecking=accept-new", "-o",
"BatchMode=yes", &format!("{user}@{host}"),
]);
cmd
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}