1use crate::client_trust::{effective_trust_home, gate_pray_ssh_host, gate_pray_ssh_publisher};
2use crate::ssh_identity::active_ssh_user_fingerprint;
3use crate::ssh_rpc::{call_stdio, RpcRequest, RpcResponse, SSH_RPC_SPEC};
4use crate::{PrayError, PrayResult};
5use serde_json::Value;
6use std::io::{BufReader, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PraySshTarget {
15 pub user: Option<String>,
16 pub host: String,
17 pub port: u16,
18 pub root: Option<PathBuf>,
19}
20
21pub fn is_pray_ssh_url(url: &str) -> bool {
22 url.starts_with("pray+ssh://") || url.starts_with("ssh+pray://")
23}
24
25pub fn parse_pray_ssh_url(url: &str) -> PrayResult<PraySshTarget> {
26 let remainder = url
27 .strip_prefix("pray+ssh://")
28 .or_else(|| url.strip_prefix("ssh+pray://"))
29 .ok_or_else(|| PrayError::Parse {
30 kind: "pray ssh url",
31 message: format!("expected pray+ssh:// url, got {url}"),
32 })?;
33
34 let (authority, root) = match remainder.split_once('/') {
35 Some((authority, path)) if !path.is_empty() => {
36 (authority, Some(PathBuf::from(format!("/{path}"))))
37 }
38 _ => (remainder, None),
39 };
40
41 let (credentials, host_port) = match authority.rsplit_once('@') {
42 Some((user, host_port)) => (Some(user.to_string()), host_port),
43 None => (None, authority),
44 };
45
46 let (host, port) = match host_port.rsplit_once(':') {
47 Some((host, port_text)) if !host.contains(']') => {
48 let port = port_text.parse::<u16>().map_err(|error| PrayError::Parse {
49 kind: "pray ssh url",
50 message: format!("invalid port in {url}: {error}"),
51 })?;
52 (host.to_string(), port)
53 }
54 _ => (host_port.to_string(), 22),
55 };
56
57 if host.is_empty() {
58 return Err(PrayError::Parse {
59 kind: "pray ssh url",
60 message: format!("missing host in {url}"),
61 });
62 }
63
64 Ok(PraySshTarget {
65 user: credentials,
66 host,
67 port,
68 root,
69 })
70}
71
72pub struct SshRpcSession {
73 child: Child,
74 stdin: Option<ChildStdin>,
75 reader: BufReader<ChildStdout>,
76}
77
78impl SshRpcSession {
79 pub fn connect(target: &PraySshTarget) -> PrayResult<Self> {
80 if target.host == "stdio-host" {
81 if let Ok(root) = std::env::var("PRAY_TEST_SSH_STDIO_ROOT") {
82 let mut command = Command::new(pray_program());
83 command.arg("serve").arg("--stdio").arg("--root").arg(root);
84 return Self::connect_stdio(command);
85 }
86 }
87
88 let mut command = Command::new(ssh_program());
89 command
90 .arg("-p")
91 .arg(target.port.to_string())
92 .arg("-o")
93 .arg("BatchMode=yes")
94 .arg("-o")
95 .arg("StrictHostKeyChecking=accept-new");
96 if let Some(user) = &target.user {
97 command.arg(format!("{user}@{}", target.host));
98 } else {
99 command.arg(&target.host);
100 }
101 let mut remote_command = String::from("pray serve --stdio");
102 if let Some(root) = &target.root {
103 remote_command.push_str(" --root ");
104 remote_command.push_str(&shell_escape(root.to_string_lossy().as_ref()));
105 }
106 command.arg(remote_command);
107 command.stdin(Stdio::piped());
108 command.stdout(Stdio::piped());
109 command.stderr(Stdio::piped());
110 let mut child = command.spawn().map_err(|error| {
111 PrayError::Unsupported(format!("failed to start ssh for pray rpc: {error}"))
112 })?;
113 let stdin = child
114 .stdin
115 .take()
116 .ok_or_else(|| PrayError::Unsupported("rpc stdin unavailable".to_string()))?;
117 let stdout = child
118 .stdout
119 .take()
120 .ok_or_else(|| PrayError::Unsupported("rpc stdout unavailable".to_string()))?;
121 Ok(Self {
122 child,
123 stdin: Some(stdin),
124 reader: BufReader::new(stdout),
125 })
126 }
127
128 pub fn connect_stdio(mut command: Command) -> PrayResult<Self> {
129 command.stdin(Stdio::piped());
130 command.stdout(Stdio::piped());
131 command.stderr(Stdio::piped());
132 let mut child = command.spawn().map_err(|error| {
133 PrayError::Unsupported(format!("failed to start stdio rpc: {error}"))
134 })?;
135 let stdin = child
136 .stdin
137 .take()
138 .ok_or_else(|| PrayError::Unsupported("rpc stdin unavailable".to_string()))?;
139 let stdout = child
140 .stdout
141 .take()
142 .ok_or_else(|| PrayError::Unsupported("rpc stdout unavailable".to_string()))?;
143 Ok(Self {
144 child,
145 stdin: Some(stdin),
146 reader: BufReader::new(stdout),
147 })
148 }
149
150 pub fn call(&mut self, method: &str, params: Value) -> PrayResult<RpcResponse> {
151 let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed).to_string();
152 let request = RpcRequest::new(request_id, method, params);
153 let stdin = self
154 .stdin
155 .as_mut()
156 .ok_or_else(|| PrayError::Unsupported("rpc stdin unavailable".to_string()))?;
157 let response = call_stdio(&mut self.reader, stdin, &request)?;
158 if response.spec != SSH_RPC_SPEC {
159 return Err(PrayError::Resolution(format!(
160 "unexpected rpc spec in response: {}",
161 response.spec
162 )));
163 }
164 Ok(response)
165 }
166
167 pub fn call_json(&mut self, method: &str, params: Value) -> PrayResult<Value> {
168 let response = self.call(method, params)?;
169 if response.status / 100 != 2 {
170 let message = response
171 .body
172 .get("error")
173 .and_then(Value::as_str)
174 .unwrap_or("rpc request failed");
175 return Err(PrayError::Resolution(format!(
176 "rpc {method} failed with status {}: {message}",
177 response.status
178 )));
179 }
180 Ok(response.body)
181 }
182
183 pub fn call_bytes(&mut self, method: &str, params: Value) -> PrayResult<Vec<u8>> {
184 let response = self.call(method, params)?;
185 if response.status / 100 != 2 {
186 let message = response
187 .body
188 .get("error")
189 .and_then(Value::as_str)
190 .unwrap_or("rpc request failed");
191 return Err(PrayError::Resolution(format!(
192 "rpc {method} failed with status {}: {message}",
193 response.status
194 )));
195 }
196 response.decode_body_bytes()
197 }
198}
199
200impl Drop for SshRpcSession {
201 fn drop(&mut self) {
202 if let Some(mut stdin) = self.stdin.take() {
203 let _ = stdin.flush();
204 }
205 let _ = self.child.wait();
206 }
207}
208
209pub fn with_pray_ssh_session<T>(
210 source_url: &str,
211 operation: impl FnOnce(&mut SshRpcSession) -> PrayResult<T>,
212) -> PrayResult<T> {
213 let target = parse_pray_ssh_url(source_url)?;
214 let home = effective_trust_home()?;
215 let _host_key = gate_pray_ssh_host(&home, source_url, &target.host, target.port)?;
216 if let Some(publisher) = active_ssh_user_fingerprint() {
217 gate_pray_ssh_publisher(&home, source_url, &publisher)?;
218 }
219 let mut session = SshRpcSession::connect(&target)?;
220 operation(&mut session)
221}
222
223pub fn ssh_program() -> String {
224 [
225 "/usr/bin/ssh",
226 "/opt/homebrew/bin/ssh",
227 "/usr/local/bin/ssh",
228 "ssh",
229 ]
230 .into_iter()
231 .find(|candidate| *candidate == "ssh" || Path::new(candidate).exists())
232 .unwrap_or("ssh")
233 .to_string()
234}
235
236fn pray_program() -> String {
237 if let Ok(path) = std::env::var("PRAY_TEST_BINARY") {
238 return path;
239 }
240 std::env::current_exe()
241 .ok()
242 .and_then(|path| path.to_str().map(str::to_string))
243 .unwrap_or_else(|| "pray".to_string())
244}
245
246fn shell_escape(value: &str) -> String {
247 if value.chars().all(|character| {
248 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '/')
249 }) {
250 value.to_string()
251 } else {
252 format!("'{}'", value.replace('\'', "'\"'\"'"))
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn parse_pray_ssh_url_reads_user_host_port_and_root() {
262 let target = parse_pray_ssh_url("pray+ssh://pray@prayers.internal:2222/var/lib/pray")
263 .expect("parse url");
264 assert_eq!(target.user.as_deref(), Some("pray"));
265 assert_eq!(target.host, "prayers.internal");
266 assert_eq!(target.port, 2222);
267 assert_eq!(target.root, Some(PathBuf::from("/var/lib/pray")));
268 }
269}