use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use anyhow::{Context, Result, bail};
use crate::config::GUEST_USER;
pub fn quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub fn ensure_key(key: &Path) -> Result<String> {
if !key.exists() {
let status = Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "bsdt", "-f"])
.arg(key)
.status()
.context("running ssh-keygen (is OpenSSH installed?)")?;
if !status.success() {
bail!("ssh-keygen failed");
}
}
let public = key.with_extension("pub");
std::fs::read_to_string(&public).with_context(|| format!("reading {}", public.display()))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum User {
Guest,
Root,
}
impl User {
fn name(self) -> &'static str {
match self {
User::Guest => GUEST_USER,
User::Root => "root",
}
}
}
pub struct Ssh {
pub key: PathBuf,
pub port: u16,
}
impl Ssh {
fn options(&self) -> Vec<String> {
let mut opts = vec!["-i".into(), self.key.to_string_lossy().into_owned(), "-p".into(), self.port.to_string()];
for opt in [
"StrictHostKeyChecking=no",
"UserKnownHostsFile=/dev/null",
"LogLevel=ERROR",
"IdentitiesOnly=yes",
"ConnectTimeout=5",
] {
opts.push("-o".into());
opts.push(opt.into());
}
opts
}
fn command(&self, user: User) -> Command {
let mut cmd = Command::new("ssh");
cmd.args(self.options()).arg(format!("{}@127.0.0.1", user.name()));
cmd
}
pub fn probe(&self, user: User, script: &str) -> Result<ExitStatus> {
Ok(self
.command(user)
.args(["-o", "BatchMode=yes"])
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?)
}
pub fn run(&self, user: User, script: &str) -> Result<ExitStatus> {
let mut cmd = self.command(user);
if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
cmd.arg("-t");
}
Ok(cmd.arg(script).status()?)
}
pub fn run_checked(&self, user: User, script: &str) -> Result<()> {
let status = self.run(user, script)?;
if !status.success() {
bail!("`{script}` failed in the guest ({status})");
}
Ok(())
}
pub fn script(&self, user: User, script: &str) -> Result<()> {
let status = self.command(user).arg("-T").arg(script).stdin(Stdio::null()).status()?;
if !status.success() {
bail!("a setup script failed in the guest ({status})");
}
Ok(())
}
pub fn shell(&self, user: User, dir: &str) -> Result<ExitStatus> {
let mut cmd = self.command(user);
cmd.arg("-t").arg(format!("cd {} 2>/dev/null; exec \"$SHELL\" -l", quote(dir)));
Ok(cmd.status()?)
}
pub fn rsync(&self, src: &Path, dest: &str, exclude: &[String]) -> Result<()> {
let shell = std::iter::once("ssh".to_string())
.chain(self.options().iter().map(|o| quote(o)))
.collect::<Vec<_>>()
.join(" ");
let mut cmd = Command::new("rsync");
cmd.args(["-a", "--delete", "--exclude", "/.bsdt/"]);
for pattern in exclude {
cmd.arg("--exclude").arg(pattern);
}
let status = cmd
.arg("-e")
.arg(shell)
.arg(format!("--rsync-path=mkdir -p {} && rsync", quote(dest)))
.arg(format!("{}/", src.display()))
.arg(format!("{GUEST_USER}@127.0.0.1:{dest}/"))
.status()
.context("running rsync (is it installed?)")?;
if !status.success() {
bail!("rsync to the guest failed ({status})");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quotes_for_sh() {
assert_eq!(quote("plain"), "'plain'");
assert_eq!(quote("it's"), r"'it'\''s'");
assert_eq!(quote("$HOME; rm"), "'$HOME; rm'");
}
}