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
8 use crate::{error::DeployError, known_hosts, report::Reporter};
9 use anyhow::anyhow;
10 use std::{
11 io::Write,
12 path::{Path, PathBuf},
13 process::Command,
14 };
15 use tempfile::NamedTempFile;
16
17 /// Ships a source tree to its deploy target.
18 pub 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.
28 pub struct RsyncTransport {
29 host: String,
30 remote_path: String,
31 identity_file: PathBuf,
32 }
33
34 impl 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
44 impl 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 }