1use std::{
2 ffi::{OsStr, OsString},
3 fs,
4 io::{self, Read, Write},
5 path::Path,
6 process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio},
7 sync::mpsc::{self, Receiver, TryRecvError},
8 thread,
9 time::{Duration, Instant},
10};
11
12#[cfg(any(unix, windows))]
13use std::fs::File;
14#[cfg(windows)]
15use std::path::PathBuf;
16
17use rustferry_remote::{
18 CancellationToken, MAX_WORKER_STDIO_REQUEST_BYTES, MAX_WORKER_STDIO_RESPONSE_BYTES,
19};
20use same_file::Handle as FileIdentityHandle;
21use tempfile::NamedTempFile;
22#[cfg(not(windows))]
23use tempfile::TempDir;
24use thiserror::Error;
25#[cfg(windows)]
26use uuid::Uuid;
27
28use crate::config::{SshConfigError, SshEndpointConfig, SshIdentityFileGuard};
29
30pub const MAX_SSH_REQUEST_BYTES: usize = MAX_WORKER_STDIO_REQUEST_BYTES;
32pub const MAX_SSH_RESPONSE_BYTES: usize = MAX_WORKER_STDIO_RESPONSE_BYTES;
34const MAX_SSH_STDERR_BYTES: usize = 64 * 1024;
35pub const SSH_CONNECT_TIMEOUT_SECONDS: u64 = 15;
37pub const SSH_OPERATION_TIMEOUT: Duration = Duration::from_mins(2);
39pub const SSH_SNAPSHOT_SESSION_TIMEOUT: Duration = Duration::from_hours(2);
41const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(10);
42const PROCESS_REAP_TIMEOUT: Duration = Duration::from_millis(100);
43#[cfg(windows)]
44const WINDOWS_SNAPSHOT_CREATE_ATTEMPTS: usize = 16;
45
46#[derive(Debug)]
47struct PrivateSnapshotDirectory {
48 #[cfg(not(windows))]
49 temporary: TempDir,
50 #[cfg(unix)]
51 _handle: File,
52 #[cfg(windows)]
53 path: PathBuf,
54 #[cfg(windows)]
55 handle: Option<File>,
56 #[cfg(windows)]
57 identity: Option<FileIdentityHandle>,
58}
59
60impl PrivateSnapshotDirectory {
61 fn path(&self) -> &Path {
62 #[cfg(windows)]
63 {
64 &self.path
65 }
66 #[cfg(not(windows))]
67 {
68 self.temporary.path()
69 }
70 }
71
72 #[cfg(windows)]
73 fn revalidate(&self) -> Result<(), SshConfigError> {
74 use std::os::windows::io::AsHandle as _;
75
76 let handle = self
77 .handle
78 .as_ref()
79 .ok_or(SshConfigError::KnownHostsSnapshotFailed)?;
80 rustferry_core::windows_private_directory::verify_private_directory_handle(
81 handle.as_handle(),
82 )
83 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
84 let retained = self
85 .identity
86 .as_ref()
87 .ok_or(SshConfigError::KnownHostsSnapshotFailed)?;
88 let named = FileIdentityHandle::from_path(&self.path)
89 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
90 let metadata = fs::symlink_metadata(&self.path)
91 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
92 if &named != retained || metadata.file_type().is_symlink() || !metadata.is_dir() {
93 return Err(SshConfigError::KnownHostsSnapshotFailed);
94 }
95 Ok(())
96 }
97}
98
99#[cfg(windows)]
100impl Drop for PrivateSnapshotDirectory {
101 fn drop(&mut self) {
102 drop(self.identity.take());
103 if let Some(handle) = self.handle.take() {
104 let _ =
107 rustferry_core::windows_private_directory::remove_private_directory_handle(handle);
108 }
109 }
110}
111
112#[derive(Debug)]
114pub struct SshInvocation {
115 program: OsString,
116 arguments: Vec<OsString>,
117 timeout: Duration,
118 known_hosts_snapshot: NamedTempFile,
119 known_hosts_identity: FileIdentityHandle,
120 snapshot_directory: PrivateSnapshotDirectory,
121 identity_file_guard: Option<SshIdentityFileGuard>,
122}
123
124impl SshInvocation {
125 pub fn program(&self) -> &OsStr {
127 &self.program
128 }
129
130 pub fn arguments(&self) -> &[OsString] {
132 &self.arguments
133 }
134
135 pub const fn timeout(&self) -> Duration {
137 self.timeout
138 }
139
140 pub fn known_hosts_snapshot_path(&self) -> &Path {
142 self.known_hosts_snapshot.path()
143 }
144
145 pub(crate) fn revalidate_identity_file(&self) -> Result<(), SshConfigError> {
146 if let Some(guard) = &self.identity_file_guard {
147 guard.revalidate()?;
148 }
149 Ok(())
150 }
151
152 pub(crate) fn revalidate_trust_snapshot(&self) -> Result<(), SshConfigError> {
153 #[cfg(windows)]
154 self.snapshot_directory.revalidate()?;
155 #[cfg(not(windows))]
156 let _ = self.snapshot_directory.path();
157 let named = FileIdentityHandle::from_path(self.known_hosts_snapshot.path())
158 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
159 let metadata = fs::symlink_metadata(self.known_hosts_snapshot.path())
160 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
161 if named != self.known_hosts_identity
162 || metadata.file_type().is_symlink()
163 || !metadata.is_file()
164 {
165 return Err(SshConfigError::KnownHostsSnapshotFailed);
166 }
167 Ok(())
168 }
169}
170
171pub fn build_ssh_invocation(config: &SshEndpointConfig) -> Result<SshInvocation, SshConfigError> {
178 build_ssh_invocation_for(config, "--stdio", SSH_OPERATION_TIMEOUT)
179}
180
181pub fn build_ssh_session_invocation(
189 config: &SshEndpointConfig,
190) -> Result<SshInvocation, SshConfigError> {
191 build_ssh_invocation_for(config, "--stdio-session-v1", SSH_SNAPSHOT_SESSION_TIMEOUT)
192}
193
194fn build_ssh_invocation_for(
195 config: &SshEndpointConfig,
196 remote_mode: &'static str,
197 timeout: Duration,
198) -> Result<SshInvocation, SshConfigError> {
199 let known_hosts_bytes = config.validated_known_hosts_bytes()?;
200 let identity_file_guard = config.open_identity_file_guard()?;
201 let snapshot_directory = create_private_snapshot_directory()?;
202 let mut known_hosts_snapshot = tempfile::Builder::new()
203 .prefix("known-hosts-")
204 .tempfile_in(snapshot_directory.path())
205 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
206 #[cfg(unix)]
207 {
208 use std::os::unix::fs::PermissionsExt as _;
209 known_hosts_snapshot
210 .as_file()
211 .set_permissions(std::fs::Permissions::from_mode(0o600))
212 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
213 }
214 known_hosts_snapshot
215 .write_all(&known_hosts_bytes)
216 .and_then(|()| known_hosts_snapshot.flush())
217 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
218 let known_hosts_identity = snapshot_file_identity(&known_hosts_snapshot)?;
219 let known_hosts_option = known_hosts_option(known_hosts_snapshot.path())?;
220 let mut arguments = vec![
221 "-F".into(),
222 "none".into(),
223 "-o".into(),
224 "BatchMode=yes".into(),
225 "-o".into(),
226 "StrictHostKeyChecking=yes".into(),
227 "-o".into(),
228 known_hosts_option,
229 "-o".into(),
230 "GlobalKnownHostsFile=none".into(),
231 "-o".into(),
232 "ForwardAgent=no".into(),
233 "-o".into(),
234 "ClearAllForwardings=yes".into(),
235 "-o".into(),
236 "RequestTTY=no".into(),
237 "-o".into(),
238 "PermitLocalCommand=no".into(),
239 "-o".into(),
240 "ConnectionAttempts=1".into(),
241 "-o".into(),
242 OsString::from(format!("ConnectTimeout={SSH_CONNECT_TIMEOUT_SECONDS}")),
243 "-o".into(),
244 "ServerAliveInterval=10".into(),
245 "-o".into(),
246 "ServerAliveCountMax=2".into(),
247 "-o".into(),
248 "LogLevel=ERROR".into(),
249 "-T".into(),
250 ];
251 if let Some(identity_file) = config.identity_file() {
252 arguments.extend([
253 OsString::from("-o"),
254 OsString::from("IdentitiesOnly=yes"),
255 OsString::from("-i"),
256 OsString::from(identity_file.as_str()),
257 ]);
258 }
259 arguments.extend([
260 OsString::from("-p"),
261 OsString::from(config.port().to_string()),
262 OsString::from("-l"),
263 OsString::from(config.user().as_str()),
264 OsString::from(config.host().as_str()),
265 OsString::from("ferry-worker-macos"),
266 OsString::from("serve"),
267 OsString::from(remote_mode),
268 ]);
269 let invocation = SshInvocation {
270 program: "ssh".into(),
271 arguments,
272 timeout,
273 known_hosts_snapshot,
274 known_hosts_identity,
275 snapshot_directory,
276 identity_file_guard,
277 };
278 invocation.revalidate_trust_snapshot()?;
279 Ok(invocation)
280}
281
282fn known_hosts_option(path: &Path) -> Result<OsString, SshConfigError> {
283 validate_openssh_path(path)?;
284 let path = path
285 .to_str()
286 .ok_or(SshConfigError::KnownHostsSnapshotFailed)?;
287 let mut option = String::with_capacity(path.len().saturating_add(24));
288 option.push_str("UserKnownHostsFile=\"");
289 for character in path.chars() {
290 match character {
291 '\\' | '"' => {
292 option.push('\\');
293 option.push(character);
294 }
295 _ => option.push(character),
296 }
297 }
298 option.push('"');
299 Ok(option.into())
300}
301
302fn snapshot_file_identity(snapshot: &NamedTempFile) -> Result<FileIdentityHandle, SshConfigError> {
303 snapshot
304 .as_file()
305 .try_clone()
306 .and_then(FileIdentityHandle::from_file)
307 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)
308}
309
310#[cfg(windows)]
311fn create_private_snapshot_directory() -> Result<PrivateSnapshotDirectory, SshConfigError> {
312 let requested_parent = std::env::temp_dir();
313 validate_openssh_path(&requested_parent)?;
314 let parent =
315 fs::canonicalize(requested_parent).map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
316 validate_openssh_path(&parent)?;
317 for _ in 0..WINDOWS_SNAPSHOT_CREATE_ATTEMPTS {
318 let path = parent.join(format!("rustferry-ssh-{}", Uuid::new_v4().simple()));
319 validate_openssh_path(&path)?;
320 match rustferry_core::windows_private_directory::create_private_directory(&path) {
321 Ok(handle) => {
322 let mut directory = PrivateSnapshotDirectory {
323 path,
324 handle: Some(handle),
325 identity: None,
326 };
327 let retained = directory
328 .handle
329 .as_ref()
330 .expect("new private snapshot handle")
331 .try_clone()
332 .and_then(FileIdentityHandle::from_file)
333 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
334 directory.identity = Some(retained);
335 directory.revalidate()?;
336 return Ok(directory);
337 }
338 Err(error)
339 if error.kind()
340 == rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists =>
341 {
342 }
343 Err(_) => return Err(SshConfigError::KnownHostsSnapshotFailed),
344 }
345 }
346 Err(SshConfigError::KnownHostsSnapshotFailed)
347}
348
349#[cfg(not(windows))]
350fn create_private_snapshot_directory() -> Result<PrivateSnapshotDirectory, SshConfigError> {
351 let requested_parent = std::env::temp_dir();
352 validate_openssh_path(&requested_parent)?;
353 let parent =
354 fs::canonicalize(requested_parent).map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
355 validate_openssh_path(&parent)?;
356 let temporary = tempfile::Builder::new()
357 .prefix("rustferry-ssh-")
358 .tempdir_in(&parent)
359 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
360 #[cfg(unix)]
361 fs::set_permissions(
362 temporary.path(),
363 <fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o700),
364 )
365 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
366 let canonical_directory =
367 fs::canonicalize(temporary.path()).map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
368 if canonical_directory != temporary.path() {
369 return Err(SshConfigError::KnownHostsSnapshotFailed);
370 }
371 validate_openssh_path(&canonical_directory)?;
372 let path_metadata = fs::symlink_metadata(&canonical_directory)
373 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
374 if path_metadata.file_type().is_symlink() || !path_metadata.file_type().is_dir() {
375 return Err(SshConfigError::KnownHostsSnapshotFailed);
376 }
377
378 #[cfg(unix)]
379 {
380 use std::os::unix::fs::MetadataExt as _;
381
382 let handle = File::open(&canonical_directory)
383 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
384 let opened_metadata = handle
385 .metadata()
386 .map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
387 if opened_metadata.dev() != path_metadata.dev()
388 || opened_metadata.ino() != path_metadata.ino()
389 || opened_metadata.mode() & 0o077 != 0
390 {
391 return Err(SshConfigError::KnownHostsSnapshotFailed);
392 }
393 validate_unix_temp_ancestors(&canonical_directory, opened_metadata.uid())?;
394 Ok(PrivateSnapshotDirectory {
395 temporary,
396 _handle: handle,
397 })
398 }
399 #[cfg(not(unix))]
400 {
401 Ok(PrivateSnapshotDirectory { temporary })
402 }
403}
404
405fn validate_openssh_path(path: &Path) -> Result<(), SshConfigError> {
406 if !path.is_absolute() {
407 return Err(SshConfigError::KnownHostsSnapshotFailed);
408 }
409 let path = path
410 .to_str()
411 .ok_or(SshConfigError::KnownHostsSnapshotFailed)?;
412 if path
413 .chars()
414 .any(|character| character.is_control() || matches!(character, '%' | '$'))
415 {
416 return Err(SshConfigError::KnownHostsSnapshotFailed);
417 }
418 Ok(())
419}
420
421#[cfg(unix)]
422fn validate_unix_temp_ancestors(directory: &Path, current_uid: u32) -> Result<(), SshConfigError> {
423 use std::os::unix::fs::MetadataExt as _;
424
425 for ancestor in directory.ancestors().skip(1) {
426 let metadata =
427 fs::symlink_metadata(ancestor).map_err(|_| SshConfigError::KnownHostsSnapshotFailed)?;
428 let mode = metadata.mode();
429 let owned_by_trusted_principal = metadata.uid() == current_uid || metadata.uid() == 0;
430 let writable_by_another_principal = mode & 0o022 != 0;
431 #[allow(clippy::useless_conversion)]
432 let sticky = mode & u32::from(libc::S_ISVTX) != 0;
433 if metadata.file_type().is_symlink()
434 || !metadata.file_type().is_dir()
435 || !owned_by_trusted_principal
436 || (writable_by_another_principal && !sticky)
437 {
438 return Err(SshConfigError::KnownHostsSnapshotFailed);
439 }
440 }
441 Ok(())
442}
443
444#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
446pub enum SshTransportError {
447 #[error("SSH worker operation was cancelled")]
449 Cancelled,
450 #[error("SSH worker request is {bytes} bytes; maximum is {maximum} bytes")]
452 RequestTooLarge {
453 bytes: usize,
455 maximum: usize,
457 },
458 #[error("OpenSSH client could not be started")]
460 SpawnFailed,
461 #[error("OpenSSH protocol pipe failed")]
463 IoFailed,
464 #[error("SSH worker operation timed out")]
466 TimedOut,
467 #[error("OpenSSH client exited unsuccessfully with status {status:?}")]
469 ProcessFailed {
470 status: Option<i32>,
472 },
473 #[error("SSH identity file changed before process start")]
475 IdentityFileChanged,
476 #[error("SSH host-key trust snapshot changed before process start")]
478 TrustSnapshotChanged,
479 #[error("SSH worker response exceeded the {maximum}-byte limit")]
481 ResponseTooLarge {
482 maximum: usize,
484 },
485}
486
487pub trait SshRunner: Send + Sync {
489 fn exchange(
495 &self,
496 invocation: &SshInvocation,
497 request: &[u8],
498 cancellation: &CancellationToken,
499 ) -> Result<Vec<u8>, SshTransportError>;
500}
501
502#[derive(Clone, Copy, Debug, Default)]
504pub struct ProcessSshRunner;
505
506impl SshRunner for ProcessSshRunner {
507 fn exchange(
508 &self,
509 invocation: &SshInvocation,
510 request: &[u8],
511 cancellation: &CancellationToken,
512 ) -> Result<Vec<u8>, SshTransportError> {
513 let deadline = Instant::now() + invocation.timeout();
514 if cancellation.is_cancelled() {
515 return Err(SshTransportError::Cancelled);
516 }
517 if request.len() > MAX_SSH_REQUEST_BYTES {
518 return Err(SshTransportError::RequestTooLarge {
519 bytes: request.len(),
520 maximum: MAX_SSH_REQUEST_BYTES,
521 });
522 }
523 invocation
524 .revalidate_identity_file()
525 .map_err(|_| SshTransportError::IdentityFileChanged)?;
526 invocation
527 .revalidate_trust_snapshot()
528 .map_err(|_| SshTransportError::TrustSnapshotChanged)?;
529
530 let mut child = Command::new(invocation.program())
531 .args(invocation.arguments())
532 .stdin(Stdio::piped())
533 .stdout(Stdio::piped())
534 .stderr(Stdio::piped())
535 .spawn()
536 .map_err(|_| SshTransportError::SpawnFailed)?;
537 let Some((stdin, stdout, stderr)) = take_child_pipes(&mut child) else {
538 terminate_child_bounded(child, deadline);
539 return Err(SshTransportError::IoFailed);
540 };
541 let request = request.to_vec();
542 let Ok(pipe_events) = spawn_pipe_tasks(stdin, stdout, stderr, request) else {
543 terminate_child_bounded(child, deadline);
544 return Err(SshTransportError::IoFailed);
545 };
546 let mut write_result = None;
547 let mut stdout_result = None;
548 let mut stderr_result = None;
549 let mut status = None;
550
551 loop {
552 if cancellation.is_cancelled() {
553 terminate_child_bounded(child, deadline);
554 return Err(SshTransportError::Cancelled);
555 }
556 if Instant::now() >= deadline {
557 terminate_child_bounded(child, deadline);
558 return Err(SshTransportError::TimedOut);
559 }
560
561 if status.is_none() {
562 match child.try_wait() {
563 Ok(Some(exit_status)) => status = Some(exit_status),
564 Ok(None) => {}
565 Err(_) => {
566 terminate_child_bounded(child, deadline);
567 return Err(SshTransportError::IoFailed);
568 }
569 }
570 }
571
572 if drain_pipe_events(
573 &pipe_events,
574 &mut write_result,
575 &mut stdout_result,
576 &mut stderr_result,
577 )
578 .is_err()
579 {
580 terminate_child_bounded(child, deadline);
581 return Err(SshTransportError::IoFailed);
582 }
583
584 if let Some(exit_status) = status {
585 if !exit_status.success() {
586 return Err(SshTransportError::ProcessFailed {
587 status: exit_status.code(),
588 });
589 }
590 if write_result.is_some() && stdout_result.is_some() && stderr_result.is_some() {
591 if Instant::now() >= deadline {
592 return Err(SshTransportError::TimedOut);
593 }
594 let write_result = write_result
595 .take()
596 .expect("completed writer result remains available");
597 let stdout = stdout_result
598 .take()
599 .expect("completed stdout result remains available");
600 let stderr = stderr_result
601 .take()
602 .expect("completed stderr result remains available");
603 write_result.map_err(|_| SshTransportError::IoFailed)?;
604 let stdout = stdout.map_err(|_| SshTransportError::IoFailed)?;
605 stderr.map_err(|_| SshTransportError::IoFailed)?;
606 if stdout.exceeded {
607 return Err(SshTransportError::ResponseTooLarge {
608 maximum: MAX_SSH_RESPONSE_BYTES,
609 });
610 }
611 return Ok(stdout.bytes);
612 }
613 }
614
615 let remaining = deadline.saturating_duration_since(Instant::now());
616 thread::sleep(PROCESS_POLL_INTERVAL.min(remaining));
617 }
618 }
619}
620
621fn take_child_pipes(child: &mut Child) -> Option<(ChildStdin, ChildStdout, ChildStderr)> {
622 Some((
623 child.stdin.take()?,
624 child.stdout.take()?,
625 child.stderr.take()?,
626 ))
627}
628
629enum PipeEvent {
630 Write(io::Result<()>),
631 Stdout(io::Result<BoundedRead>),
632 Stderr(io::Result<BoundedRead>),
633}
634
635fn spawn_pipe_tasks(
636 mut stdin: impl Write + Send + 'static,
637 stdout: impl Read + Send + 'static,
638 stderr: impl Read + Send + 'static,
639 request: Vec<u8>,
640) -> io::Result<Receiver<PipeEvent>> {
641 let (sender, receiver) = mpsc::channel();
642 let write_sender = sender.clone();
643 drop(
644 thread::Builder::new()
645 .name("rustferry-ssh-stdin".to_owned())
646 .spawn(move || {
647 let result = stdin.write_all(&request).and_then(|()| stdin.flush());
648 let _ = write_sender.send(PipeEvent::Write(result));
649 })?,
650 );
651 let stdout_sender = sender.clone();
652 drop(
653 thread::Builder::new()
654 .name("rustferry-ssh-stdout".to_owned())
655 .spawn(move || {
656 let _ = stdout_sender.send(PipeEvent::Stdout(read_bounded(
657 stdout,
658 MAX_SSH_RESPONSE_BYTES,
659 )));
660 })?,
661 );
662 drop(
663 thread::Builder::new()
664 .name("rustferry-ssh-stderr".to_owned())
665 .spawn(move || {
666 let _ = sender.send(PipeEvent::Stderr(read_bounded(
667 stderr,
668 MAX_SSH_STDERR_BYTES,
669 )));
670 })?,
671 );
672 Ok(receiver)
673}
674
675fn drain_pipe_events(
676 receiver: &Receiver<PipeEvent>,
677 write_result: &mut Option<io::Result<()>>,
678 stdout_result: &mut Option<io::Result<BoundedRead>>,
679 stderr_result: &mut Option<io::Result<BoundedRead>>,
680) -> Result<(), ()> {
681 loop {
682 match receiver.try_recv() {
683 Ok(PipeEvent::Write(result)) => *write_result = Some(result),
684 Ok(PipeEvent::Stdout(result)) => *stdout_result = Some(result),
685 Ok(PipeEvent::Stderr(result)) => *stderr_result = Some(result),
686 Err(TryRecvError::Empty) => return Ok(()),
687 Err(TryRecvError::Disconnected) => {
688 return if write_result.is_some()
689 && stdout_result.is_some()
690 && stderr_result.is_some()
691 {
692 Ok(())
693 } else {
694 Err(())
695 };
696 }
697 }
698 }
699}
700
701pub(crate) fn terminate_child_bounded(mut child: Child, operation_deadline: Instant) {
702 let _ = child.kill();
703 let cleanup_deadline = operation_deadline.min(Instant::now() + PROCESS_REAP_TIMEOUT);
704 loop {
705 match child.try_wait() {
706 Ok(Some(_)) | Err(_) => return,
707 Ok(None) => {}
708 }
709 let remaining = cleanup_deadline.saturating_duration_since(Instant::now());
710 if remaining.is_zero() {
711 spawn_child_reaper(child);
712 return;
713 }
714 thread::sleep(PROCESS_POLL_INTERVAL.min(remaining));
715 }
716}
717
718fn spawn_child_reaper(mut child: Child) {
719 let _ = thread::Builder::new()
720 .name("rustferry-ssh-reaper".to_owned())
721 .spawn(move || {
722 let _ = child.wait();
723 });
724}
725
726struct BoundedRead {
727 bytes: Vec<u8>,
728 exceeded: bool,
729}
730
731fn read_bounded(mut reader: impl Read, maximum: usize) -> io::Result<BoundedRead> {
732 let mut bytes = Vec::with_capacity(maximum.min(64 * 1024));
733 let mut exceeded = false;
734 let mut buffer = [0_u8; 16 * 1024];
735 loop {
736 let count = reader.read(&mut buffer)?;
737 if count == 0 {
738 break;
739 }
740 let remaining = maximum.saturating_add(1).saturating_sub(bytes.len());
741 let retained = count.min(remaining);
742 bytes.extend_from_slice(&buffer[..retained]);
743 exceeded |= count > retained || bytes.len() > maximum;
744 }
745 Ok(BoundedRead { bytes, exceeded })
746}
747
748#[cfg(all(test, unix))]
749pub(crate) fn test_invocation(
750 program: impl Into<OsString>,
751 arguments: impl IntoIterator<Item = OsString>,
752 timeout: Duration,
753) -> SshInvocation {
754 let snapshot_directory =
755 create_private_snapshot_directory().expect("private snapshot directory");
756 let known_hosts_snapshot =
757 NamedTempFile::new_in(snapshot_directory.path()).expect("snapshot placeholder");
758 let known_hosts_identity =
759 snapshot_file_identity(&known_hosts_snapshot).expect("snapshot identity");
760 SshInvocation {
761 program: program.into(),
762 arguments: arguments.into_iter().collect(),
763 timeout,
764 known_hosts_snapshot,
765 known_hosts_identity,
766 snapshot_directory,
767 identity_file_guard: None,
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 #[cfg(unix)]
777 fn known_hosts_option_quotes_lists_and_escapes_tokens() {
778 let option =
779 known_hosts_option(Path::new("/tmp/rust ferry/\"key\"")).expect("quoted OpenSSH path");
780 assert_eq!(
781 option,
782 OsString::from("UserKnownHostsFile=\"/tmp/rust ferry/\\\"key\\\"\"")
783 );
784 }
785
786 #[test]
787 #[cfg(windows)]
788 fn known_hosts_option_quotes_native_windows_paths() {
789 let option =
790 known_hosts_option(Path::new(r"C:\tmp\rust ferry\key")).expect("quoted OpenSSH path");
791 assert_eq!(
792 option,
793 OsString::from(r#"UserKnownHostsFile="C:\\tmp\\rust ferry\\key""#)
794 );
795 }
796
797 #[test]
798 fn known_hosts_option_rejects_openssh_expansions() {
799 #[cfg(windows)]
800 let paths = [r"C:\tmp\%h\known_hosts", r"C:\tmp\${HOME}\known_hosts"];
801 #[cfg(not(windows))]
802 let paths = ["/tmp/%h/known_hosts", "/tmp/${HOME}/known_hosts"];
803 for path in paths {
804 assert_eq!(
805 known_hosts_option(Path::new(path)),
806 Err(SshConfigError::KnownHostsSnapshotFailed)
807 );
808 }
809 }
810
811 #[test]
812 fn process_deadline_does_not_join_descendant_held_pipes() {
813 let invocation = subprocess_invocation(
814 "transport::tests::subprocess_exits_while_descendant_holds_pipes",
815 Duration::from_millis(150),
816 );
817 let started = Instant::now();
818 assert_eq!(
819 ProcessSshRunner.exchange(&invocation, b"{}\n", &CancellationToken::new()),
820 Err(SshTransportError::TimedOut)
821 );
822 assert!(started.elapsed() < Duration::from_secs(1));
823 }
824
825 #[test]
826 fn process_cancellation_has_bounded_reap_and_no_thread_joins() {
827 let invocation = subprocess_invocation(
828 "transport::tests::subprocess_pipe_holder",
829 Duration::from_secs(5),
830 );
831 let cancellation = CancellationToken::new();
832 let cancellation_request = cancellation.clone();
833 thread::spawn(move || {
834 thread::sleep(Duration::from_millis(50));
835 let _ = cancellation_request.cancel();
836 });
837 let started = Instant::now();
838 assert_eq!(
839 ProcessSshRunner.exchange(&invocation, b"{}\n", &cancellation),
840 Err(SshTransportError::Cancelled)
841 );
842 assert!(started.elapsed() < Duration::from_secs(1));
843 }
844
845 fn subprocess_invocation(test_name: &str, timeout: Duration) -> SshInvocation {
846 let snapshot_directory =
847 create_private_snapshot_directory().expect("private snapshot directory");
848 let known_hosts_snapshot =
849 NamedTempFile::new_in(snapshot_directory.path()).expect("snapshot placeholder");
850 let known_hosts_identity =
851 snapshot_file_identity(&known_hosts_snapshot).expect("snapshot identity");
852 SshInvocation {
853 program: std::env::current_exe()
854 .expect("current test executable")
855 .into_os_string(),
856 arguments: ["--exact", test_name, "--ignored", "--nocapture"]
857 .into_iter()
858 .map(OsString::from)
859 .collect(),
860 timeout,
861 known_hosts_snapshot,
862 known_hosts_identity,
863 snapshot_directory,
864 identity_file_guard: None,
865 }
866 }
867
868 #[test]
869 #[ignore = "subprocess helper"]
870 #[allow(clippy::zombie_processes)]
871 fn subprocess_exits_while_descendant_holds_pipes() {
872 Command::new(std::env::current_exe().expect("current test executable"))
873 .args([
874 "--exact",
875 "transport::tests::subprocess_pipe_holder",
876 "--ignored",
877 "--nocapture",
878 ])
879 .spawn()
880 .expect("pipe-holding descendant");
881 }
882
883 #[test]
884 #[ignore = "subprocess helper"]
885 fn subprocess_pipe_holder() {
886 thread::sleep(Duration::from_secs(2));
887 }
888}