From 45d050c0b32242b9bf0fa8094eca8011855fce2c Mon Sep 17 00:00:00 2001 From: Warren Date: Sat, 20 Jun 2026 16:40:29 +0800 Subject: [PATCH] P0: exit-status for subsystem, improved error msgs, integration test suite --- markbase-core/src/ssh_server/server.rs | 14 +- markbase-core/tests/integration_test.rs | 249 ++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 markbase-core/tests/integration_test.rs diff --git a/markbase-core/src/ssh_server/server.rs b/markbase-core/src/ssh_server/server.rs index df4a023..15aa284 100644 --- a/markbase-core/src/ssh_server/server.rs +++ b/markbase-core/src/ssh_server/server.rs @@ -106,7 +106,7 @@ impl SshServer { upload_hook_config_clone, ) { - error!("Connection error: {}", e); + error!("SSH connection error: {}", e); } }); } @@ -666,6 +666,10 @@ fn handle_ssh_service_loop( if !has_exec && packet.payload.len() >= 5 { let channel_id = u32::from_be_bytes([packet.payload[1], packet.payload[2], packet.payload[3], packet.payload[4]]); + // ⭐⭐⭐⭐⭐ P0: Send exit-status 0 for subsystem channels + let exit_status_packet = channel_manager.build_channel_exit_status(channel_id, 0)?; + let encrypted_exit = EncryptedPacket::new(&exit_status_packet.payload, encryption_ctx, true)?; + encrypted_exit.write(stream)?; let close_packet = channel_manager.build_channel_close(channel_id)?; let encrypted_response = EncryptedPacket::new(&close_packet.payload, encryption_ctx, true)?; @@ -673,7 +677,10 @@ fn handle_ssh_service_loop( } } Some(&pt) if pt == PacketType::SSH_MSG_DISCONNECT as u8 => { - info!("Received SSH_MSG_DISCONNECT"); + let reason_code = if packet.payload.len() >= 5 { + u32::from_be_bytes([packet.payload[1], packet.payload[2], packet.payload[3], packet.payload[4]]) + } else { 0 }; + info!("Received SSH_MSG_DISCONNECT (reason={})", reason_code); break; } Some(&pt) if pt == PacketType::SSH_MSG_CHANNEL_WINDOW_ADJUST as u8 => { @@ -688,7 +695,8 @@ fn handle_ssh_service_loop( } } _ => { - warn!("Unknown packet type: {:?}", packet.payload.first()); + let pt = packet.payload.first().copied().unwrap_or(0); + warn!("Unknown/unhandled packet type: {} (0x{:02x}), payload_len={}", pt, pt, packet.payload.len()); } } diff --git a/markbase-core/tests/integration_test.rs b/markbase-core/tests/integration_test.rs new file mode 100644 index 0000000..01049e5 --- /dev/null +++ b/markbase-core/tests/integration_test.rs @@ -0,0 +1,249 @@ +use std::path::PathBuf; +use std::process::{Child, Command, Output}; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +/// Find a free TCP port by binding to port 0 +fn find_free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +fn get_binary_path() -> PathBuf { + // For integration tests, cargo builds the binary at CARGO_BIN_EXE_ + if let Ok(path) = std::env::var("CARGO_BIN_EXE_markbase-core") { + return PathBuf::from(path); + } + // Fallback: look relative to manifest dir + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR not set"); + let candidate = PathBuf::from(&manifest_dir) + .parent() + .expect("No parent of manifest dir") + .join("target") + .join("debug") + .join("markbase-core"); + if candidate.exists() { + return candidate; + } + panic!("Cannot find markbase-core binary at {:?}. Build with: cargo build -p markbase-core", candidate); +} + +fn start_ssh_server(port: u16) -> Child { + let binary = get_binary_path(); + let log_file = std::env::temp_dir().join(format!("ssh_server_{}.log", port)); + let log_file_handle = std::fs::File::create(&log_file) + .expect("Failed to create server log file"); + eprintln!("SSH server log: {:?}", log_file); + // Server uses relative path "data/auth.sqlite" from project root + let project_root = std::env::var("CARGO_MANIFEST_DIR") + .map(|d| PathBuf::from(d).parent().unwrap().to_path_buf()) + .unwrap_or_else(|_| PathBuf::from(".")); + let child = Command::new(&binary) + .args(["ssh-start", "--port", &port.to_string()]) + .current_dir(&project_root) + .env("RUST_LOG", "info") + .stdout(log_file_handle.try_clone().unwrap()) + .stderr(log_file_handle) + .spawn() + .expect("Failed to start SSH server"); + child +} + +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + let start = Instant::now(); + while start.elapsed() < timeout { + match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(1000)) { + Ok(stream) => { + // Connection accepted — verify server is ready by trying a small read + let _ = stream.set_read_timeout(Some(Duration::from_millis(200))); + return true; + } + Err(_) => { + sleep(Duration::from_millis(300)); + } + } + } + false +} + +fn run_ssh(port: u16, cmd: &str) -> Output { + let port_str = port.to_string(); + let host = format!("demo@127.0.0.1"); + let output = Command::new("sshpass") + .args(["-p", "demo123", "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "PreferredAuthentications=password", + "-p", &port_str, + &host, + cmd]) + .output(); + + match output { + Ok(o) => o, + Err(e) => { + panic!("Failed to run ssh command: {} (is sshpass installed?)", e); + } + } +} + +fn run_scp(port: u16, src: &str, dst: &str, legacy: bool) -> Output { + let port_str = port.to_string(); + let mut args = vec!["-P", &port_str]; + if legacy { + args.push("-O"); + } + args.extend_from_slice(&[ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + src, + dst, + ]); + + let output = if cfg!(target_os = "linux") || cfg!(target_os = "macos") { + let mut sshpass_args = vec!["-p", "demo123", "scp", "-P", &port_str]; + if legacy { + sshpass_args.push("-O"); + } + sshpass_args.extend_from_slice(&[ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + src, + dst, + ]); + Command::new("sshpass").args(&sshpass_args).output() + } else { + Command::new("scp").args(&args).output() + }; + + match output { + Ok(o) => o, + Err(e) => { + panic!("Failed to run scp command: {} (is sshpass installed?)", e); + } + } +} + +/// Test SSH exec with exit code +#[test] +#[ignore = "requires sshpass and a running SSH server"] +fn test_ssh_exec_exit_code() { + let port = find_free_port(); + let mut server = start_ssh_server(port); + assert!(wait_for_port(port, Duration::from_secs(15)), "Server failed to start"); + + // Test exit code 42 + let output = run_ssh(port, "exit 42"); + assert_eq!(output.status.code(), Some(42), "Expected exit code 42, got {:?}", output.status.code()); + + // Test exit code 0 (default) + let output = run_ssh(port, "echo hello"); + assert_eq!(output.status.code(), Some(0), "Expected exit code 0, got {:?}", output.status.code()); + assert!(String::from_utf8_lossy(&output.stdout).contains("hello"), "Expected hello in output"); + + let _ = server.kill(); + let _ = server.wait(); +} + +/// Test SCP legacy file transfer +#[test] +#[ignore = "requires sshpass and a running SSH server"] +fn test_scp_legacy_transfer() { + let port = find_free_port(); + let mut server = start_ssh_server(port); + assert!(wait_for_port(port, Duration::from_secs(15)), "Server failed to start"); + + let tmp_dir = std::env::temp_dir(); + let src_path = tmp_dir.join("scp_legacy_src.txt"); + let content = "legacy scp test data\nline 2\n"; + std::fs::write(&src_path, content).unwrap(); + + let dst = format!("demo@127.0.0.1:scp_legacy_dst.txt"); + let output = run_scp(port, src_path.to_str().unwrap(), &dst, true); + assert!(output.status.success(), "SCP legacy failed: {}", String::from_utf8_lossy(&output.stderr)); + + // Verify file content on server + let home = PathBuf::from("/Users/accusys/momentry/var/sftpgo/data/demo"); + let dst_path = home.join("scp_legacy_dst.txt"); + let result = std::fs::read_to_string(&dst_path); + assert!(result.is_ok(), "Remote file not found: {:?}", dst_path); + assert_eq!(result.unwrap(), content, "File content mismatch"); + + let _ = std::fs::remove_file(&dst_path); + let _ = server.kill(); + let _ = server.wait(); +} + +/// Test SCP modern (SFTP protocol) file transfer +#[test] +#[ignore = "requires sshpass and a running SSH server"] +fn test_scp_modern_transfer() { + let port = find_free_port(); + let mut server = start_ssh_server(port); + assert!(wait_for_port(port, Duration::from_secs(15)), "Server failed to start"); + + let tmp_dir = std::env::temp_dir(); + let src_path = tmp_dir.join("scp_modern_src.txt"); + let content = "modern scp test data\nanother line\nand another\n"; + std::fs::write(&src_path, content).unwrap(); + + let dst = format!("demo@127.0.0.1:scp_modern_dst.txt"); + let output = run_scp(port, src_path.to_str().unwrap(), &dst, false); + assert!(output.status.success(), "SCP modern failed: {}", String::from_utf8_lossy(&output.stderr)); + + // Verify file content on server + let home = PathBuf::from("/Users/accusys/momentry/var/sftpgo/data/demo"); + let dst_path = home.join("scp_modern_dst.txt"); + let result = std::fs::read_to_string(&dst_path); + assert!(result.is_ok(), "Remote file not found: {:?}", dst_path); + assert_eq!(result.unwrap(), content, "File content mismatch"); + + let _ = std::fs::remove_file(&dst_path); + let _ = server.kill(); + let _ = server.wait(); +} + +/// Test both SCP modes + exec in sequence +#[test] +#[ignore = "requires sshpass and a running SSH server"] +fn test_full_workflow() { + let port = find_free_port(); + let mut server = start_ssh_server(port); + assert!(wait_for_port(port, Duration::from_secs(15)), "Server failed to start"); + + // 1. Exec test + let output = run_ssh(port, "uname -a"); + assert!(output.status.success(), "Exec test failed"); + + // 2. Legacy SCP + let tmp_dir = std::env::temp_dir(); + let src_path = tmp_dir.join("wf_scp_src.txt"); + std::fs::write(&src_path, "full workflow test").unwrap(); + let dst = format!("demo@127.0.0.1:wf_scp_dst.txt"); + let output = run_scp(port, src_path.to_str().unwrap(), &dst, true); + assert!(output.status.success(), "Legacy SCP in workflow failed"); + + // 3. Modern SCP + let src_path2 = tmp_dir.join("wf_scp_modern_src.txt"); + std::fs::write(&src_path2, "modern workflow test").unwrap(); + let dst2 = format!("demo@127.0.0.1:wf_scp_modern_dst.txt"); + let output = run_scp(port, src_path2.to_str().unwrap(), &dst2, false); + assert!(output.status.success(), "Modern SCP in workflow failed"); + + // 4. Verify files + let home = PathBuf::from("/Users/accusys/momentry/var/sftpgo/data/demo"); + for f in ["wf_scp_dst.txt", "wf_scp_modern_dst.txt"] { + let p = home.join(f); + assert!(p.exists(), "File {} not found", f); + let _ = std::fs::remove_file(&p); + } + + let _ = server.kill(); + let _ = server.wait(); +}