Skip to main content

ssh_cli/ssh/
packing.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Safe packing of `sudo`/`su` commands for one-shot multi-host LLM flows.
5//!
6//! Builds **remote** `sh -c` strings with shell-safe single-quote escaping for
7//! compound commands sent over the SSH channel (`channel.exec`), **not** local
8//! `std::process::Command` spawns.
9//!
10//! # External process boundary (G-PROC)
11//!
12//! - Local product code never invokes `sh`/`sudo`/`su` via `Command`.
13//! - Remote packing is intentional: elevation must run on the target host shell.
14//! - Secrets go on channel stdin (`sudo -S` / `su`), never in argv / command text.
15//! - Callers must pass payloads already rejected for NUL (`validate_command_length`).
16
17use secrecy::{ExposeSecret, SecretString};
18use zeroize::Zeroize;
19
20/// Escapes a string for safe use inside shell single quotes.
21///
22/// Strategy: wrap in single quotes and escape inner single quotes
23/// with the sequence `'\''` (close quote, backslash-quote, open quote).
24#[must_use]
25pub fn escape_shell_single_quotes(value: &str) -> String {
26    let mut result = String::with_capacity(value.len() + 2);
27    result.push('\'');
28    for ch in value.chars() {
29        if ch == '\'' {
30            result.push_str("'\\''");
31        } else {
32            result.push(ch);
33        }
34    }
35    result.push('\'');
36    result
37}
38
39/// Appends `description` as a shell comment safely.
40#[must_use]
41pub fn append_description(command: &str, description: Option<&str>) -> String {
42    match description {
43        Some(d) if !d.trim().is_empty() => {
44            let cleaned = d.replace(['\n', '\r'], " ");
45            format!("{command} # {cleaned}")
46        }
47        _ => command.to_string(),
48    }
49}
50
51/// Packing result: remote command **without** secret in argv + optional bytes
52/// to send on the SSH channel stdin (GAP-SSH-SEC-001).
53///
54/// `stdin` may hold a password; [`Drop`] zeroizes it (memory / RAII rule).
55/// Debug redacts stdin. Prefer moving `stdin` into `run_command` (which also
56/// zeroizes after the channel write).
57#[derive(Clone)]
58pub struct PackedCommand {
59    /// Remote command line (no embedded password).
60    pub command: String,
61    /// Bytes to write on channel stdin (e.g. password + `\n` for `sudo -S` / `su`).
62    pub stdin: Option<Vec<u8>>,
63}
64
65impl std::fmt::Debug for PackedCommand {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("PackedCommand")
68            .field("command", &self.command)
69            .field("stdin", &self.stdin.as_ref().map(|_| "<redacted bytes>"))
70            .finish()
71    }
72}
73
74impl Drop for PackedCommand {
75    fn drop(&mut self) {
76        if let Some(ref mut bytes) = self.stdin {
77            bytes.zeroize();
78        }
79    }
80}
81
82impl PackedCommand {
83    /// Moves stdin out for the channel write; remaining drop is a no-op.
84    ///
85    /// Prefer this over field access: `Drop` prevents partial moves of `stdin`.
86    #[must_use]
87    pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
88        self.stdin.take()
89    }
90}
91
92/// Packs a command for `sudo` with `sh -c`.
93///
94/// - With password: `sudo -S -p '' sh -c 'cmd'` and password on the **channel stdin** (not argv).
95/// - Without password: `sudo -n sh -c 'cmd'`.
96#[must_use]
97pub fn pack_sudo(command: &str, sudo_password: Option<&SecretString>) -> PackedCommand {
98    let cmd_esc = escape_shell_single_quotes(command);
99    match sudo_password {
100        Some(password) => {
101            let mut stdin = password.expose_secret().as_bytes().to_vec();
102            stdin.push(b'\n');
103            PackedCommand {
104                command: format!("sudo -S -p '' sh -c {cmd_esc}"),
105                stdin: Some(stdin),
106            }
107        }
108        None => PackedCommand {
109            command: format!("sudo -n sh -c {cmd_esc}"),
110            stdin: None,
111        },
112    }
113}
114
115/// Packs a command for `su - -c` one-shot; password goes on the channel stdin.
116#[must_use]
117pub fn pack_su(command: &str, su_password: &SecretString) -> PackedCommand {
118    let cmd_esc = escape_shell_single_quotes(command);
119    let mut stdin = su_password.expose_secret().as_bytes().to_vec();
120    stdin.push(b'\n');
121    PackedCommand {
122        command: format!("su - -c {cmd_esc}"),
123        stdin: Some(stdin),
124    }
125}
126
127/// Sanitizes a command fragment for best-effort use with `pkill -f`.
128///
129/// Accepts alphanumerics and a restricted symbol set; stops at the first dangerous
130/// metacharacter. Requires at least 3 characters. Never embeds passwords (pattern only).
131#[must_use]
132pub fn remote_abort_pattern(command: &str) -> Option<String> {
133    let mut cleaned = String::with_capacity(command.len().min(128));
134    for ch in command.chars().take(128) {
135        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
136            cleaned.push(ch);
137        } else {
138            break;
139        }
140    }
141    // Avoid a second heap string when trim does not shrink `cleaned`.
142    let trimmed = cleaned.trim();
143    if trimmed.len() < 3 {
144        None
145    } else if trimmed.len() == cleaned.len() {
146        Some(cleaned)
147    } else {
148        Some(trimmed.to_string())
149    }
150}
151
152/// Builds a best-effort remote abort command (TERM, then KILL).
153///
154/// Does not embed secrets; uses only the sanitized command pattern.
155#[must_use]
156pub fn pack_abort_pkill(pattern: &str) -> String {
157    let esc = escape_shell_single_quotes(pattern);
158    format!(
159        "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
160    )
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn escape_single_quote() {
169        assert_eq!(escape_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
170        assert_eq!(escape_shell_single_quotes("abc"), "'abc'");
171    }
172
173    #[test]
174    fn sudo_with_password_uses_sh_c_no_secret_in_argv() {
175        let password = SecretString::from("s3cr3t".to_string());
176        let pack = pack_sudo("echo hi | tee /tmp/x", Some(&password));
177        assert!(pack.command.contains("sudo -S -p '' sh -c"));
178        assert!(!pack.command.contains("s3cr3t"));
179        assert!(!pack.command.contains("printf"));
180        let mut pack = pack;
181        let stdin = pack.take_stdin().expect("stdin with password");
182        assert_eq!(stdin, b"s3cr3t\n");
183    }
184
185    #[test]
186    fn sudo_without_password_uses_n() {
187        let pack = pack_sudo("id", None);
188        assert_eq!(pack.command, "sudo -n sh -c 'id'");
189        assert!(pack.stdin.is_none());
190    }
191
192    #[test]
193    fn su_pack_no_secret_in_argv() {
194        let password = SecretString::from("rootpw".to_string());
195        let pack = pack_su("whoami", &password);
196        assert!(pack.command.contains("su - -c"));
197        assert!(!pack.command.contains("rootpw"));
198        assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
199    }
200
201    #[test]
202    fn description_appends_comment() {
203        assert_eq!(
204            append_description("ls", Some("lista arquivos")),
205            "ls # lista arquivos"
206        );
207        assert_eq!(append_description("ls", None), "ls");
208    }
209
210    #[test]
211    fn debug_redacts_stdin() {
212        let password = SecretString::from("s3cr3t".to_string());
213        let pack = pack_sudo("id", Some(&password));
214        let dbg = format!("{pack:?}");
215        assert!(!dbg.contains("s3cr3t"));
216        assert!(dbg.contains("<redacted bytes>"));
217    }
218
219    #[test]
220    fn abort_pattern_sanitizes() {
221        assert_eq!(
222            remote_abort_pattern("sleep 999"),
223            Some("sleep 999".to_string())
224        );
225        // GAP-SSH-TEST-003: dangerous metacharacter → reject (not a tautology).
226        assert_eq!(remote_abort_pattern("$(rm -rf)"), None);
227        assert!(remote_abort_pattern("ab").is_none());
228    }
229}