// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Configuration for the SFTP server. //! //! Loads bind address, host key directory, and operational parameters from //! the RUSTFS_SFTP_* environment variables. Validates the configuration and //! loads host keys from the configured directory at startup. //! //! Validation bounds and defaults (part-size, handles-per-session, //! backend-op-timeout, read-cache window and total-memory) are pulled //! from constants::limits. use super::constants::limits::{ BACKEND_OP_TIMEOUT_MAX_SECS, BACKEND_OP_TIMEOUT_MIN_SECS, DEFAULT_BACKEND_OP_TIMEOUT_SECS, DEFAULT_HANDLES_PER_SESSION, HANDLES_PER_SESSION_MAX, HANDLES_PER_SESSION_MIN, READ_CACHE_DISABLED, READ_CACHE_TOTAL_MEM_DEFAULT, READ_CACHE_TOTAL_MEM_MIN, READ_CACHE_WINDOW_DEFAULT, READ_CACHE_WINDOW_MAX, READ_CACHE_WINDOW_MIN, S3_MAX_PART_SIZE, S3_MIN_PART_SIZE, }; #[cfg(any(unix, windows))] use russh::keys::PublicKeyBase64; use std::net::SocketAddr; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use thiserror::Error; const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SFTP_CONFIG: &str = "sftp_config"; const EVENT_SFTP_CONFIG_STATE: &str = "sftp_config_state"; const EVENT_SFTP_HOST_KEY_STATE: &str = "sftp_host_key_state"; /// Upper bound on file size accepted as a candidate host key (1 MiB). /// Guards against accidentally reading huge non-key files in the host /// key directory. Real keys are well under 10 KiB. const MAX_HOST_KEY_FILE_SIZE: u64 = 1024 * 1024; /// PEM pre-encapsulation boundary marker prefix per RFC 7468 section 3. /// The textual encoding is exactly five hyphens, the literal "BEGIN", a /// space, the label, and five more hyphens. Used to distinguish a file /// that looks like a private key but failed to decode (passphrase, corrupt) /// from a file that is genuinely something else (a .pub key, a README). const PEM_BEGIN_MARKER: &str = "-----BEGIN"; /// Errors that can occur during SFTP server initialization. #[derive(Debug, Error)] pub enum SftpInitError { /// RUSTFS_SFTP_HOST_KEY_DIR was not set when SFTP was enabled. /// Operators must point this variable at a directory containing /// at least one persistent host key. #[error("RUSTFS_SFTP_HOST_KEY_DIR is required when SFTP is enabled")] HostKeyDirNotSet, /// The host-key directory does not exist or its metadata cannot /// be read. Includes the underlying io error for diagnosis. #[error("host key directory does not exist or is not readable: {path}: {source}")] HostKeyDirUnreadable { path: PathBuf, source: std::io::Error }, /// A host-key file in the directory has group or other permission /// bits set. The mode must grant no access beyond the owner so a /// local non-root user cannot impersonate the SFTP server. #[error("host key file has insecure permissions {mode:#o}: {path} (group and other permission bits must be unset)")] InsecureHostKeyPermissions { path: PathBuf, mode: u32 }, /// The host-key directory contained no decodable private keys. /// Operators must place at least one ed25519 / ECDSA / RSA-SHA256 /// private key with owner-only permissions in the directory before startup. #[error("no valid host keys found in {path}")] NoHostKeysFound { path: PathBuf }, /// The SftpConfig validate() check failed. Carries a human-readable /// reason; the wrapping caller logs the full string. #[error("invalid SFTP configuration: {0}")] InvalidConfig(String), /// The run loop in russh::server::run returned an error during /// startup, before the listener became ready. Wraps the russh error /// string. #[error("SSH server error: {0}")] Server(String), /// The host running the binary is neither a Unix-family target nor /// Windows. Unix enforces owner-only host-key mode bits and Windows /// trusts operator-managed NTFS ACLs, but neither mechanism exists on /// other targets, so SFTP refuses to start rather than load host keys /// with weaker guarantees. #[error("SFTP requires a Unix-family or Windows host (current OS: {os})")] UnsupportedPlatform { os: String }, } /// Runtime configuration for the SFTP listener. #[derive(Debug, Clone)] pub struct SftpConfig { /// Address that the SSH listener binds to. pub bind_addr: SocketAddr, /// Directory containing host key files. pub host_key_dir: PathBuf, /// Idle session timeout in seconds. pub idle_timeout_secs: u64, /// S3 multipart part size in bytes. Drives the flush boundary in /// the streaming write path and the single-upload size ceiling /// (part_size * 10_000, the S3 parts cap). The 16 MiB default /// caps a single upload at 160 GiB; raise to reach S3's 5 TiB /// per-object limit. Validated against S3_MIN_PART_SIZE and /// S3_MAX_PART_SIZE bounds. pub part_size: u64, /// Maximum simultaneously-open SFTP handles per session. A handle /// is the server-side identifier returned by SSH_FXP_OPEN and /// SSH_FXP_OPENDIR. Some(n) honours the operator override after /// validating against HANDLES_PER_SESSION_MIN (8) and /// HANDLES_PER_SESSION_MAX (1024). None means no override. The /// driver uses DEFAULT_HANDLES_PER_SESSION (64). Out-of-range /// values supplied via RUSTFS_SFTP_HANDLES_PER_SESSION resolve to /// None with a warn log. See SftpConfig::resolve_handles_per_session. pub handles_per_session: Option, /// Per-call deadline applied to every StorageBackend invocation /// the SFTP driver issues. Some(n) honours the operator override /// after validating against BACKEND_OP_TIMEOUT_MIN_SECS (5) and /// BACKEND_OP_TIMEOUT_MAX_SECS (600). None means no override. The /// driver uses DEFAULT_BACKEND_OP_TIMEOUT_SECS (60). Out-of-range /// values supplied via RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS resolve /// to None with a warn log. See /// SftpConfig::resolve_backend_op_timeout_secs. pub backend_op_timeout_secs: Option, /// Per-handle read cache window size in bytes. Some(0) is the /// READ_CACHE_DISABLED sentinel and turns the cache off entirely. /// Some(n) for any other value honours the operator override /// after validating against READ_CACHE_WINDOW_MIN (MAX_READ_LEN, /// 256 KiB) and READ_CACHE_WINDOW_MAX (64 MiB). None means no /// override. The driver uses READ_CACHE_WINDOW_DEFAULT (4 MiB). /// Out-of-range non-zero values supplied via /// RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES resolve to None with a warn /// log. See SftpConfig::resolve_read_cache_window_bytes. pub read_cache_window_bytes: Option, /// Process-wide ceiling on cumulative read cache memory across /// every live SFTP handle. Some(n) honours the operator override /// after validating against READ_CACHE_TOTAL_MEM_MIN (16 MiB) and /// the u64 ceiling. None means no override. The driver uses /// READ_CACHE_TOTAL_MEM_DEFAULT (256 MiB). Below-min values /// supplied via RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES resolve to /// None with a warn log. See /// SftpConfig::resolve_read_cache_total_mem_bytes. pub read_cache_total_mem_bytes: Option, /// Reject all write operations when true. pub read_only: bool, /// SSH identification string (must start with SSH-2.0-). pub banner: String, } impl SftpConfig { /// Validate configuration values. /// /// Host key directory existence and key loading are validated separately /// in load_host_keys, which runs after this check. pub async fn validate(&self) -> Result<(), SftpInitError> { if !self.banner.starts_with("SSH-2.0-") { return Err(SftpInitError::InvalidConfig("banner must start with SSH-2.0-".to_string())); } if self.idle_timeout_secs == 0 { return Err(SftpInitError::InvalidConfig("idle timeout must be greater than zero".to_string())); } if self.part_size < S3_MIN_PART_SIZE { return Err(SftpInitError::InvalidConfig(format!( "part size must be at least {S3_MIN_PART_SIZE} bytes ({} MiB)", S3_MIN_PART_SIZE / (1024 * 1024) ))); } if self.part_size > S3_MAX_PART_SIZE { return Err(SftpInitError::InvalidConfig(format!( "part size must not exceed {S3_MAX_PART_SIZE} bytes ({} GiB)", S3_MAX_PART_SIZE / (1024 * 1024 * 1024) ))); } // The drain index in write_dispatch_flush_one_part casts // part_size to usize. Reject configurations where the cast // would truncate (only reachable on 32-bit targets) so the // truncation cannot fire silently mid-upload. if usize::try_from(self.part_size).is_err() { return Err(SftpInitError::InvalidConfig(format!( "part size {} exceeds usize on this target; rebuild on 64-bit or lower part_size", self.part_size ))); } Ok(()) } /// Resolve the handles_per_session value from a raw env-var read. /// None passes through unchanged. Some(n) is returned unchanged /// when n is in the inclusive range /// HANDLES_PER_SESSION_MIN..=HANDLES_PER_SESSION_MAX. Out-of-range /// inputs return None and log a warning naming the requested /// value and the bounds. The driver applies /// DEFAULT_HANDLES_PER_SESSION when the value is None. pub fn resolve_handles_per_session(raw: Option) -> Option { match raw { None => None, Some(n) if (HANDLES_PER_SESSION_MIN..=HANDLES_PER_SESSION_MAX).contains(&n) => Some(n), Some(n) => { tracing::warn!( event = EVENT_SFTP_CONFIG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, requested = n, min = HANDLES_PER_SESSION_MIN, max = HANDLES_PER_SESSION_MAX, default = DEFAULT_HANDLES_PER_SESSION, result = "handles_per_session_out_of_range", "sftp config state changed", ); None } } } /// Resolve the backend_op_timeout_secs value from a raw env-var /// read. None passes through unchanged. Some(n) is returned /// unchanged when n is in the inclusive range /// BACKEND_OP_TIMEOUT_MIN_SECS..=BACKEND_OP_TIMEOUT_MAX_SECS. /// Out-of-range inputs return None and log a warning naming the /// requested value and the bounds. The driver applies /// DEFAULT_BACKEND_OP_TIMEOUT_SECS when the value is None. pub fn resolve_backend_op_timeout_secs(raw: Option) -> Option { match raw { None => None, Some(n) if (BACKEND_OP_TIMEOUT_MIN_SECS..=BACKEND_OP_TIMEOUT_MAX_SECS).contains(&n) => Some(n), Some(n) => { tracing::warn!( event = EVENT_SFTP_CONFIG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, requested = n, min = BACKEND_OP_TIMEOUT_MIN_SECS, max = BACKEND_OP_TIMEOUT_MAX_SECS, default = DEFAULT_BACKEND_OP_TIMEOUT_SECS, result = "backend_timeout_out_of_range", "sftp config state changed", ); None } } } /// Resolve the read_cache_window_bytes value from a raw env-var /// read. None passes through unchanged. Some(0) is the /// READ_CACHE_DISABLED sentinel: the driver short-circuits the /// populate path so reads do not retain any buffer between /// FXP_READs. Some(n) where n is in the inclusive range /// READ_CACHE_WINDOW_MIN..=READ_CACHE_WINDOW_MAX is returned /// unchanged. Other values return None and log a warning /// naming the requested value and the bounds. The driver applies /// READ_CACHE_WINDOW_DEFAULT when the value is None. pub fn resolve_read_cache_window_bytes(raw: Option) -> Option { match raw { None => None, Some(READ_CACHE_DISABLED) => Some(READ_CACHE_DISABLED), Some(n) if (READ_CACHE_WINDOW_MIN..=READ_CACHE_WINDOW_MAX).contains(&n) => Some(n), Some(n) => { tracing::warn!( event = EVENT_SFTP_CONFIG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, requested = n, min = READ_CACHE_WINDOW_MIN, max = READ_CACHE_WINDOW_MAX, default = READ_CACHE_WINDOW_DEFAULT, result = "read_cache_window_out_of_range", "sftp config state changed", ); None } } } /// Resolve the read_cache_total_mem_bytes value from a raw env-var /// read. None passes through unchanged. Some(n) is returned /// unchanged when n is at or above READ_CACHE_TOTAL_MEM_MIN. /// Below-min inputs return None and log a warning naming the /// requested value and the bound. The driver applies /// READ_CACHE_TOTAL_MEM_DEFAULT when the value is None. pub fn resolve_read_cache_total_mem_bytes(raw: Option) -> Option { match raw { None => None, Some(n) if n >= READ_CACHE_TOTAL_MEM_MIN => Some(n), Some(n) => { tracing::warn!( event = EVENT_SFTP_CONFIG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, requested = n, min = READ_CACHE_TOTAL_MEM_MIN, default = READ_CACHE_TOTAL_MEM_DEFAULT, result = "read_cache_total_mem_below_minimum", "sftp config state changed", ); None } } } /// Scan RUSTFS_SFTP_HOST_KEY_DIR and load all valid SSH private keys. /// /// Host keys identify the server. Each file in the directory is a /// private key (e.g. generated by ssh-keygen). Clients record the /// corresponding public key on first connect and verify it on subsequent /// connections to prevent man-in-the-middle attacks. /// /// Fails startup if the directory cannot be read, if any key file has /// group or world permission bits set (hard error), or if zero valid /// keys are found after scanning. /// /// There is no in-memory key generation fallback. A fresh key per /// restart produces spurious host-key-changed warnings that /// undermine the MITM defence. /// /// The PrivateKey type from ssh-key implements Zeroize on drop, /// so key material is scrubbed at server shutdown. The PEM string /// read from disk is a regular String and is not zeroed; this /// matches the secret handling in the existing S3 and FTPS auth /// paths. /// /// On Unix the loader enforces owner-only mode bits (group and other /// must have no permission). On Windows it loads without a mode check /// and trusts operator-managed NTFS ACLs, matching the convention the /// other secret-bearing subsystems use. Targets that are neither Unix /// nor Windows return SftpInitError::UnsupportedPlatform. #[cfg(any(unix, windows))] pub async fn load_host_keys(host_key_dir: &Path) -> Result, SftpInitError> { #[cfg(windows)] warn_once_windows(host_key_dir); let mut entries = tokio::fs::read_dir(host_key_dir) .await .map_err(|e| SftpInitError::HostKeyDirUnreadable { path: host_key_dir.to_path_buf(), source: e, })?; let mut keys = Vec::new(); while let Some(entry) = entries.next_entry().await.map_err(|e| SftpInitError::HostKeyDirUnreadable { path: host_key_dir.to_path_buf(), source: e, })? { let path = entry.path(); let metadata = match tokio::fs::metadata(&path).await { Ok(m) => m, Err(e) => { tracing::warn!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), err = %e, result = "stat_failed", "sftp host key state changed" ); continue; } }; if !metadata.is_file() { continue; } // Skip empty files and files too large to be valid keys. let file_size = metadata.len(); if file_size == 0 || file_size > MAX_HOST_KEY_FILE_SIZE { tracing::debug!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), size = file_size, result = "size_out_of_range", "sftp host key state changed" ); continue; } // A world-readable private key lets any local user impersonate the // SFTP server, the same restriction OpenSSH enforces. Platform // behaviour is documented on check_host_key_permissions. check_host_key_permissions(&path, &metadata)?; let data = match tokio::fs::read_to_string(&path).await { Ok(d) => d, Err(e) => { tracing::warn!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), err = %e, result = "read_failed", "sftp host key state changed" ); continue; } }; match russh::keys::decode_secret_key(&data, None) { Ok(key) => { tracing::info!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), algorithm = ?key.algorithm(), state = "loaded", "sftp host key state changed" ); keys.push(key); } Err(e) => { // Distinguish two cases: // 1. The file is genuinely not a private key (a // .pub file, README, etc). Debug log and skip. // 2. The file looks like a private key but failed // to decode (passphrase-protected, corrupted). // Warn so the operator has the failed-decode // reason in the log. if data.contains(PEM_BEGIN_MARKER) { tracing::warn!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), err = %e, result = "decode_failed", "sftp host key state changed" ); } else { tracing::debug!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, path = %path.display(), err = %e, result = "not_a_private_key", "sftp host key state changed" ); } } } } if keys.is_empty() { return Err(SftpInitError::NoHostKeysFound { path: host_key_dir.to_path_buf(), }); } // Sort keys by algorithm preference, then by public key bytes // for deterministic ordering within the same algorithm. // russh offers keys to clients in array order during key exchange. keys.sort_by(|left, right| { let left_rank = match left.algorithm() { russh::keys::Algorithm::Ed25519 => 0, russh::keys::Algorithm::Ecdsa { .. } => 1, russh::keys::Algorithm::Rsa { .. } => 2, _ => 3, }; let right_rank = match right.algorithm() { russh::keys::Algorithm::Ed25519 => 0, russh::keys::Algorithm::Ecdsa { .. } => 1, russh::keys::Algorithm::Rsa { .. } => 2, _ => 3, }; left_rank .cmp(&right_rank) .then_with(|| left.public_key_bytes().cmp(&right.public_key_bytes())) }); tracing::info!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, count = keys.len(), dir = %host_key_dir.display(), state = "load_complete", "sftp host key state changed" ); Ok(keys) } /// Targets that are neither Unix nor Windows are unsupported. Returns /// SftpInitError::UnsupportedPlatform without reading the directory. #[cfg(not(any(unix, windows)))] pub async fn load_host_keys(_host_key_dir: &Path) -> Result, SftpInitError> { Err(SftpInitError::UnsupportedPlatform { os: std::env::consts::OS.to_string(), }) } } /// Log the Windows operator-facing host-key warning at most once per /// process. rustfs trusts operator-managed NTFS ACLs on Windows, the same /// convention the other secret-bearing subsystems use. #[cfg(windows)] fn warn_once_windows(host_key_dir: &Path) { use std::sync::Once; static WARN: Once = Once::new(); WARN.call_once(|| { tracing::warn!( event = EVENT_SFTP_HOST_KEY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_CONFIG, host_key_dir = %host_key_dir.display(), result = "windows_acl_operator_managed", "sftp host key state changed" ); }); } /// Per-file host-key permission check. Unix enforces owner-only mode bits /// (group and other must have no permission). Windows performs no check and /// trusts operator-managed NTFS ACLs. #[cfg(any(unix, windows))] #[cfg_attr(not(unix), allow(unused_variables))] fn check_host_key_permissions(path: &Path, metadata: &std::fs::Metadata) -> Result<(), SftpInitError> { #[cfg(unix)] { let mode = metadata.permissions().mode() & 0o777; if mode & 0o077 != 0 { return Err(SftpInitError::InsecureHostKeyPermissions { path: path.to_path_buf(), mode, }); } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use tempfile::TempDir; // PEM boundary markers (RFC 7468 five-hyphen / BEGIN-or-END / // label / five-hyphen) are composed at runtime by build_pem_block // so the source file contains no contiguous private-key marker that // secret scanners would flag. Throwaway test-vector keys. #[cfg(any(unix, windows))] const PEM_BOUNDARY_DASHES: &str = "-----"; #[cfg(any(unix, windows))] const PEM_OPENSSH_LABEL: &str = "OPENSSH PRIVATE KEY"; /// Wrap a base64 body in the OpenSSH-format PEM boundary markers. /// The boundary string is composed at runtime from PEM_BOUNDARY_DASHES /// and PEM_OPENSSH_LABEL so the source file does not contain the full /// marker as a contiguous literal. #[cfg(any(unix, windows))] fn build_pem_block(body: &str) -> String { format!("{d}BEGIN {l}{d}\n{body}\n{d}END {l}{d}\n", d = PEM_BOUNDARY_DASHES, l = PEM_OPENSSH_LABEL,) } #[cfg(any(unix, windows))] fn test_ed25519_pem() -> String { // Throwaway Ed25519 private key, no passphrase. build_pem_block( "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\ QyNTUxOQAAACCkeMEUpnJEbOMBXiQfjZcHZMEbHW3DlNRL+Jbi1cIqMgAAAKDviRiQ74kY\n\ kAAAAAtzc2gtZWQyNTUxOQAAACCkeMEUpnJEbOMBXiQfjZcHZMEbHW3DlNRL+Jbi1cIqMg\n\ AAAEBb5q0DpuL1Rbx4CHUEaRQRSVn1xS2SF+A+qES7OkhrOKR4wRSmckRs4wFeJB+Nlwdk\n\ wRsdbcOU1Ev4luLVwioyAAAAGHNpbW9uc0B1YnVudHUtbGludXgtMjQwNAECAwQF", ) } #[cfg(unix)] fn test_ecdsa_pem() -> String { // ECDSA P-256 fixture key for the algorithm-preference sort // test. Not passphrase-protected. build_pem_block( "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS\n\ 1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSBp+cYoqTsQzIF+eQS23gIOBFkIqhi\n\ M8u54NeDrEyxKSewEHP+5i6/+1HURUWDnW+YfS6nbfGb8GxBkJ2ghVvZAAAAqPpS97P6Uv\n\ ezAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIGn5xiipOxDMgX5\n\ 5BLbeAg4EWQiqGIzy7ng14OsTLEpJ7AQc/7mLr/7UdRFRYOdb5h9Lqdt8ZvwbEGQnaCFW9\n\ kAAAAgBdQn3JuP2lSrY3082L+jmYvESyPu9bSmzUe8yMuILzIAAAALdGVzdC12ZWN0b3IB\n\ AgMEBQ==", ) } fn typical_config() -> SftpConfig { SftpConfig { bind_addr: "0.0.0.0:2222".parse().unwrap(), host_key_dir: PathBuf::from("/tmp/sftp-host-keys"), idle_timeout_secs: 600, part_size: 16 * 1024 * 1024, handles_per_session: None, backend_op_timeout_secs: None, read_cache_window_bytes: None, read_cache_total_mem_bytes: None, read_only: false, banner: "SSH-2.0-RustFS".to_string(), } } /// Write a file at the given path with the given content and mode. #[cfg(unix)] fn write_file_with_mode(path: &Path, content: &str, mode: u32) { let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true).mode(mode); let mut file = opts.open(path).expect("open file"); std::io::Write::write_all(&mut file, content.as_bytes()).expect("write file"); } #[tokio::test] async fn validate_accepts_typical_config() { let cfg = typical_config(); assert!(cfg.validate().await.is_ok()); } #[tokio::test] async fn validate_rejects_banner_without_ssh_2_0_prefix() { let mut cfg = typical_config(); cfg.banner = "RustFS".to_string(); let err = cfg.validate().await.expect_err("banner must be rejected"); assert!(matches!(err, SftpInitError::InvalidConfig(_))); assert!(format!("{err}").contains("banner")); } #[tokio::test] async fn validate_rejects_zero_idle_timeout() { let mut cfg = typical_config(); cfg.idle_timeout_secs = 0; let err = cfg.validate().await.expect_err("zero idle timeout must be rejected"); assert!(matches!(err, SftpInitError::InvalidConfig(_))); assert!(format!("{err}").contains("idle timeout")); } #[tokio::test] async fn validate_rejects_zero_part_size() { let mut cfg = typical_config(); cfg.part_size = 0; let err = cfg.validate().await.expect_err("zero part size must be rejected"); assert!(matches!(err, SftpInitError::InvalidConfig(_))); assert!(format!("{err}").contains("part size")); } #[tokio::test] async fn validate_rejects_part_size_below_min() { let mut cfg = typical_config(); cfg.part_size = S3_MIN_PART_SIZE - 1; let err = cfg.validate().await.expect_err("sub-minimum part size must be rejected"); assert!(matches!(err, SftpInitError::InvalidConfig(_))); assert!(format!("{err}").contains("part size")); } #[tokio::test] async fn validate_accepts_part_size_at_minimum() { let mut cfg = typical_config(); cfg.part_size = S3_MIN_PART_SIZE; assert!(cfg.validate().await.is_ok()); } #[tokio::test] async fn validate_accepts_part_size_at_maximum() { let mut cfg = typical_config(); cfg.part_size = S3_MAX_PART_SIZE; assert!(cfg.validate().await.is_ok()); } #[tokio::test] async fn validate_rejects_part_size_above_max() { let mut cfg = typical_config(); cfg.part_size = S3_MAX_PART_SIZE + 1; let err = cfg.validate().await.expect_err("above-max part size must be rejected"); assert!(matches!(err, SftpInitError::InvalidConfig(_))); assert!(format!("{err}").contains("part size")); } #[test] fn error_display_does_not_leak_secrets() { // None of the SftpInitError variants carry secret material in their // display output. The fields are: paths, raw mode bits, std::io::Error // messages, and free-form descriptive strings. This locks that in. let err = SftpInitError::InvalidConfig("idle timeout must be greater than zero".to_string()); let display = format!("{err}"); assert!(!display.is_empty()); } #[tokio::test] #[cfg(any(unix, windows))] async fn load_host_keys_fails_when_dir_missing() { let path = PathBuf::from("/this/path/does/not/exist/sftp-host-keys"); let err = SftpConfig::load_host_keys(&path).await.expect_err("missing dir must error"); assert!(matches!(err, SftpInitError::HostKeyDirUnreadable { .. })); } #[tokio::test] #[cfg(any(unix, windows))] async fn load_host_keys_fails_when_dir_empty() { let dir = TempDir::new().expect("tempdir"); let err = SftpConfig::load_host_keys(dir.path()) .await .expect_err("empty dir must error"); assert!(matches!(err, SftpInitError::NoHostKeysFound { .. })); } #[tokio::test] #[cfg(windows)] async fn load_host_keys_loads_valid_ed25519_on_windows() { let dir = TempDir::new().expect("tempdir"); let key_path = dir.path().join("ssh_host_ed25519_key"); // Windows does not enforce mode bits. The host key loads // regardless of the inherited tempdir ACL. std::fs::write(&key_path, test_ed25519_pem()).expect("write key"); let keys = SftpConfig::load_host_keys(dir.path()) .await .expect("valid ed25519 key must load on Windows"); assert_eq!(keys.len(), 1); } #[tokio::test] #[cfg(unix)] async fn load_host_keys_rejects_insecure_permissions() { let dir = TempDir::new().expect("tempdir"); let key_path = dir.path().join("ssh_host_ed25519_key"); // 0o644 has world-readable bit set: must be rejected. write_file_with_mode(&key_path, &test_ed25519_pem(), 0o644); let err = SftpConfig::load_host_keys(dir.path()) .await .expect_err("insecure perms must error"); match err { SftpInitError::InsecureHostKeyPermissions { mode, .. } => { assert_eq!(mode & 0o777, 0o644); } other => panic!("expected InsecureHostKeyPermissions, got {other:?}"), } } #[tokio::test] #[cfg(unix)] async fn load_host_keys_loads_one_valid_ed25519_key() { let dir = TempDir::new().expect("tempdir"); let key_path = dir.path().join("ssh_host_ed25519_key"); write_file_with_mode(&key_path, &test_ed25519_pem(), 0o600); let keys = SftpConfig::load_host_keys(dir.path()).await.expect("valid key must load"); assert_eq!(keys.len(), 1); assert!(matches!(keys[0].algorithm(), russh::keys::Algorithm::Ed25519)); } #[tokio::test] #[cfg(unix)] async fn load_host_keys_skips_non_key_files() { let dir = TempDir::new().expect("tempdir"); // Real key plus an unrelated file. write_file_with_mode(&dir.path().join("ssh_host_ed25519_key"), &test_ed25519_pem(), 0o600); write_file_with_mode(&dir.path().join("README"), "Place host keys in this directory.\n", 0o600); let keys = SftpConfig::load_host_keys(dir.path()) .await .expect("must load the one valid key"); assert_eq!(keys.len(), 1); } #[tokio::test] #[cfg(unix)] async fn load_host_keys_handles_empty_file() { let dir = TempDir::new().expect("tempdir"); write_file_with_mode(&dir.path().join("empty"), "", 0o600); write_file_with_mode(&dir.path().join("ssh_host_ed25519_key"), &test_ed25519_pem(), 0o600); let keys = SftpConfig::load_host_keys(dir.path()) .await .expect("must skip empty and load the valid key"); assert_eq!(keys.len(), 1); } #[tokio::test] #[cfg(unix)] async fn load_host_keys_skips_passphrase_protected_key_with_warn() { // Build content that looks like a private key but cannot be decoded // (we pass None as the passphrase). Exercises the load_host_keys // branch that distinguishes "looks like a key" from "definitely // not a key" by the PEM_BEGIN_MARKER prefix check. let dir = TempDir::new().expect("tempdir"); let fake_passphrase_key = build_pem_block("this is not a valid base64 payload, decode will fail"); write_file_with_mode(&dir.path().join("encrypted_key"), fake_passphrase_key.as_str(), 0o600); // A real key alongside it so the loader does not fail with NoHostKeysFound. write_file_with_mode(&dir.path().join("ssh_host_ed25519_key"), &test_ed25519_pem(), 0o600); let keys = SftpConfig::load_host_keys(dir.path()) .await .expect("must skip the unreadable key and load the valid one"); assert_eq!(keys.len(), 1, "passphrase-protected key must be skipped, valid key must load"); } #[tokio::test] #[cfg(unix)] async fn load_host_keys_sorts_ed25519_before_ecdsa() { let dir = TempDir::new().expect("tempdir"); // Write ECDSA first to confirm sort ordering rather than insertion order. write_file_with_mode(&dir.path().join("ssh_host_ecdsa_key"), &test_ecdsa_pem(), 0o600); write_file_with_mode(&dir.path().join("ssh_host_ed25519_key"), &test_ed25519_pem(), 0o600); let keys = SftpConfig::load_host_keys(dir.path()).await.expect("both keys must load"); assert_eq!(keys.len(), 2); assert!( matches!(keys[0].algorithm(), russh::keys::Algorithm::Ed25519), "Ed25519 must be first in the sorted output, regardless of file scan order" ); assert!(matches!(keys[1].algorithm(), russh::keys::Algorithm::Ecdsa { .. })); } #[test] fn resolve_handles_per_session_none_passes_through() { assert_eq!(SftpConfig::resolve_handles_per_session(None), None); } #[test] fn resolve_handles_per_session_in_range_passes_through() { assert_eq!(SftpConfig::resolve_handles_per_session(Some(64)), Some(64)); assert_eq!(SftpConfig::resolve_handles_per_session(Some(128)), Some(128)); assert_eq!(SftpConfig::resolve_handles_per_session(Some(512)), Some(512)); } #[test] fn resolve_handles_per_session_at_lower_bound_passes_through() { assert_eq!( SftpConfig::resolve_handles_per_session(Some(HANDLES_PER_SESSION_MIN)), Some(HANDLES_PER_SESSION_MIN) ); } #[test] fn resolve_handles_per_session_at_upper_bound_passes_through() { assert_eq!( SftpConfig::resolve_handles_per_session(Some(HANDLES_PER_SESSION_MAX)), Some(HANDLES_PER_SESSION_MAX) ); } #[test] fn resolve_handles_per_session_below_min_returns_none() { assert_eq!(SftpConfig::resolve_handles_per_session(Some(0)), None); assert_eq!(SftpConfig::resolve_handles_per_session(Some(HANDLES_PER_SESSION_MIN - 1)), None); } #[test] fn resolve_handles_per_session_above_max_returns_none() { assert_eq!(SftpConfig::resolve_handles_per_session(Some(HANDLES_PER_SESSION_MAX + 1)), None); assert_eq!(SftpConfig::resolve_handles_per_session(Some(usize::MAX)), None); } #[test] fn resolve_backend_op_timeout_secs_none_passes_through() { assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(None), None); } #[test] fn resolve_backend_op_timeout_secs_in_range_passes_through() { assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(30)), Some(30)); assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(60)), Some(60)); assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(300)), Some(300)); } #[test] fn resolve_backend_op_timeout_secs_at_lower_bound_passes_through() { assert_eq!( SftpConfig::resolve_backend_op_timeout_secs(Some(BACKEND_OP_TIMEOUT_MIN_SECS)), Some(BACKEND_OP_TIMEOUT_MIN_SECS) ); } #[test] fn resolve_backend_op_timeout_secs_at_upper_bound_passes_through() { assert_eq!( SftpConfig::resolve_backend_op_timeout_secs(Some(BACKEND_OP_TIMEOUT_MAX_SECS)), Some(BACKEND_OP_TIMEOUT_MAX_SECS) ); } #[test] fn resolve_backend_op_timeout_secs_below_min_returns_none() { assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(0)), None); assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(BACKEND_OP_TIMEOUT_MIN_SECS - 1)), None); } #[test] fn resolve_backend_op_timeout_secs_above_max_returns_none() { assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(BACKEND_OP_TIMEOUT_MAX_SECS + 1)), None); assert_eq!(SftpConfig::resolve_backend_op_timeout_secs(Some(u64::MAX)), None); } #[test] fn resolve_read_cache_window_bytes_none_passes_through() { assert_eq!(SftpConfig::resolve_read_cache_window_bytes(None), None); } #[test] fn resolve_read_cache_window_bytes_in_range_passes_through() { assert_eq!( SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_WINDOW_DEFAULT)), Some(READ_CACHE_WINDOW_DEFAULT) ); assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(8 * 1024 * 1024)), Some(8 * 1024 * 1024)); } #[test] fn resolve_read_cache_window_bytes_at_lower_bound_passes_through() { assert_eq!( SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_WINDOW_MIN)), Some(READ_CACHE_WINDOW_MIN) ); } #[test] fn resolve_read_cache_window_bytes_at_upper_bound_passes_through() { assert_eq!( SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_WINDOW_MAX)), Some(READ_CACHE_WINDOW_MAX) ); } #[test] fn resolve_read_cache_window_bytes_below_min_but_nonzero_returns_none() { assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(1)), None); assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_WINDOW_MIN - 1)), None); } #[test] fn resolve_read_cache_window_bytes_above_max_returns_none() { assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_WINDOW_MAX + 1)), None); assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(u64::MAX)), None); } #[test] fn resolve_read_cache_window_bytes_zero_returns_disabled_sentinel() { assert_eq!( SftpConfig::resolve_read_cache_window_bytes(Some(READ_CACHE_DISABLED)), Some(READ_CACHE_DISABLED) ); assert_eq!(SftpConfig::resolve_read_cache_window_bytes(Some(0)), Some(0)); } #[test] fn resolve_read_cache_total_mem_bytes_none_passes_through() { assert_eq!(SftpConfig::resolve_read_cache_total_mem_bytes(None), None); } #[test] fn resolve_read_cache_total_mem_bytes_at_or_above_min_passes_through() { assert_eq!( SftpConfig::resolve_read_cache_total_mem_bytes(Some(READ_CACHE_TOTAL_MEM_MIN)), Some(READ_CACHE_TOTAL_MEM_MIN) ); assert_eq!( SftpConfig::resolve_read_cache_total_mem_bytes(Some(READ_CACHE_TOTAL_MEM_DEFAULT)), Some(READ_CACHE_TOTAL_MEM_DEFAULT) ); assert_eq!(SftpConfig::resolve_read_cache_total_mem_bytes(Some(u64::MAX)), Some(u64::MAX)); } #[test] fn resolve_read_cache_total_mem_bytes_below_min_returns_none() { assert_eq!(SftpConfig::resolve_read_cache_total_mem_bytes(Some(0)), None); assert_eq!(SftpConfig::resolve_read_cache_total_mem_bytes(Some(READ_CACHE_TOTAL_MEM_MIN - 1)), None); } }