ssh_mcp/ssh/elevation.rs
1//! Privilege elevation utilities for SSH command execution
2//!
3//! This module provides utilities for:
4//! - Wrapping commands with `sudo` for privilege escalation
5//! - Escaping passwords and commands for safe shell execution
6//!
7//! The elevation logic for `su` shells is implemented directly in
8//! [`SshConnectionManager`](super::connection::SshConnectionManager).
9
10/// Wraps a command for execution with sudo privileges.
11///
12/// # Arguments
13/// * `command` - The command to wrap with sudo
14/// * `password` - Optional sudo password. If None, uses `sudo -n` (passwordless).
15/// If Some, uses `printf | sudo -S` to pipe the password.
16///
17/// # Returns
18/// A string containing the wrapped command ready for execution.
19///
20/// # Examples
21///
22/// ```
23/// use ssh_mcp::ssh::elevation::wrap_sudo_command;
24///
25/// // Passwordless sudo
26/// let cmd = wrap_sudo_command("apt update", None);
27/// assert_eq!(cmd, "sudo -n sh -c 'apt update'");
28///
29/// // Sudo with password
30/// let cmd = wrap_sudo_command("apt update", Some("mypassword"));
31/// assert_eq!(cmd, "printf '%s\\n' 'mypassword' | sudo -S -p '' sh -c 'apt update'");
32/// ```
33pub fn wrap_sudo_command(command: &str, password: Option<&str>) -> String {
34 let escaped_command = escape_for_shell(command);
35
36 match password {
37 None => {
38 // No password provided, use -n to fail if sudo requires a password
39 format!("sudo -n sh -c '{}'", escaped_command)
40 }
41 Some(pwd) => {
42 // Password provided — pipe it into sudo using printf. This avoids complex
43 // PTY/stdin handling on the SSH channel and is simpler and more reliable.
44 let escaped_pwd = escape_for_shell(pwd);
45 format!(
46 "printf '%s\\n' '{}' | sudo -S -p '' sh -c '{}'",
47 escaped_pwd, escaped_command
48 )
49 }
50 }
51}
52
53/// Escapes a string for safe use in single-quoted shell contexts.
54///
55/// Replaces single quotes with the pattern `'"'"'` which:
56/// 1. Ends the current single-quoted string
57/// 2. Adds an escaped single quote
58/// 3. Starts a new single-quoted string
59///
60/// This is the standard POSIX-compliant method for escaping single quotes
61/// within single-quoted strings.
62///
63/// # Examples
64///
65/// ```
66/// use ssh_mcp::ssh::elevation::escape_for_shell;
67///
68/// assert_eq!(escape_for_shell("hello"), "hello");
69/// assert_eq!(escape_for_shell("it's"), "it'\"'\"'s");
70/// assert_eq!(escape_for_shell("a'b'c"), "a'\"'\"'b'\"'\"'c");
71/// ```
72pub fn escape_for_shell(s: &str) -> String {
73 crate::shell_escape::escape_for_shell(s)
74}
75
76/// Checks if a password is valid for use in sudo commands.
77///
78/// A valid password:
79/// - Is not empty after trimming
80/// - Does not contain null bytes
81///
82/// # Arguments
83/// * `password` - The password to validate
84///
85/// # Returns
86/// `true` if the password is valid, `false` otherwise.
87pub fn is_valid_password(password: &str) -> bool {
88 !password.trim().is_empty() && !password.contains('\0')
89}
90
91/// Sanitizes a password by trimming whitespace.
92///
93/// Returns `None` if the password is empty or only whitespace.
94///
95/// # Arguments
96/// * `password` - The password to sanitize
97///
98/// # Returns
99/// `Some(String)` with the sanitized password, or `None` if invalid.
100pub fn sanitize_password(password: Option<&str>) -> Option<String> {
101 password
102 .map(|p| p.trim())
103 .filter(|p| !p.is_empty())
104 .map(|p| p.to_string())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn test_wrap_sudo_command_without_password() {
113 let result = wrap_sudo_command("apt update", None);
114 assert_eq!(result, "sudo -n sh -c 'apt update'");
115 }
116
117 #[test]
118 fn test_wrap_sudo_command_with_password() {
119 let result = wrap_sudo_command("apt update", Some("secret123"));
120 assert_eq!(
121 result,
122 "printf '%s\\n' 'secret123' | sudo -S -p '' sh -c 'apt update'"
123 );
124 }
125
126 #[test]
127 fn test_wrap_sudo_command_with_quotes_in_command() {
128 let result = wrap_sudo_command("echo 'hello world'", None);
129 assert_eq!(result, "sudo -n sh -c 'echo '\"'\"'hello world'\"'\"''");
130 }
131
132 #[test]
133 fn test_wrap_sudo_command_with_quotes_in_password() {
134 let result = wrap_sudo_command("apt update", Some("pass'word"));
135 assert_eq!(
136 result,
137 "printf '%s\\n' 'pass'\"'\"'word' | sudo -S -p '' sh -c 'apt update'"
138 );
139 }
140
141 #[test]
142 fn test_wrap_sudo_command_complex() {
143 let result = wrap_sudo_command("cat /etc/shadow | grep root", Some("admin123"));
144 assert_eq!(
145 result,
146 "printf '%s\\n' 'admin123' | sudo -S -p '' sh -c 'cat /etc/shadow | grep root'"
147 );
148 }
149
150 #[test]
151 fn test_escape_for_shell_no_quotes() {
152 assert_eq!(escape_for_shell("hello world"), "hello world");
153 }
154
155 #[test]
156 fn test_escape_for_shell_single_quote() {
157 assert_eq!(escape_for_shell("it's"), "it'\"'\"'s");
158 }
159
160 #[test]
161 fn test_escape_for_shell_multiple_quotes() {
162 assert_eq!(
163 escape_for_shell("'a' and 'b'"),
164 "'\"'\"'a'\"'\"' and '\"'\"'b'\"'\"'"
165 );
166 }
167
168 #[test]
169 fn test_is_valid_password() {
170 assert!(is_valid_password("secret123"));
171 assert!(is_valid_password("with spaces"));
172 assert!(!is_valid_password(""));
173 assert!(!is_valid_password(" "));
174 assert!(!is_valid_password("has\0null"));
175 }
176
177 #[test]
178 fn test_sanitize_password() {
179 assert_eq!(
180 sanitize_password(Some("secret")),
181 Some("secret".to_string())
182 );
183 assert_eq!(
184 sanitize_password(Some(" secret ")),
185 Some("secret".to_string())
186 );
187 assert_eq!(sanitize_password(Some("")), None);
188 assert_eq!(sanitize_password(Some(" ")), None);
189 assert_eq!(sanitize_password(None), None);
190 }
191}