Skip to main content

smbcloud_deploy/
transport.rs

1//! Shipping a source tree to its deploy target.
2//!
3//! A [`Transport`] takes files that are ready to ship and puts them on the
4//! server. Today the only one is [`RsyncTransport`] (rsync over SSH with the
5//! server host key pinned). A git-smart-HTTP transport can implement the same
6//! trait later without changing any callers.
7
8use crate::{error::DeployError, known_hosts, report::Reporter};
9use anyhow::anyhow;
10use std::{
11    io::Write,
12    path::{Path, PathBuf},
13    process::Command,
14};
15use tempfile::NamedTempFile;
16
17/// Ships a source tree to its deploy target.
18pub trait Transport {
19    /// Transfer `source` to the target, reporting progress via `reporter`.
20    fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError>;
21}
22
23/// rsync over SSH with the server's host key pinned (see [`known_hosts`]).
24///
25/// Auth and topology are resolved by the caller and passed in: the front-end
26/// owns where the identity file lives and how the remote path is derived, so the
27/// engine makes no assumptions about `~/.ssh` layout or project naming.
28pub struct RsyncTransport {
29    host: String,
30    remote_path: String,
31    identity_file: PathBuf,
32}
33
34impl RsyncTransport {
35    pub fn new(host: String, remote_path: String, identity_file: PathBuf) -> Self {
36        Self {
37            host,
38            remote_path,
39            identity_file,
40        }
41    }
42}
43
44impl Transport for RsyncTransport {
45    fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError> {
46        // Pin the server's host key to a temp known_hosts file for this transfer,
47        // protecting every user against DNS/BGP hijacking even on untrusted
48        // networks. The file is deleted when it drops at the end of this call.
49        let mut known_hosts_file = NamedTempFile::new()
50            .map_err(|e| anyhow!("Failed to create temp known_hosts file: {e}"))?;
51        writeln!(known_hosts_file, "{}", known_hosts::for_host(&self.host))
52            .map_err(|e| anyhow!("Failed to write known_hosts: {e}"))?;
53
54        // Fully hardened SSH: only the pinned host key is trusted, only the given
55        // identity is used, no password fallback, never prompt.
56        let ssh_command = format!(
57            "ssh -i {identity} \
58             -o StrictHostKeyChecking=yes \
59             -o UserKnownHostsFile={known_hosts} \
60             -o IdentitiesOnly=yes \
61             -o PasswordAuthentication=no \
62             -o BatchMode=yes",
63            identity = self.identity_file.to_string_lossy(),
64            known_hosts = known_hosts_file.path().display(),
65        );
66
67        // `rsync -a src/` syncs contents, not the directory itself, so both
68        // sides need a trailing slash.
69        let source_str = source.to_string_lossy();
70        let source_with_slash = if source_str.ends_with('/') {
71            source_str.into_owned()
72        } else {
73            format!("{source_str}/")
74        };
75        let remote_with_slash = if self.remote_path.ends_with('/') {
76            self.remote_path.clone()
77        } else {
78            format!("{}/", self.remote_path)
79        };
80        let destination = format!("git@{}:{}", self.host, remote_with_slash);
81
82        reporter.step_start(&format!("Syncing {source_with_slash} -> {destination}"));
83
84        // known_hosts_file must stay alive until rsync exits so SSH can read it.
85        let output = Command::new("rsync")
86            .args([
87                "-a",
88                "--exclude=.git",
89                "--exclude=.smb",
90                "-e",
91                &ssh_command,
92                &source_with_slash,
93                &destination,
94            ])
95            .output();
96        drop(known_hosts_file);
97
98        match output {
99            Ok(result) if result.status.success() => {
100                for line in String::from_utf8_lossy(&result.stderr).lines() {
101                    reporter.remote_line(line);
102                }
103                reporter.step_done("Deployment complete via rsync.");
104                Ok(())
105            }
106            Ok(result) => {
107                let code = result.status.code().unwrap_or(-1);
108                reporter.step_fail(&format!("rsync exited with status {code}"));
109                for line in String::from_utf8_lossy(&result.stderr).lines() {
110                    reporter.remote_line(line);
111                }
112                Err(DeployError::Other(anyhow!(
113                    "rsync exited with status {code}"
114                )))
115            }
116            Err(e) => {
117                reporter.step_fail(&format!("Failed to launch rsync: {e}. Is rsync installed?"));
118                Err(DeployError::Other(anyhow!("Failed to launch rsync: {e}")))
119            }
120        }
121    }
122}