Skip to main content

rustferry_ssh/
transport.rs

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
30/// Maximum JSON request accepted by the process transport.
31pub const MAX_SSH_REQUEST_BYTES: usize = MAX_WORKER_STDIO_REQUEST_BYTES;
32/// Maximum JSON response accepted from a worker.
33pub const MAX_SSH_RESPONSE_BYTES: usize = MAX_WORKER_STDIO_RESPONSE_BYTES;
34const MAX_SSH_STDERR_BYTES: usize = 64 * 1024;
35/// Fixed OpenSSH TCP connection timeout.
36pub const SSH_CONNECT_TIMEOUT_SECONDS: u64 = 15;
37/// Fixed deadline for one protocol exchange, including worker diagnostics.
38pub const SSH_OPERATION_TIMEOUT: Duration = Duration::from_mins(2);
39/// Fixed finite deadline for one complete Rust/Xcode snapshot build session.
40pub 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            // The trust file's own temporary-path cleanup has an unavoidable same-user race. The
105            // directory itself is removed only through this exact retained handle, never by path.
106            let _ =
107                rustferry_core::windows_private_directory::remove_private_directory_handle(handle);
108        }
109    }
110}
111
112/// Fixed OpenSSH process specification for one worker exchange.
113#[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    /// Fixed executable name resolved by the operating system.
126    pub fn program(&self) -> &OsStr {
127        &self.program
128    }
129
130    /// Exact argument array. No element is interpreted by a local shell.
131    pub fn arguments(&self) -> &[OsString] {
132        &self.arguments
133    }
134
135    /// Total deadline for this exchange.
136    pub const fn timeout(&self) -> Duration {
137        self.timeout
138    }
139
140    /// Private operation-owned trust snapshot retained for this invocation.
141    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
171/// Build the only OpenSSH invocation accepted by this crate.
172///
173/// # Errors
174///
175/// Revalidates the identity path and publishes the pinned host entry into a
176/// private operation-owned snapshot retained by the returned invocation.
177pub fn build_ssh_invocation(config: &SshEndpointConfig) -> Result<SshInvocation, SshConfigError> {
178    build_ssh_invocation_for(config, "--stdio", SSH_OPERATION_TIMEOUT)
179}
180
181/// Build the fixed OpenSSH invocation for one framed snapshot session.
182///
183/// # Errors
184///
185/// Applies the same pinned-host and retained-identity checks as
186/// [`build_ssh_invocation`], with the sole remote mode
187/// `ferry-worker-macos serve --stdio-session-v1`.
188pub 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/// Stable, secret-free OpenSSH transport failure.
445#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
446pub enum SshTransportError {
447    /// The client cancelled the operation.
448    #[error("SSH worker operation was cancelled")]
449    Cancelled,
450    /// The request exceeded the local protocol bound.
451    #[error("SSH worker request is {bytes} bytes; maximum is {maximum} bytes")]
452    RequestTooLarge {
453        /// Observed request size.
454        bytes: usize,
455        /// Accepted maximum.
456        maximum: usize,
457    },
458    /// The OpenSSH client could not be started.
459    #[error("OpenSSH client could not be started")]
460    SpawnFailed,
461    /// A local pipe failed without exposing worker output.
462    #[error("OpenSSH protocol pipe failed")]
463    IoFailed,
464    /// The fixed operation deadline elapsed.
465    #[error("SSH worker operation timed out")]
466    TimedOut,
467    /// OpenSSH exited unsuccessfully. Stderr remains private.
468    #[error("OpenSSH client exited unsuccessfully with status {status:?}")]
469    ProcessFailed {
470        /// Numeric exit status when the operating system supplied one.
471        status: Option<i32>,
472    },
473    /// The private-key path no longer matched its retained file handle.
474    #[error("SSH identity file changed before process start")]
475    IdentityFileChanged,
476    /// The private host-key snapshot or its retained directory failed revalidation.
477    #[error("SSH host-key trust snapshot changed before process start")]
478    TrustSnapshotChanged,
479    /// Worker stdout exceeded its public protocol limit.
480    #[error("SSH worker response exceeded the {maximum}-byte limit")]
481    ResponseTooLarge {
482        /// Accepted maximum.
483        maximum: usize,
484    },
485}
486
487/// Runtime-neutral boundary around a complete request/response exchange.
488pub trait SshRunner: Send + Sync {
489    /// Send one bounded JSON request and return one bounded JSON response.
490    ///
491    /// # Errors
492    ///
493    /// Returns a stable transport error without exposing remote stderr.
494    fn exchange(
495        &self,
496        invocation: &SshInvocation,
497        request: &[u8],
498        cancellation: &CancellationToken,
499    ) -> Result<Vec<u8>, SshTransportError>;
500}
501
502/// OpenSSH-backed runner using argument arrays and piped standard I/O.
503#[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}