@@ -9,6 +9,19 @@ use tracing::debug;
99
1010use crate :: CONTAINER_STATEDIR ;
1111
12+ /// Combine multiple command arguments into a properly escaped shell command string
13+ ///
14+ /// This is necessary because SSH protocol sends commands as strings, not argument arrays.
15+ /// When bcvk receives multiple arguments like ["/bin/sh", "-c", "echo hello; sleep 5"],
16+ /// they must be combined into a single string that will be correctly interpreted by the
17+ /// remote shell.
18+ ///
19+ /// Uses the `shlex` crate for robust POSIX shell escaping.
20+ pub fn shell_escape_command ( args : & [ String ] ) -> String {
21+ shlex:: try_join ( args. iter ( ) . map ( |s| s. as_str ( ) ) )
22+ . expect ( "Command arguments should not contain null bytes" )
23+ }
24+
1225/// Represents an SSH keypair with file paths and public key content
1326#[ derive( Debug , Clone ) ]
1427pub struct SshKeyPair {
@@ -176,25 +189,76 @@ pub fn connect_via_container(container_name: &str, args: Vec<String>) -> Result<
176189/// SSH connection configuration options
177190#[ derive( Debug , Clone ) ]
178191pub struct SshConnectionOptions {
179- /// Connection timeout in seconds (default: 30)
180- pub connect_timeout : u32 ,
192+ /// Common SSH options shared across implementations
193+ pub common : CommonSshOptions ,
181194 /// Enable/disable TTY allocation (default: true)
182195 pub allocate_tty : bool ,
183- /// SSH log level (default: ERROR)
196+ /// Suppress output to stdout/stderr (default: false)
197+ pub suppress_output : bool ,
198+ }
199+
200+ /// Common SSH options that can be shared between different SSH implementations
201+ #[ derive( Debug , Clone ) ]
202+ pub struct CommonSshOptions {
203+ /// Use strict host key checking
204+ pub strict_host_keys : bool ,
205+ /// SSH connection timeout in seconds
206+ pub connect_timeout : u32 ,
207+ /// Server alive interval in seconds
208+ pub server_alive_interval : u32 ,
209+ /// SSH log level
184210 pub log_level : String ,
185211 /// Additional SSH options as key-value pairs
186212 pub extra_options : Vec < ( String , String ) > ,
187- /// Suppress output to stdout/stderr (default: false)
188- pub suppress_output : bool ,
189213}
190214
191- impl Default for SshConnectionOptions {
215+ impl Default for CommonSshOptions {
192216 fn default ( ) -> Self {
193217 Self {
218+ strict_host_keys : false ,
194219 connect_timeout : 30 ,
195- allocate_tty : true ,
220+ server_alive_interval : 60 ,
196221 log_level : "ERROR" . to_string ( ) ,
197222 extra_options : vec ! [ ] ,
223+ }
224+ }
225+ }
226+
227+ impl CommonSshOptions {
228+ /// Apply these options to an SSH command
229+ pub fn apply_to_command ( & self , cmd : & mut std:: process:: Command ) {
230+ // Basic security options
231+ cmd. args ( [ "-o" , "IdentitiesOnly=yes" ] ) ;
232+ cmd. args ( [ "-o" , "PasswordAuthentication=no" ] ) ;
233+ cmd. args ( [ "-o" , "KbdInteractiveAuthentication=no" ] ) ;
234+ cmd. args ( [ "-o" , "GSSAPIAuthentication=no" ] ) ;
235+
236+ // Connection options
237+ cmd. args ( [ "-o" , & format ! ( "ConnectTimeout={}" , self . connect_timeout) ] ) ;
238+ cmd. args ( [
239+ "-o" ,
240+ & format ! ( "ServerAliveInterval={}" , self . server_alive_interval) ,
241+ ] ) ;
242+ cmd. args ( [ "-o" , & format ! ( "LogLevel={}" , self . log_level) ] ) ;
243+
244+ // Host key checking
245+ if !self . strict_host_keys {
246+ cmd. args ( [ "-o" , "StrictHostKeyChecking=no" ] ) ;
247+ cmd. args ( [ "-o" , "UserKnownHostsFile=/dev/null" ] ) ;
248+ }
249+
250+ // Add extra SSH options
251+ for ( key, value) in & self . extra_options {
252+ cmd. args ( [ "-o" , & format ! ( "{}={}" , key, value) ] ) ;
253+ }
254+ }
255+ }
256+
257+ impl Default for SshConnectionOptions {
258+ fn default ( ) -> Self {
259+ Self {
260+ common : CommonSshOptions :: default ( ) ,
261+ allocate_tty : true ,
198262 suppress_output : false ,
199263 }
200264 }
@@ -204,10 +268,14 @@ impl SshConnectionOptions {
204268 /// Create options suitable for quick connectivity tests (short timeout, no TTY)
205269 pub fn for_connectivity_test ( ) -> Self {
206270 Self {
207- connect_timeout : 2 ,
271+ common : CommonSshOptions {
272+ strict_host_keys : false ,
273+ connect_timeout : 2 ,
274+ server_alive_interval : 60 ,
275+ log_level : "ERROR" . to_string ( ) ,
276+ extra_options : vec ! [ ] ,
277+ } ,
208278 allocate_tty : false ,
209- log_level : "ERROR" . to_string ( ) ,
210- extra_options : vec ! [ ] ,
211279 suppress_output : true ,
212280 }
213281 }
@@ -251,25 +319,18 @@ pub fn connect_via_container_with_options(
251319 cmd. args ( [ "exec" , container_name, "ssh" ] ) ;
252320 }
253321
254- // SSH key and security options
322+ // SSH key
255323 let keypath = Utf8Path :: new ( "/run/tmproot" )
256324 . join ( CONTAINER_STATEDIR . trim_start_matches ( '/' ) )
257325 . join ( "ssh" ) ;
258326 cmd. args ( [ "-i" , keypath. as_str ( ) ] ) ;
259- cmd. args ( [ "-o" , "IdentitiesOnly=yes" ] ) ;
260- cmd. args ( [ "-o" , "PasswordAuthentication=no" ] ) ;
261- cmd. args ( [ "-o" , "KbdInteractiveAuthentication=no" ] ) ;
262- cmd. args ( [ "-o" , "GSSAPIAuthentication=no" ] ) ;
263- cmd. args ( [ "-o" , "StrictHostKeyChecking=no" ] ) ;
264- cmd. args ( [ "-o" , "UserKnownHostsFile=/dev/null" ] ) ;
265-
266- // Configurable options
267- cmd. args ( [ "-o" , & format ! ( "ConnectTimeout={}" , options. connect_timeout) ] ) ;
268- cmd. args ( [ "-o" , & format ! ( "LogLevel={}" , options. log_level) ] ) ;
269-
270- // Add extra SSH options
271- for ( key, value) in & options. extra_options {
272- cmd. args ( [ "-o" , & format ! ( "{}={}" , key, value) ] ) ;
327+
328+ // Apply common SSH options
329+ options. common . apply_to_command ( & mut cmd) ;
330+
331+ // Container SSH always uses batch mode for non-interactive commands
332+ if !options. allocate_tty {
333+ cmd. args ( [ "-o" , "BatchMode=yes" ] ) ;
273334 }
274335
275336 // Connect to VM via QEMU port forwarding on localhost
@@ -278,11 +339,31 @@ pub fn connect_via_container_with_options(
278339
279340 // Add any additional arguments
280341 if !args. is_empty ( ) {
342+ debug ! ( "Adding SSH arguments: {:?}" , args) ;
281343 cmd. arg ( "--" ) ;
282- cmd. args ( & args) ;
344+
345+ // If we have multiple arguments, we need to properly combine them into a single
346+ // command string that will survive shell parsing on the remote side.
347+ // This is because SSH protocol sends commands as strings, not argument arrays.
348+ if args. len ( ) > 1 {
349+ // Combine arguments with proper shell escaping
350+ let combined_command = shell_escape_command ( & args) ;
351+ debug ! ( "Combined escaped command: {}" , combined_command) ;
352+ cmd. arg ( combined_command) ;
353+ } else {
354+ // Single argument can be passed directly
355+ cmd. args ( & args) ;
356+ }
283357 }
284358
285359 debug ! ( "Executing: podman {:?}" , cmd. get_args( ) . collect:: <Vec <_>>( ) ) ;
360+ debug ! (
361+ "Full command line: podman {}" ,
362+ cmd. get_args( )
363+ . map( |s| s. to_string_lossy( ) . to_string( ) )
364+ . collect:: <Vec <_>>( )
365+ . join( " " )
366+ ) ;
286367
287368 // Suppress output if requested (useful for connectivity testing)
288369 if options. suppress_output {
@@ -336,36 +417,68 @@ mod tests {
336417 fn test_ssh_connection_options ( ) {
337418 // Test default options
338419 let default_opts = SshConnectionOptions :: default ( ) ;
339- assert_eq ! ( default_opts. connect_timeout, 30 ) ;
420+ assert_eq ! ( default_opts. common . connect_timeout, 30 ) ;
340421 assert ! ( default_opts. allocate_tty) ;
341- assert_eq ! ( default_opts. log_level, "ERROR" ) ;
342- assert ! ( default_opts. extra_options. is_empty( ) ) ;
422+ assert_eq ! ( default_opts. common . log_level, "ERROR" ) ;
423+ assert ! ( default_opts. common . extra_options. is_empty( ) ) ;
343424 assert ! ( !default_opts. suppress_output) ;
344425
345426 // Test connectivity test options
346427 let test_opts = SshConnectionOptions :: for_connectivity_test ( ) ;
347- assert_eq ! ( test_opts. connect_timeout, 2 ) ;
428+ assert_eq ! ( test_opts. common . connect_timeout, 2 ) ;
348429 assert ! ( !test_opts. allocate_tty) ;
349- assert_eq ! ( test_opts. log_level, "ERROR" ) ;
350- assert ! ( test_opts. extra_options. is_empty( ) ) ;
430+ assert_eq ! ( test_opts. common . log_level, "ERROR" ) ;
431+ assert ! ( test_opts. common . extra_options. is_empty( ) ) ;
351432 assert ! ( test_opts. suppress_output) ;
352433
353434 // Test custom options
354435 let mut custom_opts = SshConnectionOptions :: default ( ) ;
355- custom_opts. connect_timeout = 10 ;
436+ custom_opts. common . connect_timeout = 10 ;
356437 custom_opts. allocate_tty = false ;
357- custom_opts. log_level = "DEBUG" . to_string ( ) ;
438+ custom_opts. common . log_level = "DEBUG" . to_string ( ) ;
358439 custom_opts
440+ . common
359441 . extra_options
360442 . push ( ( "ServerAliveInterval" . to_string ( ) , "30" . to_string ( ) ) ) ;
361443
362- assert_eq ! ( custom_opts. connect_timeout, 10 ) ;
444+ assert_eq ! ( custom_opts. common . connect_timeout, 10 ) ;
363445 assert ! ( !custom_opts. allocate_tty) ;
364- assert_eq ! ( custom_opts. log_level, "DEBUG" ) ;
365- assert_eq ! ( custom_opts. extra_options. len( ) , 1 ) ;
446+ assert_eq ! ( custom_opts. common . log_level, "DEBUG" ) ;
447+ assert_eq ! ( custom_opts. common . extra_options. len( ) , 1 ) ;
366448 assert_eq ! (
367- custom_opts. extra_options[ 0 ] ,
449+ custom_opts. common . extra_options[ 0 ] ,
368450 ( "ServerAliveInterval" . to_string( ) , "30" . to_string( ) )
369451 ) ;
370452 }
453+
454+ #[ test]
455+ fn test_shell_escape_command ( ) {
456+ // Single argument
457+ assert_eq ! ( shell_escape_command( & [ "echo" . to_string( ) ] ) , "echo" ) ;
458+
459+ // Multiple simple arguments
460+ assert_eq ! (
461+ shell_escape_command( & [ "/bin/sh" . to_string( ) , "-c" . to_string( ) ] ) ,
462+ "/bin/sh -c"
463+ ) ;
464+
465+ // Arguments with special characters - shlex uses single quotes for POSIX compliance
466+ let result = shell_escape_command ( & [
467+ "/bin/sh" . to_string ( ) ,
468+ "-c" . to_string ( ) ,
469+ "echo hello; sleep 5; echo world" . to_string ( ) ,
470+ ] ) ;
471+ // Verify that the result properly escapes the command with semicolons
472+ assert ! ( result. starts_with( "/bin/sh -c " ) ) ;
473+ assert ! ( result. contains( "echo hello; sleep 5; echo world" ) ) ;
474+
475+ // Test that shlex properly handles quotes and spaces
476+ let result2 = shell_escape_command ( & [
477+ "echo" . to_string ( ) ,
478+ "hello world" . to_string ( ) ,
479+ "it's working" . to_string ( ) ,
480+ ] ) ;
481+ assert ! ( result2. contains( "hello world" ) ) ;
482+ assert ! ( result2. contains( "it's working" ) ) ;
483+ }
371484}
0 commit comments