Skip to main content

rmux_proto/
control.rs

1//! tmux-compatible control-mode text protocol helpers.
2
3use serde::{Deserialize, Serialize};
4
5/// tmux-compatible control-mode transport flavor negotiated over the detached
6/// bincode RPC channel.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ControlMode {
9    /// Plain `-C` control mode.
10    Plain,
11    /// `-CC` control-control mode with DCS wrapping.
12    ControlControl,
13}
14
15impl ControlMode {
16    /// Returns the tmux top-level `-C` count as parsed by Clap.
17    #[must_use]
18    pub const fn from_count(count: u8) -> Self {
19        if count >= 2 {
20            Self::ControlControl
21        } else {
22            Self::Plain
23        }
24    }
25
26    /// Returns `true` when the client requested tmux control-control mode.
27    #[must_use]
28    pub const fn is_control_control(self) -> bool {
29        matches!(self, Self::ControlControl)
30    }
31}
32
33/// Low watermark for buffered control-mode output.
34pub const CONTROL_BUFFER_LOW: usize = 512;
35/// High watermark for buffered control-mode output.
36pub const CONTROL_BUFFER_HIGH: usize = 8192;
37/// Minimum control-mode write chunk tmux attempts before stopping.
38pub const CONTROL_WRITE_MINIMUM: usize = 32;
39/// Maximum age for queued control-mode pane output before disconnecting.
40pub const CONTROL_MAXIMUM_AGE_MS: u64 = 300_000;
41/// Startup prefix for control-control mode.
42pub const CONTROL_CONTROL_START: &str = "\u{1b}P1000p";
43/// Shutdown suffix for control-control mode.
44pub const CONTROL_CONTROL_END: &str = "\u{1b}\\";
45/// Private in-band marker used by Windows rmux clients to represent stdin EOF.
46///
47/// Windows named pipes do not provide a Unix-style write-half close while the
48/// same client handle keeps reading server output. This marker is consumed by
49/// the rmux server before command parsing and is never emitted as user output.
50pub const CONTROL_STDIN_EOF_MARKER: &str = "\0rmux-control-eof";
51
52/// Detached upgrade request that switches a connection into tmux-compatible
53/// control mode while leaving the underlying RPC framing unchanged.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55pub struct ClientTerminalContext {
56    /// Explicit terminal feature names contributed by top-level `-2` and `-T`.
57    #[serde(default)]
58    pub terminal_features: Vec<String>,
59    /// Whether the invoking client should be treated as UTF-8 capable.
60    #[serde(default)]
61    pub utf8: bool,
62}
63
64/// Maximum number of command-line commands accepted across a control-mode
65/// upgrade boundary.
66pub const MAX_INITIAL_CONTROL_COMMANDS: usize = 1024;
67
68/// Detached upgrade request that switches a connection into tmux-compatible
69/// control mode while leaving the underlying RPC framing unchanged.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ControlModeRequest {
72    /// The requested control-mode flavor.
73    pub mode: ControlMode,
74    /// Terminal/runtime hints captured from the invoking client.
75    #[serde(default)]
76    pub client_terminal: ClientTerminalContext,
77    /// Number of command-line commands written immediately after the upgrade frame.
78    ///
79    /// This is explicit because local sockets and Windows named pipes are byte
80    /// streams: write boundaries cannot identify argv commands reliably.
81    #[serde(default)]
82    pub initial_command_count: u32,
83}
84
85/// Detached upgrade response acknowledging entry into control mode.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ControlModeResponse {
88    /// The accepted control-mode flavor.
89    pub mode: ControlMode,
90}
91
92/// Guard kind for `%begin`, `%end`, and `%error`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ControlGuardKind {
95    /// `%begin`
96    Begin,
97    /// `%end`
98    End,
99    /// `%error`
100    Error,
101}
102
103impl ControlGuardKind {
104    /// Returns the tmux control-guard keyword.
105    #[must_use]
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Begin => "begin",
109            Self::End => "end",
110            Self::Error => "error",
111        }
112    }
113}
114
115/// Formats a tmux-compatible guard line.
116#[must_use]
117pub fn format_guard_line(
118    kind: ControlGuardKind,
119    time_secs: i64,
120    command_number: u64,
121    flags: u8,
122) -> String {
123    format!(
124        "%{} {} {} {}\n",
125        kind.as_str(),
126        time_secs,
127        command_number,
128        flags
129    )
130}
131
132/// Formats a tmux-compatible `%output` line for pane bytes.
133#[must_use]
134pub fn format_output_line(pane_id: u32, bytes: &[u8]) -> String {
135    format!("%output %{} {}\n", pane_id, octal_escape(bytes))
136}
137
138/// Formats a tmux-compatible `%extended-output` line for pane bytes.
139#[must_use]
140pub fn format_extended_output_line(pane_id: u32, age_ms: u64, bytes: &[u8]) -> String {
141    format!(
142        "%extended-output %{} {} : {}\n",
143        pane_id,
144        age_ms,
145        octal_escape(bytes)
146    )
147}
148
149/// Formats a tmux-compatible `%pause` line.
150#[must_use]
151pub fn format_pause_line(pane_id: u32) -> String {
152    format!("%pause %{}\n", pane_id)
153}
154
155/// Formats a tmux-compatible `%continue` line.
156#[must_use]
157pub fn format_continue_line(pane_id: u32) -> String {
158    format!("%continue %{}\n", pane_id)
159}
160
161/// Formats a tmux-compatible `%exit` line.
162#[must_use]
163pub fn format_exit_line(reason: Option<&str>) -> String {
164    match reason {
165        Some(reason) if !reason.is_empty() => format!("%exit {reason}\n"),
166        _ => "%exit\n".to_owned(),
167    }
168}
169
170/// Formats a tmux-compatible control-mode data payload.
171///
172/// ASCII control bytes and `\` are `\NNN` octal-escaped. Valid UTF-8
173/// text is left intact so clients that expect tmux-style Unicode output do
174/// not see every non-ASCII byte expanded into octal sequences. Invalid UTF-8
175/// bytes are escaped one byte at a time.
176#[must_use]
177pub fn octal_escape(bytes: &[u8]) -> String {
178    let mut output = String::with_capacity(bytes.len());
179    let mut offset = 0;
180    while offset < bytes.len() {
181        match std::str::from_utf8(&bytes[offset..]) {
182            Ok(valid) => {
183                push_escaped_text(&mut output, valid);
184                break;
185            }
186            Err(error) if error.valid_up_to() > 0 => {
187                let valid_end = offset + error.valid_up_to();
188                let valid = std::str::from_utf8(&bytes[offset..valid_end])
189                    .expect("valid_up_to must describe valid UTF-8");
190                push_escaped_text(&mut output, valid);
191                offset = valid_end;
192            }
193            Err(error) => {
194                let invalid_len = error.error_len().unwrap_or(1);
195                for &byte in &bytes[offset..offset + invalid_len] {
196                    push_octal_escape(&mut output, byte);
197                }
198                offset += invalid_len;
199            }
200        }
201    }
202    output
203}
204
205fn push_escaped_text(output: &mut String, text: &str) {
206    for character in text.chars() {
207        if character.is_ascii() {
208            let byte = character as u8;
209            if needs_octal_escape(byte) {
210                push_octal_escape(output, byte);
211            } else {
212                output.push(character);
213            }
214        } else {
215            output.push(character);
216        }
217    }
218}
219
220const fn needs_octal_escape(byte: u8) -> bool {
221    byte < b' ' || byte == b'\\'
222}
223
224fn push_octal_escape(output: &mut String, byte: u8) {
225    output.push('\\');
226    output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
227    output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
228    output.push(char::from(b'0' + (byte & 0x7)));
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{
234        format_exit_line, format_extended_output_line, format_guard_line, format_output_line,
235        octal_escape, ControlGuardKind, ControlMode,
236    };
237
238    #[test]
239    fn count_two_selects_control_control_mode() {
240        assert_eq!(ControlMode::from_count(0), ControlMode::Plain);
241        assert_eq!(ControlMode::from_count(1), ControlMode::Plain);
242        assert_eq!(ControlMode::from_count(2), ControlMode::ControlControl);
243        assert_eq!(ControlMode::from_count(3), ControlMode::ControlControl);
244    }
245
246    #[test]
247    fn octal_escape_matches_tmux_rules_for_control_bytes() {
248        assert_eq!(octal_escape(b"abc"), "abc");
249        assert_eq!(octal_escape(b"a\nb"), "a\\012b");
250        assert_eq!(octal_escape(b"\\\0"), "\\134\\000");
251        assert_eq!(octal_escape(b" "), " ");
252        assert_eq!(octal_escape(b"~"), "~");
253        // DEL is printable from tmux control-mode's perspective.
254        assert_eq!(octal_escape(b"\x7f"), "\x7f");
255        assert_eq!(octal_escape("é".as_bytes()), "é");
256        assert_eq!(octal_escape("hello 👋".as_bytes()), "hello 👋");
257        // Invalid UTF-8 still round-trips as octal bytes.
258        assert_eq!(octal_escape(b"\x80"), "\\200");
259        assert_eq!(octal_escape(b"\xff"), "\\377");
260        // All printable ASCII passes through literally.
261        for byte in b' '..b'\x7f' {
262            if byte == b'\\' {
263                continue;
264            }
265            let escaped = octal_escape(&[byte]);
266            assert_eq!(
267                escaped.len(),
268                1,
269                "byte {byte:#04x} should be literal, got {escaped:?}"
270            );
271        }
272    }
273
274    #[test]
275    fn octal_escape_covers_every_ascii_control_byte() {
276        for byte in 0_u8..b' ' {
277            let escaped = octal_escape(&[byte]);
278            assert_eq!(
279                escaped,
280                format!("\\{byte:03o}"),
281                "control byte {byte:#04x} should be octal escaped"
282            );
283        }
284        assert_eq!(octal_escape(b"\\"), "\\134");
285    }
286
287    #[test]
288    fn guard_and_output_lines_are_newline_terminated() {
289        assert_eq!(
290            format_guard_line(ControlGuardKind::Begin, 10, 22, 1),
291            "%begin 10 22 1\n"
292        );
293        assert_eq!(format_output_line(7, b"hi\n"), "%output %7 hi\\012\n");
294        assert_eq!(
295            format_extended_output_line(7, 15, b"hi"),
296            "%extended-output %7 15 : hi\n"
297        );
298        assert_eq!(format_exit_line(None), "%exit\n");
299        assert_eq!(
300            format_exit_line(Some("too far behind")),
301            "%exit too far behind\n"
302        );
303    }
304}