Skip to main content

ssh_mcp/ssh/
command.rs

1//! Command execution over SSH
2//!
3//! Provides the `CommandOutput` struct and `exec_command` functionality
4//! for executing commands over an SSH connection with timeout support.
5//!
6//! This module is designed to be compatible with both GNU and BusyBox-based
7//! systems (e.g., Debian/Ubuntu and Alpine Linux). All command detection and
8//! process monitoring uses portable mechanisms that work across distributions.
9
10use std::path::Path;
11use std::sync::Arc;
12use std::time::Duration;
13
14use russh::ChannelMsg;
15use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt};
16use tokio::sync::Mutex;
17use tokio::time::timeout;
18use tracing::{debug, error, warn};
19
20use super::config::TIMEOUT_KILL_AFTER_SECS;
21use super::connection::SshConnectionManager;
22use super::sanitize::{escape_command_for_shell, escape_for_timeout_wrapper, wrap_in_posix_shell};
23use crate::background::{JobRegistry, JobStatus, LocalLogSpooler, SharedJobState};
24use crate::error::{Result, SshMcpError};
25#[cfg(unix)]
26use crate::platform::O_NOFOLLOW_FLAG;
27
28const RAW_STREAM_BYTES_PER_TOKEN: usize = 4;
29const RAW_STREAM_STDERR_HARD_MAX_BYTES: usize = 1024 * 1024;
30
31/// Output from a command execution
32#[derive(Debug, Clone, Default)]
33pub struct CommandOutput {
34    /// Standard output from the command
35    pub stdout: String,
36
37    /// Standard error from the command
38    pub stderr: String,
39
40    /// Exit code of the command (if available)
41    pub exit_code: Option<u32>,
42
43    /// Whether stdout was truncated due to output limits
44    pub stdout_truncated: bool,
45
46    /// Whether stderr was truncated due to output limits
47    pub stderr_truncated: bool,
48
49    /// Approximate total token count for stdout (including truncated content)
50    pub stdout_total_tokens: usize,
51
52    /// Approximate total token count for stderr (including truncated content)
53    pub stderr_total_tokens: usize,
54}
55
56/// Output from a raw streaming command execution.
57///
58/// This is intended for binary-safe stdin/stdout streaming (e.g. file transfer).
59#[derive(Debug, Clone, Default)]
60pub struct TransferRawOutput {
61    /// Total bytes written to remote stdout (as received).
62    pub stdout_bytes: u64,
63
64    /// Total bytes written to remote stdin.
65    pub stdin_bytes: u64,
66
67    /// Collected stderr (lossy UTF-8).
68    pub stderr: String,
69
70    /// Exit code of the remote command (if provided).
71    pub exit_code: Option<u32>,
72}
73
74/// Process status check result
75#[derive(Debug, Clone)]
76pub struct ProcessStatus {
77    /// PID of the background process on the remote host.
78    pub pid: u32,
79    /// Strict job state label: running, completed, failed, or state_lost.
80    pub state: String,
81    /// Whether the process is currently running
82    pub running: bool,
83    /// Exit code if process has completed
84    pub exit_code: Option<u32>,
85    /// Why the job entered state_lost, if known.
86    pub state_reason: Option<String>,
87    /// Elapsed time in ps format (e.g., "12:34" or "2-12:34:56")
88    pub elapsed_time: String,
89    /// Original command string tracked for this job.
90    pub command: String,
91    /// Absolute local log path on the MCP server.
92    pub log_path: String,
93    /// Whether the local log file currently exists.
94    pub log_exists: bool,
95    /// Tail of the log file (if log_path provided)
96    pub log_tail: String,
97}
98
99impl CommandOutput {
100    /// Create a new empty CommandOutput
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Check if the command succeeded (exit code 0 or no exit code available)
106    pub fn success(&self) -> bool {
107        self.exit_code.is_some_and(|code| code == 0)
108    }
109
110    /// Get combined output (stdout + stderr)
111    pub fn combined_output(&self) -> String {
112        if self.stderr.is_empty() {
113            self.stdout.clone()
114        } else if self.stdout.is_empty() {
115            self.stderr.clone()
116        } else {
117            format!("{}\n{}", self.stdout, self.stderr)
118        }
119    }
120}
121
122/// Wrap a command with the timeout utility
123///
124/// Creates a wrapper command: `timeout -k {kill_after}s {duration}s sh -lc '{command}'`
125///
126/// The use of `sh -lc` ensures a login shell is used, which properly loads
127/// environment variables like PATH from ~/.profile or /etc/profile.
128///
129/// # Arguments
130/// * `command` - The command to wrap (should be pre-escaped)
131/// * `duration_secs` - Timeout duration in seconds (supports fractional seconds like 0.5)
132///
133/// # Returns
134/// A wrapped command string that includes timeout logic
135pub fn wrap_command_with_timeout(command: &str, duration_secs: f64) -> String {
136    let escaped_command = escape_for_timeout_wrapper(command);
137    format!(
138        "timeout -k {}s {}s sh -lc '{}'",
139        TIMEOUT_KILL_AFTER_SECS, duration_secs, escaped_command
140    )
141}
142
143fn wrap_command_for_channel_exec(command: &str) -> String {
144    wrap_in_posix_shell(command, false)
145}
146
147fn validate_timeout_duration(timeout_duration: Duration) -> Result<f64> {
148    // Convert duration to fractional seconds for millisecond precision.
149    // as_secs_f64() preserves sub-second precision (e.g., 500ms -> 0.5, 1500ms -> 1.5).
150    let duration_secs = timeout_duration.as_secs_f64();
151    if !duration_secs.is_finite() || duration_secs <= 0.0 {
152        return Err(SshMcpError::InvalidParams(
153            "duration must be finite and > 0".to_string(),
154        ));
155    }
156    Ok(duration_secs)
157}
158
159fn resolve_raw_stream_stderr_limit(max_output_tokens: Option<usize>) -> usize {
160    max_output_tokens
161        .and_then(|tokens| tokens.checked_mul(RAW_STREAM_BYTES_PER_TOKEN))
162        .filter(|bytes| *bytes > 0)
163        .unwrap_or(RAW_STREAM_STDERR_HARD_MAX_BYTES)
164        .min(RAW_STREAM_STDERR_HARD_MAX_BYTES)
165}
166
167fn utf8_prefix_len(input: &str, max_bytes: usize) -> usize {
168    if input.len() <= max_bytes {
169        return input.len();
170    }
171
172    let mut end = max_bytes;
173    while end > 0 && !input.is_char_boundary(end) {
174        end = end.saturating_sub(1);
175    }
176    end
177}
178
179fn append_bounded_lossy_stderr(stderr: &mut String, chunk: &[u8], max_len: usize) -> bool {
180    if stderr.len() >= max_len {
181        return true;
182    }
183
184    let chunk_str = String::from_utf8_lossy(chunk);
185    let remaining = max_len.saturating_sub(stderr.len());
186    if chunk_str.len() <= remaining {
187        stderr.push_str(&chunk_str);
188        return false;
189    }
190
191    let take = utf8_prefix_len(&chunk_str, remaining);
192    if take > 0 {
193        stderr.push_str(&chunk_str[..take]);
194    }
195    true
196}
197
198/// Errors that can occur before exec is successfully sent.
199/// These errors are retryable since the command has not started executing yet.
200enum PreExecError {
201    ChannelOpen(String),
202    ExecSend(String),
203}
204
205impl PreExecError {
206    /// Convert the pre-exec error into an SSH connection error.
207    fn into_ssh_error(self) -> SshMcpError {
208        match self {
209            PreExecError::ChannelOpen(msg) => SshMcpError::connection(msg),
210            PreExecError::ExecSend(msg) => SshMcpError::connection(msg),
211        }
212    }
213}
214
215/// Errors that can occur when sending command to su shell channel.
216/// These errors are retryable since the command has not started executing yet.
217enum SuSendError {
218    SendFailed(String),
219}
220
221impl SshConnectionManager {
222    /// Execute a command over SSH
223    ///
224    /// This method:
225    /// 1. Ensures the connection is active
226    /// 2. If elevated (su shell), uses the PTY shell channel
227    /// 3. Otherwise, opens a new exec channel
228    /// 4. Collects stdout/stderr with timeout
229    /// 5. On timeout, attempts graceful abort via pkill
230    ///
231    /// # Arguments
232    /// * `command` - The command to execute (should be pre-sanitized)
233    /// * `timeout_duration` - Maximum time to wait for command completion
234    ///
235    /// # Returns
236    /// * `Ok(CommandOutput)` - Command output with stdout, stderr, and exit code
237    /// * `Err(SshMcpError::Timeout)` - If command times out
238    /// * `Err(SshMcpError::Connection)` - If connection issues occur
239    pub async fn exec_command(
240        &self,
241        command: &str,
242        timeout_duration: Duration,
243    ) -> Result<CommandOutput> {
244        // Acquire semaphore permit to limit concurrent command execution
245        let _permit = self.acquire_command_slot().await?;
246
247        // Ensure we're connected
248        self.ensure_connected().await?;
249
250        // Check if we have an elevated su shell
251        if self.is_elevated() && self.has_su_channel().await {
252            debug!("Using elevated su shell for command execution");
253            return self.exec_via_su_shell(command, timeout_duration).await;
254        }
255
256        // Normal exec via new channel
257        debug!("Using normal exec channel for command execution");
258        self.exec_via_channel(command, timeout_duration).await
259    }
260
261    /// Execute command via the elevated su shell (PTY)
262    ///
263    /// Implements deterministic one-shot retry for pre-send failures:
264    /// - If sending command to su channel fails: reset su state, re-elevate, retry once
265    /// - If failure occurs after command is sent: no retry, reset su state and invalidate session
266    async fn exec_via_su_shell(
267        &self,
268        command: &str,
269        timeout_duration: Duration,
270    ) -> Result<CommandOutput> {
271        let duration_secs = validate_timeout_duration(timeout_duration)?;
272
273        // Check timeout availability lazily on first use (same as exec_via_channel)
274        let use_wrapper = self.determine_timeout_wrapper_usage().await;
275
276        // Wrap command with timeout if available
277        let wrapped_cmd = if use_wrapper {
278            wrap_command_with_timeout(command, duration_secs)
279        } else {
280            command.to_string()
281        };
282
283        debug!(
284            "Executing elevated command: cmd_len={}, wrapped_len={}, timeout_wrapped={}",
285            command.len(),
286            wrapped_cmd.len(),
287            use_wrapper
288        );
289
290        // Attempt #1: try to send command via existing su channel
291        let mut channel = match self.try_take_su_channel().await {
292            Some(ch) => ch,
293            None => {
294                // No channel available - try to elevate and retry once
295                warn!("No su channel available, attempting elevation");
296                self.reset_su_state().await;
297                self.ensure_elevated().await?;
298                match self.try_take_su_channel().await {
299                    Some(ch) => ch,
300                    None => {
301                        return Err(SshMcpError::connection(
302                            "No su channel available after elevation",
303                        ));
304                    }
305                }
306            }
307        };
308
309        // Try to send the command
310        match self
311            .try_send_to_su_channel(&mut channel, &wrapped_cmd)
312            .await
313        {
314            Ok(()) => {
315                // Command sent successfully - collect output
316                let result = self
317                    .collect_su_output(&mut channel, timeout_duration, use_wrapper)
318                    .await;
319
320                // Put the channel back (even if collection failed)
321                self.restore_su_channel(channel).await;
322
323                // Handle post-send failure: reset su state and invalidate session, no retry
324                if let Err(ref e) = result {
325                    warn!(error = ?e, "su channel failed after command sent");
326                    self.reset_su_state().await;
327                    self.invalidate_session("su channel failed after send")
328                        .await;
329                }
330
331                result
332            }
333            Err(SuSendError::SendFailed(e)) => {
334                // Pre-send failure: command was NOT sent
335                // Drop the bad channel (don't put it back)
336                drop(channel);
337
338                // Reset su state and re-elevate once
339                warn!(
340                    error = ?e,
341                    "su channel send failed (pre-send), resetting and re-elevating"
342                );
343                self.reset_su_state().await;
344                self.ensure_elevated().await?;
345
346                // Attempt #2: take new channel and send
347                let mut channel = match self.try_take_su_channel().await {
348                    Some(ch) => ch,
349                    None => {
350                        return Err(SshMcpError::connection(
351                            "No su channel available after re-elevation",
352                        ));
353                    }
354                };
355
356                // Try to send again - if this fails, no more retries
357                if let Err(SuSendError::SendFailed(e2)) = self
358                    .try_send_to_su_channel(&mut channel, &wrapped_cmd)
359                    .await
360                {
361                    // Second failure - drop channel, reset state, return error
362                    drop(channel);
363                    self.reset_su_state().await;
364                    return Err(SshMcpError::connection(format!(
365                        "Failed to send command to su channel after retry: {}",
366                        e2
367                    )));
368                }
369
370                // Second attempt succeeded - collect output
371                let result = self
372                    .collect_su_output(&mut channel, timeout_duration, use_wrapper)
373                    .await;
374
375                // Put the channel back
376                self.restore_su_channel(channel).await;
377
378                // Handle post-send failure: reset su state and invalidate session, no retry
379                if let Err(ref e) = result {
380                    warn!(error = ?e, "su channel failed after command sent (retry)");
381                    self.reset_su_state().await;
382                    self.invalidate_session("su channel failed after send (retry)")
383                        .await;
384                }
385
386                result
387            }
388        }
389    }
390
391    /// Try to take the su channel from the mutex
392    async fn try_take_su_channel(&self) -> Option<russh::Channel<russh::client::Msg>> {
393        let mut guard = self.su_channel.lock().await;
394        guard.take()
395    }
396
397    async fn restore_su_channel(&self, channel: russh::Channel<russh::client::Msg>) {
398        let mut guard = self.su_channel.lock().await;
399        if self.is_shutting_down() {
400            drop(guard);
401            let _ = channel.eof().await;
402        } else {
403            *guard = Some(channel);
404        }
405    }
406
407    /// Reset su state (clear channel and elevation flag)
408    async fn reset_su_state(&self) {
409        // Take channel out of mutex before awaiting to avoid deadlock
410        let channel = {
411            let mut guard = self.su_channel.lock().await;
412            guard.take()
413        };
414
415        // Drop lock before awaiting EOF
416        if let Some(ch) = channel {
417            // Try to close gracefully, but don't wait
418            let _ = ch.eof().await;
419        }
420
421        use std::sync::atomic::Ordering;
422        self.is_elevated.store(false, Ordering::SeqCst);
423        debug!("su state reset: channel cleared, is_elevated=false");
424    }
425
426    /// Try to send command to su channel
427    /// Returns Ok(()) if sent successfully, Err(SuSendError) if send failed
428    async fn try_send_to_su_channel(
429        &self,
430        channel: &mut russh::Channel<russh::client::Msg>,
431        command: &str,
432    ) -> std::result::Result<(), SuSendError> {
433        let wrapped_command = wrap_command_for_channel_exec(command);
434        channel
435            .data(format!("{}\n", wrapped_command).as_bytes())
436            .await
437            .map_err(|e| SuSendError::SendFailed(e.to_string()))
438    }
439
440    /// Collect output from su channel until root prompt or error
441    ///
442    /// This path is used by direct `exec_command` callers while a persistent
443    /// `su` PTY channel is active. Shell tools use their dedicated streaming
444    /// wrapper instead.
445    async fn collect_su_output(
446        &self,
447        channel: &mut russh::Channel<russh::client::Msg>,
448        timeout_duration: Duration,
449        use_wrapper: bool,
450    ) -> Result<CommandOutput> {
451        let mut buffer = String::new();
452        // When using wrapper, timeout is handled remotely - no local deadline needed
453        let deadline = if use_wrapper {
454            None
455        } else {
456            Some(tokio::time::Instant::now() + timeout_duration)
457        };
458
459        loop {
460            if let Some(deadline_ref) = deadline
461                && tokio::time::Instant::now() > deadline_ref
462            {
463                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
464            }
465
466            let wait_result =
467                tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
468
469            match wait_result {
470                Ok(Some(msg)) => {
471                    match msg {
472                        ChannelMsg::Data { data } => {
473                            let text = String::from_utf8_lossy(&data);
474                            buffer.push_str(&text);
475
476                            // Check for root prompt - indicates command complete.
477                            // The `#` sentinel is inherently fragile (any `#` in
478                            // command output would match). This is limited to the
479                            // direct persistent `su` PTY path described above.
480                            if buffer.contains('#') {
481                                // Extract output: remove the command echo and final prompt
482                                let lines: Vec<&str> = buffer.lines().collect();
483                                // First line is often the echoed command; last line is the prompt
484                                let output = if lines.len() > 2 {
485                                    lines[1..lines.len() - 1].join("\n")
486                                } else {
487                                    String::new()
488                                };
489
490                                return Ok(CommandOutput {
491                                    stdout: if output.is_empty() {
492                                        output
493                                    } else {
494                                        format!("{}\n", output)
495                                    },
496                                    stderr: String::new(),
497                                    exit_code: Some(0), // Assume success in PTY mode
498                                    ..Default::default()
499                                });
500                            }
501                        }
502                        ChannelMsg::Close => {
503                            return Err(SshMcpError::connection(
504                                "Channel closed during command execution",
505                            ));
506                        }
507                        _ => {
508                            // Ignore other messages
509                        }
510                    }
511                }
512                Ok(None) => {
513                    return Err(SshMcpError::connection(
514                        "Channel ended during command execution",
515                    ));
516                }
517                Err(_) => {
518                    // Timeout on wait, continue loop
519                    continue;
520                }
521            }
522        }
523    }
524
525    /// Execute command via a new exec channel
526    ///
527    /// Implements deterministic one-shot retry for pre-exec failures:
528    /// - Channel open failure: reconnect and retry once
529    /// - channel.exec() send failure: reconnect and retry once
530    /// - Failures after exec starts (output collection, Close/Eof): no retry,
531    ///   just invalidate session so next command reconnects
532    /// - Timeout errors: no retry (command may have partially run)
533    async fn exec_via_channel(
534        &self,
535        command: &str,
536        timeout_duration: Duration,
537    ) -> Result<CommandOutput> {
538        let duration_secs = validate_timeout_duration(timeout_duration)?;
539
540        // Wrap command with timeout if available
541        // Check timeout availability lazily on first use
542        let use_wrapper = self.determine_timeout_wrapper_usage().await;
543
544        let wrapped_cmd = if use_wrapper {
545            wrap_command_with_timeout(command, duration_secs)
546        } else {
547            // Fall back to old method: use tokio timeout + pkill
548            command.to_string()
549        };
550
551        // Attempt #1: open channel and exec
552        let (channel, _exec_sent) = self
553            .open_and_exec_with_reconnect_retry(&wrapped_cmd)
554            .await?;
555
556        // At this point, exec has been sent successfully.
557        // Collect output with appropriate timeout strategy.
558        // Failures here do NOT trigger retry - we just invalidate the session.
559        let output_result = if use_wrapper {
560            // When using wrapper, timeout is handled remotely - no tokio timeout needed
561            self.collect_channel_output(channel).await
562        } else {
563            // Fall back: use tokio timeout + pkill for abort
564            let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
565
566            match result {
567                Ok(inner_result) => inner_result,
568                Err(_) => {
569                    // Timeout occurred - attempt graceful abort
570                    warn!(
571                        "Command timed out after {}ms, attempting abort",
572                        timeout_duration.as_millis()
573                    );
574                    self.abort_command(command).await;
575                    self.invalidate_session("command timed out after exec")
576                        .await;
577                    return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
578                }
579            }
580        };
581
582        let output = match output_result {
583            Ok(out) => out,
584            Err(e) => {
585                // Failure after exec started - invalidate session, no retry
586                // Do not retry: command may have partially executed
587                if !matches!(e, SshMcpError::Timeout(_)) {
588                    self.invalidate_session("channel failed after exec").await;
589                }
590                return Err(e);
591            }
592        };
593
594        // Check if timeout command failed (e.g., not found) when using wrapper
595        if use_wrapper {
596            let stderr_lower = output.stderr.to_lowercase();
597            // Check for timeout command not found errors (multiple languages)
598            let timeout_not_found = stderr_lower.contains("timeout: command not found")
599                || stderr_lower.contains("timeout: не найдена команда")
600                || stderr_lower.contains("timeout: introuvable")
601                || stderr_lower.contains("timeout: команда не найдена");
602
603            if timeout_not_found {
604                error!("timeout command not available on remote host, enabling fallback");
605                self.disable_timeout_wrapper();
606
607                // Execute the command again using fallback method (tokio timeout + pkill)
608                // Note: This is a feature fallback, not a connection retry
609                let (channel, _) = self
610                    .open_and_exec_with_reconnect_retry(command)
611                    .await
612                    .map_err(|e| {
613                        SshMcpError::connection(format!(
614                            "Failed to start fallback execution after reconnect retry: {e}"
615                        ))
616                    })?;
617
618                let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
619
620                return match result {
621                    Ok(inner_output) => inner_output,
622                    Err(_) => {
623                        warn!(
624                            "Command timed out after {}ms (fallback), attempting abort",
625                            timeout_duration.as_millis()
626                        );
627                        self.abort_command(command).await;
628                        self.invalidate_session("fallback command timed out after exec")
629                            .await;
630                        Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
631                    }
632                };
633            }
634
635            // Check if the command was killed by timeout
636            // timeout returns 124 when it kills the command
637            if output.exit_code == Some(124) {
638                warn!("Command timed out (timeout wrapper returned 124)");
639                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
640            }
641        }
642
643        Ok(output)
644    }
645
646    /// Try to open a channel and send exec command
647    ///
648    /// Returns the channel and a boolean indicating exec was sent successfully.
649    /// Separates pre-exec failures (which can be retried) from post-exec state.
650    async fn try_open_and_exec(
651        &self,
652        command: &str,
653    ) -> std::result::Result<(russh::Channel<russh::client::Msg>, bool), PreExecError> {
654        let channel = self
655            .open_channel()
656            .await
657            .map_err(|e| PreExecError::ChannelOpen(e.to_string()))?;
658
659        debug!("Executing command: cmd_len={}", command.len());
660        let wrapped_command = wrap_command_for_channel_exec(command);
661        channel
662            .exec(true, wrapped_command.as_str())
663            .await
664            .map_err(|e| PreExecError::ExecSend(format!("Failed to exec command: {}", e)))?;
665
666        Ok((channel, true))
667    }
668
669    async fn open_and_exec_with_reconnect_retry(
670        &self,
671        command: &str,
672    ) -> Result<(russh::Channel<russh::client::Msg>, bool)> {
673        match self.try_open_and_exec(command).await {
674            Ok(result) => Ok(result),
675            Err(pre_exec_err) => {
676                match &pre_exec_err {
677                    PreExecError::ChannelOpen(e) => {
678                        warn!(
679                            error = ?e,
680                            "Channel open failed, attempting reconnect and retry"
681                        );
682                    }
683                    PreExecError::ExecSend(e) => {
684                        warn!(error = ?e, "Exec send failed, attempting reconnect and retry");
685                    }
686                }
687
688                self.reconnect().await?;
689                self.try_open_and_exec(command)
690                    .await
691                    .map_err(|retry_err| retry_err.into_ssh_error())
692            }
693        }
694    }
695
696    /// Collect output from a channel until it closes
697    ///
698    /// Implements output limiting to prevent OOM and context overflow.
699    /// Approximate token count: 1 token ≈ 4 bytes for UTF-8 text.
700    async fn collect_channel_output(
701        &self,
702        mut channel: russh::Channel<russh::client::Msg>,
703    ) -> Result<CommandOutput> {
704        // Approximate: 1 token ≈ 4 bytes for estimation
705        const BYTES_PER_TOKEN: usize = 4;
706        // Keep a small tail of truncated output so callers can still see
707        // end-of-command markers (e.g. "done").
708        const TAIL_BYTES: usize = 512;
709
710        let mut output = CommandOutput::new();
711
712        // Calculate byte limit from config (if set)
713        let max_bytes = self
714            .config
715            .max_output_tokens
716            .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
717
718        // Track total tokens received (including what was truncated)
719        let mut total_stdout_tokens: usize = 0;
720        let mut total_stderr_tokens: usize = 0;
721
722        // Flags to track if we've already added truncation messages
723        let mut stdout_truncation_added = false;
724        let mut stderr_truncation_added = false;
725
726        let mut stdout_tail: String = String::new();
727        let mut stderr_tail: String = String::new();
728
729        let push_tail = |buf: &mut String, chunk: &str| {
730            if chunk.is_empty() {
731                return;
732            }
733            buf.push_str(chunk);
734            if buf.len() > TAIL_BYTES {
735                let start = buf.len().saturating_sub(TAIL_BYTES);
736                let mut safe_start = start;
737                while safe_start > 0 && !buf.is_char_boundary(safe_start) {
738                    safe_start = safe_start.saturating_sub(1);
739                }
740                if safe_start > 0 {
741                    buf.drain(..safe_start);
742                }
743            }
744        };
745
746        while let Some(msg) = channel.wait().await {
747            match msg {
748                ChannelMsg::Data { data } => {
749                    let data_len = data.len();
750                    total_stdout_tokens =
751                        total_stdout_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
752                    let data_str = String::from_utf8_lossy(&data);
753
754                    if let Some(limit) = max_bytes {
755                        let current_len = output.stdout.len();
756
757                        // Check if we need to truncate
758                        if current_len.saturating_add(data_str.len()) > limit {
759                            if !stdout_truncation_added {
760                                // Calculate how much we can take
761                                let remaining = limit.saturating_sub(current_len);
762                                let mut take: usize = 0;
763                                if remaining > 0 {
764                                    // Safe slicing: we know remaining is within bounds since data_str.len() > remaining
765                                    let safe_end = data_str
766                                        .char_indices()
767                                        .map(|(i, _)| i)
768                                        .find(|&i| i > remaining)
769                                        .unwrap_or(data_str.len());
770                                    take = std::cmp::min(safe_end, remaining);
771                                    output.stdout.push_str(&data_str[..take]);
772                                }
773                                output.stdout_truncated = true;
774                                output.stdout_total_tokens = total_stdout_tokens;
775
776                                // Add truncation notice with tips
777                                output.stdout.push_str(&format!(
778                                    "\n[Output truncated: {} tokens total]",
779                                    total_stdout_tokens
780                                ));
781                                output.stdout.push_str(
782                                    "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
783                                );
784                                output.stdout.push_str(
785                                    "\n[Tip: For large output use SFTP/SCP tools to download files]",
786                                 );
787
788                                stdout_truncation_added = true;
789                                warn!(
790                                    "stdout truncated: total_tokens={}, limit_tokens={}",
791                                    total_stdout_tokens,
792                                    max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
793                                );
794
795                                push_tail(&mut stdout_tail, &data_str[take..]);
796                            } else {
797                                push_tail(&mut stdout_tail, &data_str);
798                            }
799                            // Skip remaining stdout data
800                        } else {
801                            output.stdout.push_str(&data_str);
802                        }
803                    } else {
804                        // No limit - add all data
805                        output.stdout.push_str(&data_str);
806                    }
807                }
808                ChannelMsg::ExtendedData { data, ext } => {
809                    let data_len = data.len();
810                    total_stderr_tokens =
811                        total_stderr_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
812
813                    // ext == 1 is typically stderr
814                    if ext == 1 {
815                        let data_str = String::from_utf8_lossy(&data);
816                        if let Some(limit) = max_bytes {
817                            let current_len = output.stderr.len();
818
819                            // Check if we need to truncate
820                            if current_len.saturating_add(data_str.len()) > limit {
821                                if !stderr_truncation_added {
822                                    // Calculate how much we can take
823                                    let remaining = limit.saturating_sub(current_len);
824                                    let mut take: usize = 0;
825                                    if remaining > 0 {
826                                        // Safe slicing: find UTF-8 safe boundary
827                                        let safe_end = data_str
828                                            .char_indices()
829                                            .map(|(i, _)| i)
830                                            .find(|&i| i > remaining)
831                                            .unwrap_or(data_str.len());
832                                        take = std::cmp::min(safe_end, remaining);
833                                        output.stderr.push_str(&data_str[..take]);
834                                    }
835                                    output.stderr_truncated = true;
836                                    output.stderr_total_tokens = total_stderr_tokens;
837
838                                    // Add truncation notice
839                                    output.stderr.push_str(&format!(
840                                        "\n[Output truncated: {} tokens total]",
841                                        total_stderr_tokens
842                                    ));
843                                    output.stderr.push_str(
844                                        "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
845                                    );
846                                    output.stderr.push_str(
847                                        "\n[Tip: For large output use SFTP/SCP tools to download files]",
848                                    );
849
850                                    stderr_truncation_added = true;
851                                    warn!(
852                                        "stderr truncated: total_tokens={}, limit_tokens={}",
853                                        total_stderr_tokens,
854                                        max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
855                                    );
856
857                                    push_tail(&mut stderr_tail, &data_str[take..]);
858                                } else {
859                                    push_tail(&mut stderr_tail, &data_str);
860                                }
861                                // Skip remaining stderr data
862                            } else {
863                                output.stderr.push_str(&data_str);
864                            }
865                        } else {
866                            // No limit - add all data
867                            output.stderr.push_str(&data_str);
868                        }
869                    } else {
870                        // Non-stderr extended data goes to stdout
871                        output.stdout.push_str(&String::from_utf8_lossy(&data));
872                    }
873                }
874                ChannelMsg::ExitStatus { exit_status } => {
875                    output.exit_code = Some(exit_status);
876                }
877                ChannelMsg::ExitSignal { signal_name, .. } => {
878                    // Map signal to conventional shell exit code (128 + signal),
879                    // matching stream_channel_inner in background/stream.rs.
880                    let code = match signal_name {
881                        russh::Sig::HUP => 129,
882                        russh::Sig::INT => 130,
883                        russh::Sig::QUIT => 131,
884                        russh::Sig::ILL => 132,
885                        russh::Sig::ABRT => 134,
886                        russh::Sig::FPE => 136,
887                        russh::Sig::KILL => 137,
888                        russh::Sig::USR1 => 138,
889                        russh::Sig::SEGV => 139,
890                        russh::Sig::PIPE => 141,
891                        russh::Sig::ALRM => 142,
892                        russh::Sig::TERM => 143,
893                        russh::Sig::Custom(_) => 128,
894                    };
895                    output.exit_code = Some(code);
896                }
897                ChannelMsg::Close | ChannelMsg::Eof => {
898                    // Don't break - ExitStatus may arrive after Close/Eof
899                    // Loop will exit naturally when channel.wait() returns None
900                }
901                _ => {
902                    // Ignore other messages
903                }
904            }
905        }
906
907        // Store final token counts (if not already set from truncation)
908        if output.stdout_total_tokens == 0 {
909            output.stdout_total_tokens = total_stdout_tokens;
910        }
911        if output.stderr_total_tokens == 0 {
912            output.stderr_total_tokens = total_stderr_tokens;
913        }
914
915        if output.stdout_truncated && !stdout_tail.is_empty() {
916            output.stdout.push('\n');
917            output.stdout.push_str(&stdout_tail);
918        }
919
920        if output.stderr_truncated && !stderr_tail.is_empty() {
921            output.stderr.push('\n');
922            output.stderr.push_str(&stderr_tail);
923        }
924
925        // If there's stderr and a non-zero exit code, we might want to handle it
926        // For now, just return the output as-is
927        debug!(
928            "Command completed: exit_code={:?}, stdout_len={}, stderr_len={}, stdout_truncated={}, stderr_truncated={}",
929            output.exit_code,
930            output.stdout.len(),
931            output.stderr.len(),
932            output.stdout_truncated,
933            output.stderr_truncated
934        );
935
936        // A channel that closed without an exit status or exit signal indicates
937        // the SSH session was torn down (e.g. concurrent invalidate_session,
938        // network drop, server kill).  Returning Ok with exit_code=None would
939        // be treated as success by calltool_from_command_output — a silent
940        // failure.  Return an explicit error instead so the caller can surface
941        // it and trigger reconnection.
942        if output.exit_code.is_none() {
943            return Err(SshMcpError::connection(
944                "SSH channel closed without exit status (session may have been torn down)",
945            ));
946        }
947
948        Ok(output)
949    }
950
951    /// Attempt to abort a running command by killing matching processes
952    ///
953    /// Sends `timeout 3s pkill -f 'command' 2>/dev/null || true` to kill
954    /// any processes matching the command pattern.
955    async fn abort_command(&self, command: &str) {
956        // Try to open a new channel for the abort command
957        let channel = match self.open_channel().await {
958            Ok(ch) => ch,
959            Err(e) => {
960                error!(error = ?e, "Failed to open channel for abort");
961                return;
962            }
963        };
964
965        let escaped_command = escape_command_for_shell(command);
966        let abort_cmd = format!(
967            "timeout 3s pkill -f '{}' 2>/dev/null || true",
968            escaped_command
969        );
970
971        debug!(
972            "Sending abort command: pattern_len={}, abort_len={}",
973            command.len(),
974            abort_cmd.len()
975        );
976
977        if let Err(e) = channel.exec(true, abort_cmd.as_str()).await {
978            error!(error = ?e, "Failed to exec abort command");
979            return;
980        }
981
982        // Wait briefly for abort to complete (max 5 seconds)
983        let abort_timeout = Duration::from_secs(5);
984        let _ = timeout(abort_timeout, async {
985            let mut channel = channel;
986            while let Some(msg) = channel.wait().await {
987                match msg {
988                    ChannelMsg::Close | ChannelMsg::Eof => break,
989                    _ => continue,
990                }
991            }
992        })
993        .await;
994
995        debug!("Abort command completed");
996    }
997
998    /// Execute a command over SSH with binary-safe streaming.
999    ///
1000    /// This method is designed for use-cases like file transfer where stdout must
1001    /// be treated as bytes and forwarded to a sink without UTF-8 decoding.
1002    ///
1003    /// Notes:
1004    /// - This does not use the interactive su shell.
1005    /// - Timeouts are enforced locally via tokio timeout.
1006    pub async fn exec_raw_streaming<R, W>(
1007        &self,
1008        command: &str,
1009        mut stdin: Option<&mut R>,
1010        mut stdout: Option<&mut W>,
1011        timeout_duration: Duration,
1012    ) -> Result<TransferRawOutput>
1013    where
1014        R: AsyncRead + Unpin,
1015        W: AsyncWrite + Unpin,
1016    {
1017        let _permit = self.acquire_command_slot().await?;
1018
1019        self.ensure_connected().await?;
1020
1021        // Raw transfers must not reuse the PTY/su channel.
1022        let fut = async {
1023            let channel = self.open_channel().await?;
1024            channel
1025                .exec(true, command)
1026                .await
1027                .map_err(|e| SshMcpError::connection(format!("Failed to exec command: {e}")))?;
1028
1029            // Prevent deadlocks by pumping stdin and stdout/stderr concurrently.
1030            // stdin/stdout are borrowed, so we keep IO in this task and run the SSH channel
1031            // event loop in a spawned task (owned channel).
1032            let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
1033            let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<RawStreamEvent>(8);
1034
1035            let task_guard = JoinAbortGuard::new(tokio::spawn(async move {
1036                raw_channel_task(channel, &mut stdin_rx, out_tx).await
1037            }));
1038
1039            let mut output = TransferRawOutput::default();
1040            let stderr_limit_bytes = resolve_raw_stream_stderr_limit(self.config.max_output_tokens);
1041            let mut total_stderr_bytes = 0usize;
1042            let mut stderr_truncated = false;
1043            let mut stdin_done = stdin.is_none();
1044            let mut stdin_tx: Option<tokio::sync::mpsc::Sender<Vec<u8>>> =
1045                if stdin_done { None } else { Some(stdin_tx) };
1046            let mut channel_closed = false;
1047            let mut out_rx_closed = false;
1048
1049            let mut buf = vec![0u8; 32 * 1024];
1050
1051            loop {
1052                if stdin_done && channel_closed && out_rx_closed {
1053                    break;
1054                }
1055
1056                tokio::select! {
1057                    read_res = async {
1058                        match stdin.as_mut() {
1059                            Some(r) => r.read(&mut buf).await,
1060                            None => Ok(0),
1061                        }
1062                    }, if !stdin_done => {
1063                        let n = read_res?;
1064                        if n == 0 {
1065                            stdin_done = true;
1066                            stdin_tx = None; // drop -> EOF
1067                        } else {
1068                            let chunk = buf[..n].to_vec();
1069                            match stdin_tx.as_mut() {
1070                                Some(tx) => {
1071                                    tx.send(chunk).await.map_err(|_| {
1072                                        SshMcpError::connection("raw channel task ended while sending stdin".to_string())
1073                                    })?;
1074                                    output.stdin_bytes += n as u64;
1075                                }
1076                                None => {
1077                                    return Err(SshMcpError::connection(
1078                                        "raw stdin channel closed unexpectedly".to_string(),
1079                                    ));
1080                                }
1081                            }
1082                        }
1083                    }
1084                    maybe_evt = out_rx.recv() => {
1085                        match maybe_evt {
1086                            Some(RawStreamEvent::Stdout(data)) => {
1087                                output.stdout_bytes += data.len() as u64;
1088                                if let Some(writer) = stdout.as_mut() {
1089                                    writer.write_all(&data).await?;
1090                                }
1091                            }
1092                            Some(RawStreamEvent::Stderr(data)) => {
1093                                total_stderr_bytes = total_stderr_bytes.saturating_add(data.len());
1094                                if !stderr_truncated {
1095                                    stderr_truncated = append_bounded_lossy_stderr(
1096                                        &mut output.stderr,
1097                                        &data,
1098                                        stderr_limit_bytes,
1099                                    );
1100                                    if stderr_truncated {
1101                                        warn!(
1102                                            total_stderr_bytes,
1103                                            stderr_limit_bytes,
1104                                            "raw streaming stderr truncated"
1105                                        );
1106                                    }
1107                                }
1108                            }
1109                            Some(RawStreamEvent::ExitStatus(code)) => {
1110                                output.exit_code = Some(code);
1111                            }
1112                            Some(RawStreamEvent::Closed) => {
1113                                channel_closed = true;
1114                            }
1115                            None => {
1116                                out_rx_closed = true;
1117                            }
1118                        }
1119                    }
1120                }
1121            }
1122
1123            if stderr_truncated {
1124                output.stderr.push_str(&format!(
1125                    "\n[stderr truncated: {} bytes total, limit {} bytes]",
1126                    total_stderr_bytes, stderr_limit_bytes
1127                ));
1128            }
1129
1130            if let Some(writer) = stdout.as_mut() {
1131                writer.flush().await?;
1132            }
1133
1134            let join_handle = match task_guard.into_handle() {
1135                Some(h) => h,
1136                None => {
1137                    return Err(SshMcpError::connection(
1138                        "raw channel task handle missing".to_string(),
1139                    ));
1140                }
1141            };
1142
1143            match join_handle.await {
1144                Ok(Ok(())) => Ok(output),
1145                Ok(Err(e)) => Err(e),
1146                Err(e) => Err(SshMcpError::connection(format!(
1147                    "raw channel task join failed: {e}"
1148                ))),
1149            }
1150        };
1151
1152        match timeout(timeout_duration, fut).await {
1153            Ok(res) => res,
1154            Err(_) => {
1155                self.invalidate_session("raw command timed out").await;
1156                Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
1157            }
1158        }
1159    }
1160
1161    /// Check the status of a background job by job_id.
1162    ///
1163    /// Uses `kill -0` for process detection (existence/permission check without sending a signal).
1164    /// This avoids parsing `ps` output (GNU vs BusyBox differences) and works on common Linux
1165    /// distributions.
1166    ///
1167    /// # Arguments
1168    /// * `job_id` - Job id returned by background exec
1169    /// * `tail_lines` - Number of lines to read from log tail
1170    /// * `registry` - Job registry holding current job state
1171    ///
1172    /// # Returns
1173    /// ProcessStatus with running state, exit code, elapsed time, command, and log tail
1174    pub async fn check_process(
1175        &self,
1176        job_id: &str,
1177        tail_lines: usize,
1178        registry: &JobRegistry,
1179        spooler: &LocalLogSpooler,
1180    ) -> Result<ProcessStatus> {
1181        debug!(job_id = ?job_id, "Checking process status");
1182
1183        let job = match registry.get(job_id).await {
1184            Some(job) => job,
1185            None => match spooler.load_job_state(job_id).await {
1186                Ok(Some(recovered)) => {
1187                    let shared = Arc::new(Mutex::new(recovered));
1188                    registry
1189                        .insert(job_id.to_string(), Arc::clone(&shared))
1190                        .await;
1191                    shared
1192                }
1193                Ok(None) => {
1194                    return Err(SshMcpError::invalid_params(format!(
1195                        "job not found: {job_id}"
1196                    )));
1197                }
1198                Err(e) => {
1199                    return Err(SshMcpError::invalid_params(format!(
1200                        "failed to recover job state for {job_id}: {e}"
1201                    )));
1202                }
1203            },
1204        };
1205
1206        let job_guard = job.lock().await;
1207        let pid = job_guard.pid;
1208        let command = job_guard.command.clone();
1209        let log_path = job_guard.log_path.clone();
1210        let status = job_guard.status;
1211        let exit_code_i32 = job_guard.exit_code;
1212        let stored_state_reason = job_guard.state_reason.clone();
1213        let elapsed_time = job_guard.elapsed_time();
1214        drop(job_guard);
1215
1216        let (running, effective_status, effective_exit_code_i32, effective_reason) = match status {
1217            JobStatus::Running => {
1218                self.ensure_connected().await?;
1219                if self.is_pid_running(pid).await? {
1220                    (true, JobStatus::Running, None, None)
1221                } else if let Some(code) = exit_code_i32 {
1222                    (false, job_status_from_exit_code(code), Some(code), None)
1223                } else {
1224                    let (settled_status, settled_exit_code, settled_reason) =
1225                        await_running_job_settle(&job).await;
1226                    match settled_status {
1227                        JobStatus::Running => match settled_exit_code {
1228                            Some(code) => {
1229                                (false, job_status_from_exit_code(code), Some(code), None)
1230                            }
1231                            None => (
1232                                false,
1233                                JobStatus::StateLost,
1234                                None,
1235                                Some(settled_reason.unwrap_or_else(|| {
1236                                    "pid_not_running_and_no_exit_status".to_string()
1237                                })),
1238                            ),
1239                        },
1240                        JobStatus::Completed | JobStatus::Failed => match settled_exit_code {
1241                            Some(code) => {
1242                                (false, job_status_from_exit_code(code), Some(code), None)
1243                            }
1244                            None => (
1245                                false,
1246                                JobStatus::StateLost,
1247                                None,
1248                                Some(settled_reason.unwrap_or_else(|| {
1249                                    "missing_exit_code_for_terminal_state".to_string()
1250                                })),
1251                            ),
1252                        },
1253                        JobStatus::StateLost => (
1254                            false,
1255                            JobStatus::StateLost,
1256                            None,
1257                            Some(settled_reason.unwrap_or_else(|| "state_lost".to_string())),
1258                        ),
1259                    }
1260                }
1261            }
1262            JobStatus::Completed | JobStatus::Failed => match exit_code_i32 {
1263                Some(code) => (false, job_status_from_exit_code(code), Some(code), None),
1264                None => (
1265                    false,
1266                    JobStatus::StateLost,
1267                    None,
1268                    Some(
1269                        stored_state_reason
1270                            .clone()
1271                            .unwrap_or_else(|| "missing_exit_code_for_terminal_state".to_string()),
1272                    ),
1273                ),
1274            },
1275            JobStatus::StateLost => (
1276                false,
1277                JobStatus::StateLost,
1278                None,
1279                Some(
1280                    stored_state_reason
1281                        .clone()
1282                        .unwrap_or_else(|| "state_lost".to_string()),
1283                ),
1284            ),
1285        };
1286
1287        if status != effective_status
1288            || exit_code_i32 != effective_exit_code_i32
1289            || stored_state_reason != effective_reason
1290        {
1291            let mut guard = job.lock().await;
1292            match effective_status {
1293                JobStatus::Running => {
1294                    guard.status = JobStatus::Running;
1295                    guard.exit_code = None;
1296                    guard.state_reason = None;
1297                }
1298                JobStatus::Completed | JobStatus::Failed => {
1299                    if let Some(code) = effective_exit_code_i32 {
1300                        guard.mark_exit(code);
1301                    }
1302                }
1303                JobStatus::StateLost => {
1304                    guard.mark_state_lost(
1305                        effective_reason
1306                            .clone()
1307                            .unwrap_or_else(|| "state_lost".to_string()),
1308                    );
1309                }
1310            }
1311
1312            let persisted = guard.clone();
1313            drop(guard);
1314
1315            if let Err(e) = spooler.persist_job_state(&persisted).await {
1316                warn!(job_id = ?job_id, error = ?e, "failed to persist reconciled job state");
1317            }
1318        }
1319
1320        let exit_code = if running || effective_status == JobStatus::StateLost {
1321            None
1322        } else {
1323            effective_exit_code_i32.and_then(|code| u32::try_from(code).ok())
1324        };
1325
1326        let log_exists = log_file_exists(&log_path).await?;
1327
1328        let log_tail = read_local_log_tail(&log_path, tail_lines).await?;
1329
1330        Ok(ProcessStatus {
1331            pid,
1332            state: effective_status.as_str().to_string(),
1333            running,
1334            exit_code,
1335            state_reason: effective_reason,
1336            elapsed_time,
1337            command,
1338            log_path: log_path.to_string_lossy().to_string(),
1339            log_exists,
1340            log_tail,
1341        })
1342    }
1343
1344    async fn is_pid_running(&self, pid: u32) -> Result<bool> {
1345        // `kill -0` checks for existence/permission without sending a signal.
1346        let cmd = format!("sh -c 'kill -0 {pid} 2>/dev/null'");
1347        let output = self.exec_command(&cmd, Duration::from_secs(5)).await?;
1348        Ok(output.exit_code == Some(0))
1349    }
1350}
1351
1352fn job_status_from_exit_code(exit_code: i32) -> JobStatus {
1353    if exit_code == 0 {
1354        JobStatus::Completed
1355    } else {
1356        JobStatus::Failed
1357    }
1358}
1359
1360async fn await_running_job_settle(
1361    job: &SharedJobState,
1362) -> (JobStatus, Option<i32>, Option<String>) {
1363    tokio::time::sleep(Duration::from_millis(150)).await;
1364    let guard = job.lock().await;
1365    (guard.status, guard.exit_code, guard.state_reason.clone())
1366}
1367
1368pub(crate) async fn read_local_log_tail(path: &Path, lines: usize) -> Result<String> {
1369    if lines == 0 {
1370        return Ok(String::new());
1371    }
1372
1373    let mut file = match open_log_read_no_symlink(path).await? {
1374        Some(f) => f,
1375        None => return Ok(String::new()),
1376    };
1377
1378    let meta = file.metadata().await?;
1379    let mut pos = meta.len();
1380
1381    const CHUNK_SIZE: u64 = 8192;
1382    const MAX_READ_BYTES: usize = 1024 * 1024;
1383
1384    let mut buf: Vec<u8> = Vec::new();
1385    let mut newlines = 0usize;
1386
1387    while pos > 0 && newlines <= lines && buf.len() < MAX_READ_BYTES {
1388        let read_len = std::cmp::min(CHUNK_SIZE, pos) as usize;
1389        pos = pos.saturating_sub(read_len as u64);
1390
1391        file.seek(std::io::SeekFrom::Start(pos)).await?;
1392
1393        let mut chunk = vec![0u8; read_len];
1394        let mut got = 0usize;
1395        while got < read_len {
1396            let n = file.read(&mut chunk[got..]).await?;
1397            if n == 0 {
1398                break;
1399            }
1400            got = got.saturating_add(n);
1401        }
1402        if got == 0 {
1403            break;
1404        }
1405        chunk.truncate(got);
1406
1407        newlines = newlines.saturating_add(chunk.iter().filter(|&&b| b == b'\n').count());
1408
1409        // Prepend chunk to existing buffer (bounded by MAX_READ_BYTES).
1410        if chunk.len().saturating_add(buf.len()) > MAX_READ_BYTES {
1411            let allowed = MAX_READ_BYTES.saturating_sub(buf.len());
1412            chunk.truncate(allowed);
1413        }
1414        chunk.extend_from_slice(&buf);
1415        buf = chunk;
1416    }
1417
1418    let text = String::from_utf8_lossy(&buf);
1419    let all_lines: Vec<&str> = text.lines().collect();
1420    if all_lines.is_empty() {
1421        return Ok(String::new());
1422    }
1423
1424    let start = all_lines.len().saturating_sub(lines);
1425    Ok(all_lines[start..].join("\n"))
1426}
1427
1428async fn log_file_exists(path: &Path) -> Result<bool> {
1429    match tokio::fs::symlink_metadata(path).await {
1430        Ok(meta) => {
1431            if meta.file_type().is_symlink() {
1432                return Err(std::io::Error::new(
1433                    std::io::ErrorKind::InvalidInput,
1434                    "log path is a symlink (refusing to follow it)",
1435                )
1436                .into());
1437            }
1438            Ok(meta.is_file())
1439        }
1440        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1441        Err(e) => Err(e.into()),
1442    }
1443}
1444
1445async fn open_log_read_no_symlink(path: &Path) -> Result<Option<tokio::fs::File>> {
1446    match tokio::fs::symlink_metadata(path).await {
1447        Ok(meta) => {
1448            if meta.file_type().is_symlink() {
1449                return Err(std::io::Error::new(
1450                    std::io::ErrorKind::InvalidInput,
1451                    "log path is a symlink (refusing to follow it)",
1452                )
1453                .into());
1454            }
1455            if !meta.is_file() {
1456                return Err(std::io::Error::new(
1457                    std::io::ErrorKind::InvalidInput,
1458                    "log path is not a regular file",
1459                )
1460                .into());
1461            }
1462        }
1463        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1464            return Ok(None);
1465        }
1466        Err(e) => return Err(e.into()),
1467    }
1468
1469    let mut opts = tokio::fs::OpenOptions::new();
1470    opts.read(true);
1471
1472    #[cfg(unix)]
1473    {
1474        opts.custom_flags(O_NOFOLLOW_FLAG);
1475    }
1476
1477    match opts.open(path).await {
1478        Ok(f) => {
1479            // Re-check based on the opened file handle to avoid TOCTOU.
1480            let meta = f.metadata().await?;
1481            if !meta.is_file() {
1482                return Err(std::io::Error::new(
1483                    std::io::ErrorKind::InvalidInput,
1484                    "log path is not a regular file",
1485                )
1486                .into());
1487            }
1488            Ok(Some(f))
1489        }
1490        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1491        Err(e) => {
1492            if let Ok(meta) = tokio::fs::symlink_metadata(path).await
1493                && meta.file_type().is_symlink()
1494            {
1495                return Err(std::io::Error::new(
1496                    std::io::ErrorKind::InvalidInput,
1497                    "log path is a symlink (refusing to follow it)",
1498                )
1499                .into());
1500            }
1501            Err(e.into())
1502        }
1503    }
1504}
1505
1506#[derive(Debug)]
1507enum RawStreamEvent {
1508    Stdout(Vec<u8>),
1509    Stderr(Vec<u8>),
1510    ExitStatus(u32),
1511    Closed,
1512}
1513
1514struct JoinAbortGuard<T> {
1515    handle: Option<tokio::task::JoinHandle<T>>,
1516}
1517
1518impl<T> JoinAbortGuard<T> {
1519    fn new(handle: tokio::task::JoinHandle<T>) -> Self {
1520        Self {
1521            handle: Some(handle),
1522        }
1523    }
1524
1525    fn into_handle(mut self) -> Option<tokio::task::JoinHandle<T>> {
1526        self.handle.take()
1527    }
1528}
1529
1530impl<T> Drop for JoinAbortGuard<T> {
1531    fn drop(&mut self) {
1532        if let Some(handle) = &self.handle {
1533            handle.abort();
1534        }
1535    }
1536}
1537
1538async fn raw_channel_task(
1539    mut channel: russh::Channel<russh::client::Msg>,
1540    stdin_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
1541    out_tx: tokio::sync::mpsc::Sender<RawStreamEvent>,
1542) -> Result<()> {
1543    let mut stdin_closed = false;
1544    let mut sent_closed = false;
1545    loop {
1546        tokio::select! {
1547            maybe_chunk = stdin_rx.recv(), if !stdin_closed => {
1548                match maybe_chunk {
1549                    Some(chunk) => {
1550                        channel.data(chunk.as_slice()).await.map_err(|e| {
1551                            SshMcpError::connection(format!("Failed to send stdin: {e}"))
1552                        })?;
1553                    }
1554                    None => {
1555                        stdin_closed = true;
1556                        let _ = channel.eof().await;
1557                    }
1558                }
1559            }
1560            maybe_msg = channel.wait() => {
1561                match maybe_msg {
1562                    Some(msg) => {
1563                        let send_evt = |evt: RawStreamEvent| async {
1564                            out_tx.send(evt).await.map_err(|_| ())
1565                        };
1566
1567                        match msg {
1568                            ChannelMsg::Data { data } => {
1569                                let bytes = data.as_ref().to_vec();
1570                                if send_evt(RawStreamEvent::Stdout(bytes)).await.is_err() {
1571                                    return Ok(());
1572                                }
1573                            }
1574                            ChannelMsg::ExtendedData { data, ext } => {
1575                                let bytes = data.as_ref().to_vec();
1576                                let evt = if ext == 1 {
1577                                    RawStreamEvent::Stderr(bytes)
1578                                } else {
1579                                    RawStreamEvent::Stdout(bytes)
1580                                };
1581                                if send_evt(evt).await.is_err() {
1582                                    return Ok(());
1583                                }
1584                            }
1585                            ChannelMsg::ExitStatus { exit_status }
1586                                if send_evt(RawStreamEvent::ExitStatus(exit_status)).await.is_err() =>
1587                            {
1588                                return Ok(());
1589                            }
1590                            ChannelMsg::ExitStatus { .. } => {}
1591                            ChannelMsg::ExitSignal { signal_name, .. } => {
1592                                // Map signal to exit code (128 + signal number)
1593                                // Common signals: HUP=1, INT=2, QUIT=3, ILL=4, TRAP=5, ABRT=6, BUS=7, FPE=8, KILL=9
1594                                let code = match signal_name {
1595                                    russh::Sig::HUP => 129,
1596                                    russh::Sig::INT => 130,
1597                                    russh::Sig::QUIT => 131,
1598                                    russh::Sig::ILL => 132,
1599                                    russh::Sig::ABRT => 134,
1600                                    russh::Sig::FPE => 136,
1601                                    russh::Sig::KILL => 137,
1602                                    russh::Sig::USR1 => 138,
1603                                    russh::Sig::SEGV => 139,
1604                                    russh::Sig::PIPE => 141,
1605                                    russh::Sig::ALRM => 142,
1606                                    russh::Sig::TERM => 143,
1607                                    russh::Sig::Custom(_) => 128,
1608                                };
1609                                if send_evt(RawStreamEvent::ExitStatus(code)).await.is_err() {
1610                                    return Ok(());
1611                                }
1612                            }
1613                            ChannelMsg::Close | ChannelMsg::Eof if !sent_closed => {
1614                                // Send Closed once but keep looping to capture trailing ExitStatus
1615                                sent_closed = true;
1616                                let _ = send_evt(RawStreamEvent::Closed).await;
1617                            }
1618                            ChannelMsg::Close | ChannelMsg::Eof => {}
1619                            _ => {}
1620                        }
1621                    }
1622                    None => {
1623                        // Channel fully closed - ensure we send Closed before exiting
1624                        if !sent_closed {
1625                            let _ = out_tx.send(RawStreamEvent::Closed).await;
1626                        }
1627                        break;
1628                    }
1629                }
1630            }
1631        }
1632    }
1633
1634    Ok(())
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640
1641    #[test]
1642    fn test_command_output_success() {
1643        let output = CommandOutput {
1644            stdout: "hello".to_string(),
1645            stderr: String::new(),
1646            exit_code: Some(0),
1647            ..Default::default()
1648        };
1649        assert!(output.success());
1650    }
1651
1652    #[test]
1653    fn test_command_output_failure() {
1654        let output = CommandOutput {
1655            stdout: String::new(),
1656            stderr: "error".to_string(),
1657            exit_code: Some(1),
1658            ..Default::default()
1659        };
1660        assert!(!output.success());
1661    }
1662
1663    #[test]
1664    fn test_command_output_no_exit_code() {
1665        let output = CommandOutput {
1666            stdout: "hello".to_string(),
1667            stderr: String::new(),
1668            exit_code: None,
1669            ..Default::default()
1670        };
1671        // No exit code means the channel was torn down — not success
1672        assert!(!output.success());
1673    }
1674
1675    #[test]
1676    fn test_command_output_combined() {
1677        let output = CommandOutput {
1678            stdout: "stdout".to_string(),
1679            stderr: "stderr".to_string(),
1680            exit_code: Some(0),
1681            ..Default::default()
1682        };
1683        assert_eq!(output.combined_output(), "stdout\nstderr");
1684    }
1685
1686    #[test]
1687    fn test_command_output_combined_only_stdout() {
1688        let output = CommandOutput {
1689            stdout: "stdout".to_string(),
1690            stderr: String::new(),
1691            exit_code: Some(0),
1692            ..Default::default()
1693        };
1694        assert_eq!(output.combined_output(), "stdout");
1695    }
1696
1697    #[test]
1698    fn test_command_output_combined_only_stderr() {
1699        let output = CommandOutput {
1700            stdout: String::new(),
1701            stderr: "stderr".to_string(),
1702            exit_code: Some(1),
1703            ..Default::default()
1704        };
1705        assert_eq!(output.combined_output(), "stderr");
1706    }
1707
1708    #[test]
1709    fn test_wrap_command_with_timeout() {
1710        let cmd = wrap_command_with_timeout("sleep 10", 2.0);
1711        assert!(cmd.contains("timeout -k 2s 2s"));
1712        assert!(cmd.contains("sh -lc")); // Uses login shell
1713        assert!(cmd.contains("sleep 10"));
1714    }
1715
1716    #[test]
1717    fn test_wrap_command_with_timeout_zero_duration() {
1718        // Edge case: wrapper accepts zero (validation is elsewhere)
1719        let cmd = wrap_command_with_timeout("echo test", 0.0);
1720        assert!(cmd.contains("timeout -k 2s 0s"));
1721        assert!(cmd.contains("sh -lc"));
1722        assert!(cmd.contains("echo test"));
1723    }
1724
1725    #[test]
1726    fn test_wrap_command_with_timeout_fractional() {
1727        // Test fractional seconds for sub-second precision
1728        let cmd = wrap_command_with_timeout("sleep 1", 0.5);
1729        assert!(cmd.contains("timeout -k 2s 0.5s"));
1730        assert!(cmd.contains("sh -lc"));
1731        assert!(cmd.contains("sleep 1"));
1732    }
1733
1734    #[test]
1735    fn test_wrap_command_with_timeout_complex_command() {
1736        let cmd = wrap_command_with_timeout("echo 'hello world'", 10.0);
1737        assert!(cmd.contains("timeout -k 2s 10s"));
1738        assert!(cmd.contains("sh -lc"));
1739        assert!(cmd.contains("echo"));
1740    }
1741
1742    #[test]
1743    fn test_wrap_command_with_timeout_with_single_quotes() {
1744        let cmd = wrap_command_with_timeout("echo 'hello'", 10.0);
1745        assert!(cmd.contains("timeout -k 2s 10s"));
1746        assert!(cmd.contains("sh -lc"));
1747        // Single quotes are escaped as '"'"'
1748        assert!(cmd.contains("'\"'\"'"));
1749    }
1750
1751    #[test]
1752    fn test_wrap_command_for_channel_exec_non_login_shell() {
1753        let cmd = wrap_command_for_channel_exec("echo hello");
1754        assert_eq!(cmd, "sh -c 'echo hello'");
1755    }
1756
1757    #[test]
1758    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload() {
1759        let timeout_wrapped = wrap_command_with_timeout("echo hello", 1.0);
1760        assert_eq!(timeout_wrapped, "timeout -k 2s 1s sh -lc 'echo hello'");
1761
1762        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1763        assert_eq!(
1764            cmd,
1765            "sh -c 'timeout -k 2s 1s sh -lc '\"'\"'echo hello'\"'\"''"
1766        );
1767    }
1768
1769    #[test]
1770    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload_with_single_quotes() {
1771        let timeout_wrapped = wrap_command_with_timeout("echo 'hello'", 1.0);
1772        assert_eq!(
1773            timeout_wrapped,
1774            "timeout -k 2s 1s sh -lc 'echo '\"'\"'hello'\"'\"''"
1775        );
1776
1777        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1778        assert!(cmd.starts_with("sh -c '"));
1779        assert!(cmd.ends_with('\''));
1780
1781        let inner = &cmd[7..cmd.len() - 1];
1782        let unescaped_once = inner.replace("'\"'\"'", "'");
1783        assert_eq!(unescaped_once, timeout_wrapped);
1784        assert!(cmd.contains("hello"));
1785    }
1786
1787    #[test]
1788    fn test_resolve_raw_stream_stderr_limit_uses_token_limit() {
1789        assert_eq!(
1790            resolve_raw_stream_stderr_limit(Some(12_000)),
1791            12_000 * RAW_STREAM_BYTES_PER_TOKEN
1792        );
1793    }
1794
1795    #[test]
1796    fn test_resolve_raw_stream_stderr_limit_none_uses_hard_cap() {
1797        assert_eq!(
1798            resolve_raw_stream_stderr_limit(None),
1799            RAW_STREAM_STDERR_HARD_MAX_BYTES
1800        );
1801    }
1802
1803    #[test]
1804    fn test_resolve_raw_stream_stderr_limit_applies_hard_cap() {
1805        assert_eq!(
1806            resolve_raw_stream_stderr_limit(Some(RAW_STREAM_STDERR_HARD_MAX_BYTES)),
1807            RAW_STREAM_STDERR_HARD_MAX_BYTES
1808        );
1809    }
1810
1811    #[test]
1812    fn test_append_bounded_lossy_stderr_no_truncation() {
1813        let mut stderr = String::new();
1814        let truncated = append_bounded_lossy_stderr(&mut stderr, b"hello", 16);
1815        assert!(!truncated);
1816        assert_eq!(stderr, "hello");
1817    }
1818
1819    #[test]
1820    fn test_append_bounded_lossy_stderr_truncates_at_utf8_boundary() {
1821        let mut stderr = String::new();
1822        let truncated = append_bounded_lossy_stderr(&mut stderr, "абв".as_bytes(), 3);
1823        assert!(truncated);
1824        assert_eq!(stderr, "а");
1825    }
1826}