use anyhow::Context;
use std::path::PathBuf;
use std::sync::Arc;
const TRANSFER_HINTS: &str = "\
This may indicate:\n\
- Insufficient disk space on remote host\n\
- Permission denied creating $HOME/.cache/rcp/bin\n\
- base64 command not available on remote host";
fn format_write_error(
write_err: &std::io::Error,
stderr_data: &[u8],
status: &dyn std::fmt::Display,
) -> String {
let stderr = String::from_utf8_lossy(stderr_data);
let stderr = stderr.trim();
if stderr.is_empty() {
format!(
"failed to write base64 data to remote stdin: {write_err}\n\
\n\
remote command exited with status: {status}\n\
remote stderr was empty\n\
\n\
{TRANSFER_HINTS}"
)
} else {
format!(
"failed to write base64 data to remote stdin: {write_err}\n\
\n\
remote stderr: {stderr}\n\
\n\
{TRANSFER_HINTS}"
)
}
}
pub fn find_local_rcpd_binary() -> anyhow::Result<PathBuf> {
let mut searched_paths = Vec::new();
if let Ok(current_exe) = std::env::current_exe()
&& let Some(bin_dir) = current_exe.parent()
{
let path = bin_dir.join("rcpd");
searched_paths.push(format!("Same directory: {}", path.display()));
if path.exists() && path.is_file() {
tracing::info!("Found local rcpd binary at {}", path.display());
return Ok(path);
}
}
tracing::debug!("Trying to find rcpd in PATH");
let which_output = std::process::Command::new("which")
.arg("rcpd")
.output()
.ok();
if let Some(output) = which_output
&& output.status.success()
{
let path_str = String::from_utf8_lossy(&output.stdout);
let path_str = path_str.trim();
if !path_str.is_empty() {
let path = PathBuf::from(path_str);
searched_paths.push(format!("PATH: {}", path.display()));
if path.exists() && path.is_file() {
tracing::info!("Found local rcpd binary in PATH: {}", path.display());
return Ok(path);
}
}
}
anyhow::bail!(
"no local rcpd binary found for deployment\n\
\n\
Searched in:\n\
{}\n\
\n\
To use auto-deployment, ensure rcpd is available:\n\
- cargo install rcp-tools-rcp (installs to ~/.cargo/bin)\n\
- or add rcpd to PATH\n\
- or build with: cargo build --release --bin rcpd",
searched_paths
.iter()
.map(|p| format!("- {}", p))
.collect::<Vec<_>>()
.join("\n")
)
}
pub async fn deploy_rcpd(
session: &Arc<openssh::Session>,
local_rcpd_path: &std::path::Path,
version: &str,
remote_host: &str,
) -> anyhow::Result<String> {
tracing::info!(
"Deploying rcpd {} to remote host '{}'",
version,
remote_host
);
let binary = tokio::fs::read(local_rcpd_path).await.with_context(|| {
format!(
"failed to read local rcpd binary from {}",
local_rcpd_path.display()
)
})?;
tracing::info!(
"Read local rcpd binary ({} bytes) from {}",
binary.len(),
local_rcpd_path.display()
);
let expected_checksum = compute_sha256(&binary);
tracing::debug!("Expected SHA-256: {}", hex::encode(&expected_checksum));
let home = crate::get_remote_home(session).await?;
let remote_path = format!("{}/.cache/rcp/bin/rcpd-{}", home, version);
transfer_binary_base64(session, &binary, &remote_path).await?;
tracing::info!("Binary transferred to {}", remote_path);
verify_remote_checksum(session, &remote_path, &expected_checksum).await?;
tracing::info!("Checksum verified successfully");
Ok(remote_path)
}
async fn transfer_binary_base64(
session: &Arc<openssh::Session>,
binary: &[u8],
remote_path: &str,
) -> anyhow::Result<()> {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(binary);
let path = std::path::Path::new(remote_path);
let dir = path
.parent()
.context("remote path must have a parent directory")?
.to_str()
.context("remote path parent must be valid UTF-8")?;
let filename = path
.file_name()
.context("remote path must have a filename")?
.to_str()
.context("remote filename must be valid UTF-8")?;
let temp_filename = if let Some(version) = filename.strip_prefix("rcpd-") {
format!(".rcpd-{}.tmp.$$", version)
} else {
format!(".{}.tmp.$$", filename)
};
let dir_escaped = crate::shell_escape(dir);
let temp_path = format!("{}/{}", dir, temp_filename);
let temp_path_escaped = crate::shell_escape(&temp_path);
let final_path = format!("{}/{}", dir, filename);
let final_path_escaped = crate::shell_escape(&final_path);
let cmd = format!(
"mkdir -p {} && \
base64 -d > {} && \
chmod 700 {} && \
mv -f {} {}",
dir_escaped, temp_path_escaped, temp_path_escaped, temp_path_escaped, final_path_escaped
);
tracing::debug!("Running remote command: mkdir && base64 && chmod");
let mut child = session
.command("sh")
.arg("-c")
.arg(&cmd)
.stdin(openssh::Stdio::piped())
.stdout(openssh::Stdio::piped())
.stderr(openssh::Stdio::piped())
.spawn()
.await
.context("failed to spawn remote command for binary transfer")?;
let mut stdin = child
.stdin()
.take()
.context("failed to get stdin for remote command")?;
let mut stdout = child
.stdout()
.take()
.context("failed to get stdout for remote command")?;
let mut stderr = child
.stderr()
.take()
.context("failed to get stderr for remote command")?;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let write_result = stdin.write_all(encoded.as_bytes()).await;
if write_result.is_ok() {
stdin.shutdown().await.context("failed to shutdown stdin")?;
}
drop(stdin);
let stdout_fut = async {
let mut buf = Vec::new();
let _ = stdout.read_to_end(&mut buf).await;
buf
};
let stderr_fut = async {
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
buf
};
let (_stdout_data, stderr_data) = tokio::join!(stdout_fut, stderr_fut);
let status = child
.wait()
.await
.context("failed to wait for remote command completion")?;
if let Err(write_err) = write_result {
anyhow::bail!("{}", format_write_error(&write_err, &stderr_data, &status));
}
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr_data);
anyhow::bail!(
"failed to transfer binary to remote host\n\
\n\
stderr: {}\n\
\n\
{TRANSFER_HINTS}",
stderr
);
}
Ok(())
}
async fn verify_remote_checksum(
session: &Arc<openssh::Session>,
remote_path: &str,
expected_checksum: &[u8],
) -> anyhow::Result<()> {
let cmd = format!("sha256sum {}", crate::shell_escape(remote_path));
tracing::debug!("Verifying checksum on remote host");
let output = session
.command("sh")
.arg("-c")
.arg(&cmd)
.output()
.await
.context("failed to run sha256sum on remote host")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"failed to compute checksum on remote host\n\
stderr: {}",
stderr
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let remote_checksum = stdout
.split_whitespace()
.next()
.context("unexpected sha256sum output format")?;
let expected_hex = hex::encode(expected_checksum);
if remote_checksum != expected_hex {
anyhow::bail!(
"checksum mismatch after transfer\n\
\n\
Expected: {}\n\
Got: {}\n\
\n\
The binary transfer may have been corrupted.\n\
Please try again or check network connectivity.",
expected_hex,
remote_checksum
);
}
Ok(())
}
fn compute_sha256(data: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
Sha256::digest(data).to_vec()
}
pub async fn cleanup_old_versions(
session: &Arc<openssh::Session>,
keep_count: usize,
) -> anyhow::Result<()> {
tracing::debug!("Cleaning up old rcpd versions (keeping {})", keep_count);
let home = match crate::get_remote_home(session).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(
"cleanup of old versions skipped (HOME not available): {:#}",
e
);
return Ok(());
}
};
let cache_dir = format!("{}/.cache/rcp/bin", home);
let cmd = format!(
"cd {} 2>/dev/null && ls -t rcpd-* 2>/dev/null | tail -n +{} | xargs -r rm -f",
crate::shell_escape(&cache_dir),
keep_count + 1
);
let output = session
.command("sh")
.arg("-c")
.arg(&cmd)
.output()
.await
.context("failed to run cleanup command on remote host")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("cleanup of old versions failed (non-fatal): {}", stderr);
} else {
tracing::debug!("Old versions cleaned up successfully");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_sha256() {
let data = b"hello world";
let hash = compute_sha256(data);
let expected =
hex::decode("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9")
.unwrap();
assert_eq!(hash, expected);
}
#[test]
fn test_compute_sha256_empty() {
let data = b"";
let hash = compute_sha256(data);
let expected =
hex::decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
.unwrap();
assert_eq!(hash, expected);
}
#[test]
fn test_compute_sha256_binary() {
let data: Vec<u8> = (0..256).map(|i| i as u8).collect();
let hash = compute_sha256(&data);
assert_eq!(hash.len(), 32);
let hash2 = compute_sha256(&data);
assert_eq!(hash, hash2);
}
#[test]
fn write_error_with_stderr_includes_remote_output() {
let err = std::io::Error::from_raw_os_error(32); let stderr = b"mkdir: cannot create directory: Permission denied";
let msg = format_write_error(&err, stderr, &"exited with 1");
assert!(msg.contains("Broken pipe"), "should contain write error");
assert!(
msg.contains("Permission denied"),
"should contain remote stderr"
);
assert!(
msg.contains("This may indicate"),
"should contain hint text"
);
assert!(
!msg.contains("remote command exited with status"),
"should omit status when stderr is available"
);
}
#[test]
fn write_error_without_stderr_includes_exit_status() {
let err = std::io::Error::from_raw_os_error(32);
let stderr = b"";
let msg = format_write_error(&err, stderr, &"exited with 126");
assert!(msg.contains("Broken pipe"), "should contain write error");
assert!(
msg.contains("remote command exited with status: exited with 126"),
"should contain exit status"
);
assert!(
msg.contains("remote stderr was empty"),
"should note stderr was empty"
);
assert!(
msg.contains("This may indicate"),
"should contain hint text"
);
}
#[test]
fn write_error_trims_whitespace_only_stderr() {
let err = std::io::Error::from_raw_os_error(32);
let stderr = b" \n\t ";
let msg = format_write_error(&err, stderr, &"exited with 1");
assert!(
msg.contains("remote stderr was empty"),
"whitespace-only stderr should be treated as empty"
);
}
}