mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 15:16:56 +00:00
efa89a98ed
* refactor(logging): standardize object capacity events * refactor(logging): standardize protocol server events * refactor(logging): standardize swift protocol events * refactor(logging): standardize observability events * refactor(logging): move masking helper and extend guardrails
974 lines
41 KiB
Rust
974 lines
41 KiB
Rust
// 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<usize>,
|
|
/// 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<u64>,
|
|
/// 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<u64>,
|
|
/// 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<u64>,
|
|
/// 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<usize>) -> Option<usize> {
|
|
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<u64>) -> Option<u64> {
|
|
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<u64>) -> Option<u64> {
|
|
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<u64>) -> Option<u64> {
|
|
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<Vec<russh::keys::PrivateKey>, 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<Vec<russh::keys::PrivateKey>, 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);
|
|
}
|
|
}
|