From 44c67f7ee74b6cc78d1656cb03b9ad3bd2659bbb Mon Sep 17 00:00:00 2001 From: Steve Date: Fri, 6 Nov 2026 11:02:20 +0530 Subject: [PATCH] fix(tunnel): don't split a multibyte char when trimming the ssh stderr tail --- CHANGELOG.md | 7 +++++++ src/tunnel/mod.rs | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5058361..37771203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to SSHub are documented in this file. ## [Unreleased] +### Fixed + +- **Tunnel no longer panics on non-ASCII ssh errors** - trimming the kept ssh + stderr of a tunnel cut at a byte offset, so a multibyte character on the + boundary panicked the reader thread once the output passed 8 KB. It now cuts + on a character boundary. + ## [0.21.0] - 2026-10-28 ### Added diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index 59fc255e..dea4406b 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -78,6 +78,27 @@ impl TunnelStatus { } } +/// Cap on the stderr kept per tunnel process (bytes); past it the oldest +/// output is dropped down to [`STDERR_TAIL_KEEP`]. +const STDERR_TAIL_CAP: usize = 8192; +const STDERR_TAIL_KEEP: usize = 4096; + +/// Append one stderr line to `buf`, trimming old output from the front once +/// `buf` passes [`STDERR_TAIL_CAP`]. +fn push_stderr_line(buf: &mut String, line: &str) { + buf.push_str(line); + buf.push('\n'); + if buf.len() > STDERR_TAIL_CAP { + // `cut` is a byte offset: step back to a char boundary so a multibyte + // character is never split. + let mut cut = buf.len() - STDERR_TAIL_KEEP; + while !buf.is_char_boundary(cut) { + cut -= 1; + } + buf.drain(..cut); + } +} + struct TunnelProcess { child: Child, started_at: Instant, @@ -149,12 +170,7 @@ impl TunnelManager { use std::io::{BufRead, BufReader}; for line in BufReader::new(err).lines().map_while(Result::ok) { if let Ok(mut s) = buf.lock() { - s.push_str(&line); - s.push('\n'); - if s.len() > 8192 { - let cut = s.len() - 4096; - s.drain(..cut); - } + push_stderr_line(&mut s, &line); } } }); @@ -559,6 +575,18 @@ mod tests { use super::*; use std::time::Duration; + #[test] + fn stderr_tail_trim_does_not_split_a_multibyte_char() { + let mut buf = String::new(); + // Two-byte chars after a one-byte prefix, so the trim point lands + // inside a char whenever the buffer crosses the cap on an odd length. + for i in 0..200 { + push_stderr_line(&mut buf, &format!("x{}", "\u{e9}".repeat(50 + i % 7))); + } + assert!(buf.len() <= STDERR_TAIL_CAP); + assert!(buf.ends_with('\n')); + } + #[test] fn active_work_excludes_exhausted_reconnects() { let mut mgr = TunnelManager::new();