feat(sftp): add SFTPv3 protocol support (#2875)

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
escapecode
2026-05-10 04:48:42 +01:00
committed by GitHub
parent 8892cbbdd7
commit 96b293bf8a
44 changed files with 16555 additions and 155 deletions
+99 -24
View File
@@ -1,38 +1,26 @@
# Protocol E2E Tests
FTPS and WebDAV protocol end-to-end tests for RustFS.
FTPS, WebDAV, and SFTP protocol end-to-end tests for RustFS.
## Prerequisites
### Required Tools
```bash
# Ubuntu/Debian
sudo apt-get install sshpass ssh-keygen
# RHEL/CentOS
sudo yum install sshpass openssh-clients
# macOS
brew install sshpass openssh
```
No external SSH tooling is required. The test framework generates ed25519
host keys in-process via russh::keys under the per-test temp directory
before each SFTP server spawn, and russh-sftp drives the protocol from the
test process directly.
## Running Tests
Run all protocol tests (FTPS + WebDAV):
```bash
RUSTFS_BUILD_FEATURES=ftps,webdav cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
```
Run FTPS tests only:
```bash
RUSTFS_BUILD_FEATURES=ftps cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
```
Run WebDAV tests only:
```bash
RUSTFS_BUILD_FEATURES=webdav cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
```
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
built with. The protocol test runner schedules every entry (FTPS, WebDAV,
SFTP) regardless of the feature set, so the binary must include every
protocol the runner spawns or the corresponding entries will fail.
`--test-threads=1` is required because every entry spawns a rustfs server
on fixed bind ports.
## Test Coverage
@@ -59,3 +47,90 @@ RUSTFS_BUILD_FEATURES=webdav cargo test --package e2e_test test_protocol_core_su
- DELETE bucket
- Authentication failure test
### SFTP Tests
The SFTP suite lives in three entries plus a standalone idle-timeout case.
Every assertion runs against a freshly spawned rustfs binary with
`RUSTFS_SFTP_ENABLE=true`; the test framework also pins
`RUSTFS_SFTP_PART_SIZE=5242880` so the multipart boundary is deterministic.
#### sftp_core (`test_sftp_core_operations`)
Bind ports 9022 (SFTP) and 9200 (S3). 22 in-suite assertions covering the
core protocol surface plus cross-protocol consistency:
- Subsystem canary: SFTPv3 version exchange completes after password auth
- Bucket lifecycle: mkdir, root listing, rmdir, post-delete listing
- Small-file round-trip with SHA256 compare
- Stat on a file (size + file type) and on a bucket (directory)
- SETSTAT on a path returns ok
- Rename within bucket, listing reflects the rename
- Multipart-sized round-trip (just over 2 × part_size) with SHA256 compare
- Negative cases: symlink rejected, open of nonexistent file rejected,
read_dir of nonexistent bucket rejected, path traversal rejected
- Spec-letter assertions: APPEND open returns an error, CREATE+EXCLUDE on an
existing path returns an error, bad-password authentication is rejected
- Cross-protocol via aws-sdk-s3: SFTP write then S3 read with SHA256 match,
S3 write then SFTP read with SHA256 match
- Cross-API directory visibility: SFTP-created sub-directory visible via S3
ListObjectsV2, S3-created `__XLDIR__` marker visible via SFTP readdir as a
directory entry
#### sftp_compliance (`test_sftp_compliance_suite`)
Bind ports 9024 (SFTP) and 9300 (S3). 14 compliance regression cases against
one shared server spawn. Each case carries a stable CMPTST-NN identifier:
- CMPTST-01: medium-binary upload then download with SHA256 compare
(single-shot PutObject path below the multipart boundary)
- CMPTST-02: zero-byte upload, download, and stat-size match
- CMPTST-03: rm against a bucket path is rejected; the bucket is preserved
- CMPTST-04: rmdir against a non-empty bucket is rejected; the contained
object survives
- CMPTST-05: rmdir against a non-empty sub-directory is rejected; the inner
object survives
- CMPTST-06: open with a path-traversal pattern cannot leak a host file via
SFTP read
- CMPTST-07: read_dir of `/..` either errors or returns a listing that
contains no host system entries
- CMPTST-08: rename across buckets preserves payload and removes the source
object
- CMPTST-09: paths with embedded spaces round-trip through the russh-sftp
client
- CMPTST-10: read_link is rejected (S3 storage has no symlinks)
- CMPTST-11: SETSTAT on a path and FSETSTAT on a separate open handle both
return ok (rsync, WinSCP transfer-success contract)
- CMPTST-12: rename to the same path is a no-op; the file persists with the
original payload
- CMPTST-13: implicit-directory round-trip; uploading to a nested key
creates the parent directory implicitly and three listing forms surface
the inner file
- CMPTST-14: OPEN, WRITE, FSETSTAT, CLOSE on the same write handle all
return ok (WinSCP wire shape)
#### sftp_compliance_readonly (`test_sftp_compliance_readonly`)
Bind ports 9025 (SFTP) and 9301 (S3). Spawns a second rustfs binary with
`RUSTFS_SFTP_READ_ONLY=true`; the S3 endpoint stays writable so the suite
can seed a bucket and a fixture object via aws-sdk-s3 before opening the
SFTP session. 7 compliance cases:
- CMPTST-15: put through SFTP is rejected
- CMPTST-16: rm through SFTP is rejected
- CMPTST-17: mkdir through SFTP is rejected
- CMPTST-18: rmdir through SFTP is rejected
- CMPTST-19: rename through SFTP is rejected
- CMPTST-20: ls through SFTP is allowed and lists the seeded bucket
- CMPTST-21: get through SFTP is allowed and returns the seeded payload
byte-for-byte
The full case index lives at the top of `sftp_compliance.rs`; each helper's
log lines name its CMPTST-NN code so a failure in CI points at one named
property without consulting any external doc.
#### sftp_idle_timeout (`test_sftp_idle_timeout_disconnects`)
Bind ports 9023 (SFTP) and 9100 (S3). Spawns rustfs with
`RUSTFS_SFTP_IDLE_TIMEOUT=5`, sleeps 10 s past the timeout, then issues an
SFTP request and asserts the server has closed the session.
+5 -1
View File
@@ -12,9 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Protocol tests for FTPS and WebDAV
//! Protocol tests for FTPS, WebDAV, and SFTP
pub mod ftps_core;
pub mod sftp_compliance;
mod sftp_compliance_tests;
pub mod sftp_core;
pub mod sftp_helpers;
pub mod test_env;
pub mod test_runner;
pub mod webdav_core;
@@ -0,0 +1,215 @@
// 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.
//! Public test entry points for the SFTP compliance suite.
//!
//! Three suite entries cover every CMPTST-NN identifier:
//!
//! - test_sftp_compliance_suite CMPTST-01..14 (one shared SFTP session)
//! - test_sftp_compliance_readonly CMPTST-15..23 (one shared session, read-only mode)
//! - test_sftp_compliance_standalone CMPTST-24..33 (each case spawns its own rustfs)
//!
//! The first two reuse a single rustfs spawn for the whole bracket
//! because every case in the bracket exercises the same protocol
//! against the same server. The third aggregates cases that each need
//! a different server configuration (idle timeout, read-cache window,
//! console disabled) and therefore cannot share a binary.
//!
//! Each per-case module exposes a single descriptive entry called
//! run_<what_it_tests>(). For example cmptst_24 exposes
//! cmptst_24::run_concurrent_half_close_no_leak().
//!
//! The per-case bodies (one cmptst_NN module per case) and the
//! cross-case infrastructure (spawn helpers, fixture seeders, the
//! half-close / wedge / paused-drain stream wrappers, and the session
//! lifecycle counters) live in sftp_compliance_tests.rs. Per-case
//! marker comments and the full case-index doc live there too.
use crate::protocols::sftp_compliance_tests::{
cmptst_01, cmptst_02, cmptst_03, cmptst_04, cmptst_05, cmptst_06, cmptst_07, cmptst_08, cmptst_09, cmptst_10, cmptst_11,
cmptst_12, cmptst_13, cmptst_14, cmptst_15, cmptst_16, cmptst_17, cmptst_18, cmptst_19, cmptst_20, cmptst_21, cmptst_22,
cmptst_23, cmptst_24, cmptst_25, cmptst_26, cmptst_27, cmptst_28, cmptst_29, cmptst_32, cmptst_33, spawn_compliance_rustfs,
};
use crate::protocols::sftp_helpers::{build_test_s3_client, connect_sftp_to, wait_for_s3_ready};
use crate::protocols::test_env::ProtocolTestEnvironment;
use anyhow::{Result, anyhow};
use aws_sdk_s3::primitives::ByteStream;
use tracing::info;
// Read-write compliance suite ports. Distinct from sftp_core (9022/9200)
// and from test_sftp_idle_timeout_disconnects (9023/9100) so the SFTP
// entries can run sequentially without leftover-listener contention.
const COMPLIANCE_RW_SFTP_PORT: u16 = 9024;
const COMPLIANCE_RW_SFTP_ADDRESS: &str = "127.0.0.1:9024";
const COMPLIANCE_RW_S3_ADDRESS: &str = "127.0.0.1:9300";
// Read-only compliance suite ports. The SFTP session opened against
// this address runs against a server started with
// RUSTFS_SFTP_READ_ONLY=true. The S3 endpoint stays writable so the
// suite can seed a bucket and a fixture object before running the SFTP
// rejection assertions.
const COMPLIANCE_RO_SFTP_PORT: u16 = 9025;
const COMPLIANCE_RO_SFTP_ADDRESS: &str = "127.0.0.1:9025";
const COMPLIANCE_RO_S3_ADDRESS: &str = "127.0.0.1:9301";
const COMPLIANCE_RO_S3_ENDPOINT: &str = "http://127.0.0.1:9301";
const COMPLIANCE_RO_S3_READY_ATTEMPTS: u32 = 30;
/// Compliance suite entry: spawn one rustfs server, run every per-case
/// helper that closes a coverage gap not exercised by sftp_core. Runs
/// CMPTST-01 through CMPTST-14 against the same SFTP session.
pub async fn test_sftp_compliance_suite() -> Result<()> {
info!("Starting SFTP server for compliance suite on {}", COMPLIANCE_RW_SFTP_ADDRESS);
let (_env, mut server_process) = spawn_compliance_rustfs(COMPLIANCE_RW_SFTP_ADDRESS, COMPLIANCE_RW_S3_ADDRESS, false).await?;
let result = async {
ProtocolTestEnvironment::wait_for_port_ready(COMPLIANCE_RW_SFTP_PORT, 30)
.await
.map_err(|e| anyhow!("{}", e))?;
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
cmptst_02::run_zero_byte_round_trip(&sftp).await?;
cmptst_03::run_rm_on_bucket_path_rejected(&sftp).await?;
cmptst_04::run_rmdir_nonempty_bucket_rejected(&sftp).await?;
cmptst_05::run_rmdir_nonempty_subdir_rejected(&sftp).await?;
cmptst_06::run_path_traversal_get_rejected(&sftp).await?;
cmptst_07::run_dotdot_collapses_to_root(&sftp).await?;
cmptst_08::run_rename_cross_bucket(&sftp).await?;
cmptst_09::run_path_with_spaces_round_trip(&sftp).await?;
cmptst_10::run_readlink_rejected(&sftp).await?;
cmptst_11::run_setstat_after_put_returns_ok(&sftp).await?;
cmptst_12::run_rename_same_path_keeps_file(&sftp).await?;
cmptst_13::run_implicit_dir_round_trip(&sftp).await?;
cmptst_14::run_winscp_setstat_shape_on_handle(&sftp).await?;
drop(sftp);
session.disconnect(russh::Disconnect::ByApplication, "", "en").await?;
info!("SFTP compliance suite passed");
Ok::<(), anyhow::Error>(())
}
.await;
// Discard kill/wait errors on the teardown path: the test result
// above is the binding outcome, and a server that has already
// exited produces an error here that carries no useful signal.
server_process.kill_and_wait().await;
result
}
/// Read-only compliance entry: CMPTST-15 through CMPTST-23. The SFTP
/// server runs with RUSTFS_SFTP_READ_ONLY=true. Mutations through SFTP
/// must error. Reads through SFTP must succeed. The test seeds a bucket
/// and a file through the writable S3 endpoint before opening the SFTP
/// session.
pub async fn test_sftp_compliance_readonly() -> Result<()> {
info!("Starting SFTP server in read-only mode on {}", COMPLIANCE_RO_SFTP_ADDRESS);
let (_env, mut server_process) = spawn_compliance_rustfs(COMPLIANCE_RO_SFTP_ADDRESS, COMPLIANCE_RO_S3_ADDRESS, true).await?;
let result = async {
ProtocolTestEnvironment::wait_for_port_ready(COMPLIANCE_RO_SFTP_PORT, 30)
.await
.map_err(|e| anyhow!("{}", e))?;
let s3 = build_test_s3_client(COMPLIANCE_RO_S3_ENDPOINT);
wait_for_s3_ready(&s3, COMPLIANCE_RO_S3_READY_ATTEMPTS).await?;
let bucket = "robucket";
let seeded_key = "small.txt";
let seeded_content = b"read-only seed\n";
s3.create_bucket()
.bucket(bucket)
.send()
.await
.map_err(|e| anyhow!("S3 CreateBucket {} failed: {:?}", bucket, e))?;
s3.put_object()
.bucket(bucket)
.key(seeded_key)
.body(ByteStream::from_static(seeded_content))
.send()
.await
.map_err(|e| anyhow!("S3 PutObject {}/{} failed: {:?}", bucket, seeded_key, e))?;
info!("Seeded read-only fixture via S3: {}/{}", bucket, seeded_key);
let (session, sftp) = connect_sftp_to(COMPLIANCE_RO_SFTP_ADDRESS).await?;
cmptst_15::run_ro_put_rejected(&sftp, bucket).await?;
cmptst_16::run_ro_rm_rejected(&sftp, bucket, seeded_key).await?;
cmptst_17::run_ro_mkdir_rejected(&sftp).await?;
cmptst_18::run_ro_rmdir_rejected(&sftp, bucket).await?;
cmptst_19::run_ro_rename_rejected(&sftp, bucket, seeded_key).await?;
cmptst_20::run_ro_ls_allowed(&sftp, bucket).await?;
cmptst_21::run_ro_get_allowed(&sftp, bucket, seeded_key, seeded_content).await?;
cmptst_22::run_ro_setstat_rejected(&sftp, bucket, seeded_key).await?;
cmptst_23::run_ro_fsetstat_rejected(&sftp, bucket, seeded_key).await?;
drop(sftp);
// Discard the disconnect Result. A read-only session that has
// returned errors against every mutation can still be cleanly
// torn down, but a transient transport-level error here
// carries no useful signal beyond what the assertions above
// already pin.
let _ = session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("SFTP read-only compliance suite passed");
Ok::<(), anyhow::Error>(())
}
.await;
server_process.kill_and_wait().await;
result
}
/// Standalone-server compliance entry: runs CMPTST-24..33 in numerical
/// order. Each case spawns and tears down its own rustfs because each
/// exercises a different server configuration (idle timeout, console
/// listener, read-cache window) that cannot share a process with the
/// others.
///
/// CMPTST-30 is omitted by default. Its assertion (per-operation
/// wall-clock latency under pipelined metadata ops) is bounded by the
/// SSH SFTP subsystem's per-channel serial handler dispatch and is
/// structurally infeasible against the production code. The
/// test_sftp_handler_latency_regression #[tokio::test] entry remains
/// runnable on demand via `--ignored`.
///
/// CMPTST-31 (paused-drain) is omitted from the default suite for
/// runtime cost (200 MiB seed plus a 25 s pause window). The
/// test_sftp_paused_drain_regression #[tokio::test] entry covers it
/// for direct invocation.
///
/// CMPTST-24, 25, 26 verify kernel-level state via ss(8) and the
/// procfs ESTABLISHED discriminator. They are skipped on non-Linux
/// targets where those interfaces are absent.
pub async fn test_sftp_compliance_standalone() -> Result<()> {
info!("Starting SFTP standalone-server compliance suite");
#[cfg(target_os = "linux")]
{
cmptst_24::run_concurrent_half_close_no_leak().await?;
cmptst_25::run_wedge_kill_after_silence_in_close_wait().await?;
cmptst_26::run_healthy_idle_session_above_fast_threshold().await?;
}
cmptst_27::run_multi_session_mixed_pipelining().await?;
cmptst_28::run_5mb_download_with_concurrent_metadata_ops().await?;
cmptst_29::run_read_past_eof_volume().await?;
cmptst_32::run_read_cache_enabled_round_trip().await?;
cmptst_33::run_read_cache_disabled_round_trip().await?;
info!("SFTP standalone-server compliance suite passed");
Ok(())
}
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
// 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.
//! Core SFTP tests
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::sftp_helpers::{
AcceptAnyServerKey, ServerProcess, build_test_s3_client, connect_sftp_to, generate_host_key, sftp_read_full,
wait_for_s3_ready,
};
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, ProtocolTestEnvironment};
use anyhow::{Result, anyhow};
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::primitives::ByteStream;
use russh::client::{self, Handle};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::{FileAttributes, OpenFlags};
use rustfs_config::{
ENV_RUSTFS_ADDRESS, ENV_SFTP_ADDRESS, ENV_SFTP_ENABLE, ENV_SFTP_HOST_KEY_DIR, ENV_SFTP_IDLE_TIMEOUT, ENV_SFTP_PART_SIZE,
ENV_SFTP_READ_ONLY,
};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio::time::sleep;
use tracing::info;
const SFTP_PORT: u16 = 9022;
const SFTP_ADDRESS: &str = "127.0.0.1:9022";
// The cross-protocol assertions reach the same server process the SFTP
// session is connected to. The S3 endpoint is bound on a non-default port to
// avoid contention with any other rustfs running on port 9000 (for example a
// dev-testing harness container).
const S3_ADDRESS: &str = "127.0.0.1:9200";
const S3_ENDPOINT: &str = "http://127.0.0.1:9200";
const S3_READY_ATTEMPTS: u32 = 30;
// Mirrors GLOBAL_DIR_SUFFIX in rustfs_utils::path. The e2e_test crate does
// not depend on rustfs-utils, so the suffix is repeated locally.
const XLDIR_SUFFIX: &str = "__XLDIR__";
// Idle-timeout test uses its own ports so it can run alongside the core suite
// without clashing on the default S3 port or on the core SFTP port.
const IDLE_SFTP_PORT: u16 = 9023;
const IDLE_SFTP_ADDRESS: &str = "127.0.0.1:9023";
const IDLE_S3_ADDRESS: &str = "127.0.0.1:9100";
const IDLE_TIMEOUT_SECS: u64 = 5;
const IDLE_WAIT_SECS: u64 = 10;
// Pin the server's multipart part_size to the spec minimum (5 MiB) so the
// multipart payload in this file is sized relative to a known value and the
// Buffering to Streaming transition triggers deterministically regardless of
// the server's default.
const PART_SIZE_BYTES: usize = 5 * 1024 * 1024;
const PART_SIZE_ENV: &str = "5242880";
// Just over two part_size worth so the upload issues CreateMultipartUpload,
// at least one UploadPart mid-stream, and CompleteMultipartUpload.
const MULTIPART_SIZE: usize = PART_SIZE_BYTES * 2 + 1024;
// Fixed deterministic payload for the S3-write, SFTP-read direction. 256 KiB
// is well below part_size so the SFTP read returns the object as a single
// GetObject response without invoking the streaming multipart path.
const S3_WRITTEN_SIZE: usize = 256 * 1024;
async fn connect_sftp() -> Result<(Handle<AcceptAnyServerKey>, SftpSession)> {
connect_sftp_to(SFTP_ADDRESS).await
}
/// Confirm that an object is byte-identical when fetched via S3 and via SFTP.
/// Hashes the expected payload once, then compares both fetched payloads
/// against that hash. Either mismatch returns an error naming the side that
/// disagreed.
async fn assert_cross_protocol_sha_match(
s3: &S3Client,
sftp: &SftpSession,
bucket: &str,
key: &str,
expected: &[u8],
) -> Result<()> {
let expected_sha = Sha256::digest(expected);
let s3_get = s3
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| anyhow!("S3 GetObject {}/{} failed: {:?}", bucket, key, e))?;
let s3_bytes = s3_get
.body
.collect()
.await
.map_err(|e| anyhow!("S3 body collect failed for {}/{}: {:?}", bucket, key, e))?
.into_bytes();
if s3_bytes.len() != expected.len() {
return Err(anyhow!(
"S3 GetObject byte count mismatch for {}/{}: expected {}, got {}",
bucket,
key,
expected.len(),
s3_bytes.len()
));
}
let s3_sha = Sha256::digest(&s3_bytes);
if s3_sha != expected_sha {
return Err(anyhow!("S3 GetObject SHA256 mismatch for {}/{}", bucket, key));
}
let sftp_path = format!("/{bucket}/{key}");
let sftp_bytes = sftp_read_full(sftp, &sftp_path).await?;
if sftp_bytes.len() != expected.len() {
return Err(anyhow!(
"SFTP read byte count mismatch for {}: expected {}, got {}",
sftp_path,
expected.len(),
sftp_bytes.len()
));
}
let sftp_sha = Sha256::digest(&sftp_bytes);
if sftp_sha != expected_sha {
return Err(anyhow!("SFTP read SHA256 mismatch for {}", sftp_path));
}
Ok(())
}
/// SFTP core protocol round-trip: banner, mkdir, put, get with SHA compare, rename, delete, rmdir.
pub async fn test_sftp_core_operations() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow!("{}", e))?;
let host_key_dir = PathBuf::from(&env.temp_dir).join("sftp_host_keys");
generate_host_key(&host_key_dir).await?;
info!("Starting SFTP server on {}", SFTP_ADDRESS);
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav,sftp"));
let host_key_dir_str = host_key_dir
.to_str()
.ok_or_else(|| anyhow!("host key dir path is not utf-8"))?;
let mut server_process = ServerProcess::new(
Command::new(&binary_path)
.env(ENV_SFTP_ENABLE, "true")
.env(ENV_SFTP_ADDRESS, SFTP_ADDRESS)
.env(ENV_SFTP_HOST_KEY_DIR, host_key_dir_str)
.env(ENV_SFTP_READ_ONLY, "false")
.env(ENV_SFTP_PART_SIZE, PART_SIZE_ENV)
.env(ENV_RUSTFS_ADDRESS, S3_ADDRESS)
.arg(&env.temp_dir)
.spawn()?,
);
let result = async {
ProtocolTestEnvironment::wait_for_port_ready(SFTP_PORT, 30)
.await
.map_err(|e| anyhow!("{}", e))?;
let (session, sftp) = connect_sftp().await?;
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
// SftpSession::new completes the SFTPv3 version exchange. The
// canonicalize call below is a cheap round-trip that confirms
// the session handles real wire traffic.
info!("Testing SFTP: subsystem canary, server resolves '.' to an absolute path");
let pwd = sftp.canonicalize(".").await?;
assert!(!pwd.is_empty(), "server must resolve '.' to a non-empty absolute path");
info!("PASS: subsystem canary: server resolved '.' to {}", pwd);
// --- 2. Bucket lifecycle: mkdir then root listing ---
let bucket = "coretestbucket";
let bucket_path = format!("/{bucket}");
info!("Testing SFTP: mkdir bucket {}", bucket_path);
sftp.create_dir(&bucket_path).await?;
info!("PASS: mkdir bucket {}", bucket_path);
info!("Testing SFTP: root listing includes the new bucket");
let root_entries: Vec<String> = sftp.read_dir("/").await?.map(|e| e.file_name()).collect();
assert!(root_entries.iter().any(|n| n == bucket), "root listing should contain the new bucket");
info!("PASS: bucket {} appeared in read_dir(\"/\")", bucket);
// --- 3. Small-file round-trip with SHA256 compare ---
info!("Testing SFTP: small-file round-trip with SHA256 compare");
let small_path = format!("/{bucket}/small.txt");
let small_content = b"hello rustfs sftp\n";
let mut wf = sftp
.open_with_flags(&small_path, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE)
.await?;
wf.write_all(small_content).await?;
wf.flush().await?;
wf.shutdown().await?;
let mut rf = sftp.open_with_flags(&small_path, OpenFlags::READ).await?;
let mut buf = Vec::new();
rf.read_to_end(&mut buf).await?;
rf.shutdown().await?;
assert_eq!(buf.as_slice(), small_content, "small-file round-trip content mismatch");
let sha_in = Sha256::digest(small_content);
let sha_out = Sha256::digest(&buf);
assert_eq!(sha_in, sha_out, "small-file SHA256 mismatch");
info!("PASS: small-file round-trip SHA256 match");
// --- 4. Path STAT on a file and on a bucket ---
info!("Testing SFTP: stat on file returns size and file type");
let file_meta = sftp.metadata(&small_path).await?;
assert_eq!(file_meta.size, Some(small_content.len() as u64), "stat size mismatch");
assert!(file_meta.file_type().is_file(), "stat on a file must report regular file");
info!("PASS: stat on file reports size {} and file type", small_content.len());
info!("Testing SFTP: stat on bucket reports directory");
let bucket_meta = sftp.metadata(&bucket_path).await?;
assert!(bucket_meta.file_type().is_dir(), "stat on a bucket must report directory");
info!("PASS: stat on bucket reports directory");
// --- 5. SETSTAT on a file path returns ok ---
// SETSTAT is a no-op on the server because S3 has no POSIX mtime or permission
// semantics, but it must still return ok. Clients that send SETSTAT after every
// transfer (rsync, WinSCP) treat a non-ok status as a transfer failure.
info!("Testing SFTP: setstat on a path returns ok");
let attrs = FileAttributes {
permissions: Some(0o644),
..FileAttributes::default()
};
sftp.set_metadata(&small_path, attrs).await?;
info!("PASS: setstat returned ok");
// --- 6. Rename within bucket and listing reflects it ---
info!("Testing SFTP: rename within bucket");
let renamed = format!("/{bucket}/renamed.txt");
sftp.rename(&small_path, &renamed).await?;
info!("PASS: rename {} -> {}", small_path, renamed);
info!("Testing SFTP: listing reflects rename");
let bucket_entries: Vec<String> = sftp.read_dir(&bucket_path).await?.map(|e| e.file_name()).collect();
assert!(bucket_entries.iter().any(|n| n == "renamed.txt"), "renamed file must be listed");
assert!(!bucket_entries.iter().any(|n| n == "small.txt"), "pre-rename name must be gone");
info!("PASS: directory listing reflects the rename");
// --- 7. Multipart round-trip across the part-size boundary ---
// MULTIPART_SIZE is paired with RUSTFS_SFTP_PART_SIZE above so the upload crosses the
// multipart threshold regardless of server defaults.
info!("Testing SFTP: multipart-sized round-trip with SHA256 compare");
let big_path = format!("/{bucket}/big.bin");
let big_content: Vec<u8> = (0..MULTIPART_SIZE).map(|i| (i as u8).wrapping_mul(31)).collect();
let mut bwf = sftp
.open_with_flags(&big_path, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE)
.await?;
bwf.write_all(&big_content).await?;
bwf.flush().await?;
bwf.shutdown().await?;
let mut brf = sftp.open_with_flags(&big_path, OpenFlags::READ).await?;
let mut big_buf = Vec::with_capacity(MULTIPART_SIZE);
brf.read_to_end(&mut big_buf).await?;
brf.shutdown().await?;
assert_eq!(big_buf.len(), MULTIPART_SIZE, "multipart round-trip length mismatch");
let big_in = Sha256::digest(&big_content);
let big_out = Sha256::digest(&big_buf);
assert_eq!(big_in, big_out, "multipart SHA256 mismatch");
info!("PASS: multipart round-trip SHA256 match ({} bytes)", MULTIPART_SIZE);
// --- 8. Negative cases: symlink, open nonexistent, read_dir nonexistent, path escape ---
info!("Testing SFTP: symlink returns an error");
let symlink_err = sftp.symlink(&big_path, &format!("/{bucket}/shortcut")).await;
assert!(symlink_err.is_err(), "symlink must be rejected by the server");
info!("PASS: symlink rejected");
info!("Testing SFTP: open of nonexistent file returns an error");
let missing_path = format!("/{bucket}/not_here.txt");
let missing_err = sftp.open_with_flags(&missing_path, OpenFlags::READ).await;
assert!(missing_err.is_err(), "open of a nonexistent path must error");
info!("PASS: open of nonexistent file rejected");
info!("Testing SFTP: read_dir of nonexistent bucket returns an error");
let missing_bucket = sftp.read_dir("/nosuchbucket").await;
assert!(missing_bucket.is_err(), "read_dir of a nonexistent bucket must error");
info!("PASS: read_dir of nonexistent bucket rejected");
info!("Testing SFTP: path traversal cannot escape the storage root");
let traversal = sftp.read_dir("/../../../etc").await;
assert!(traversal.is_err(), "path traversal must be rejected or resolve to a nonexistent bucket");
info!("PASS: path traversal rejected");
// --- Spec-letter assertion: APPEND open-flag returns an error ---
// The driver maps APPEND to OpUnsupported because S3 has no append
// primitive. Open requests with APPEND must return a failure rather
// than allow a silently mistruncated upload.
info!("Testing SFTP: open with APPEND returns an error");
let append_err = sftp.open_with_flags(&renamed, OpenFlags::APPEND | OpenFlags::WRITE).await;
assert!(append_err.is_err(), "open with APPEND must error");
info!("PASS: open with APPEND rejected");
// --- Spec-letter assertion: O_EXCL on existing path returns an error ---
// CREATE | EXCLUDE on a key that already exists must fail. The
// existing renamed.txt is the target. EXCLUDE without WRITE is
// rejected by the russh-sftp client itself, so WRITE is included.
// TRUNCATE is included because the driver requires WRITE | CREATE
// | TRUNCATE on every accepted write OPEN.
info!("Testing SFTP: open with CREATE + EXCLUDE on existing path returns an error");
let excl_err = sftp
.open_with_flags(&renamed, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::EXCLUDE | OpenFlags::WRITE)
.await;
assert!(excl_err.is_err(), "CREATE+EXCLUDE on existing path must error");
info!("PASS: CREATE+EXCLUDE on existing path rejected");
// --- WRITE without CREATE or TRUNCATE is rejected at OPEN ---
// The streaming write path overwrites the entire object at
// close. A WRITE-only OPEN asks for partial-write semantics
// the server cannot honour against S3, so the OPEN is
// rejected before any handle is allocated.
info!("Testing SFTP: open with WRITE only returns an error");
let write_only_err = sftp.open_with_flags(&renamed, OpenFlags::WRITE).await;
assert!(write_only_err.is_err(), "WRITE without CREATE or TRUNCATE must be rejected at OPEN");
info!("PASS: WRITE only rejected");
// --- WRITE | CREATE without TRUNCATE is rejected at OPEN ---
// Without TRUNCATE the client is asking for create-or-modify-
// existing semantics. The server cannot deliver that against
// S3, so the OPEN is rejected before any handle is allocated.
info!("Testing SFTP: open with WRITE | CREATE without TRUNCATE returns an error");
let create_no_trunc_err = sftp.open_with_flags(&renamed, OpenFlags::WRITE | OpenFlags::CREATE).await;
assert!(create_no_trunc_err.is_err(), "WRITE | CREATE without TRUNCATE must be rejected at OPEN");
info!("PASS: WRITE | CREATE without TRUNCATE rejected");
// --- Spec-letter assertion: bad password is rejected (separate session) ---
// Fresh russh session with wrong credentials. The authenticated
// handle is left untouched. Bad auth must not succeed.
info!("Testing SFTP: second russh session with wrong password is rejected");
let bad_config = Arc::new(client::Config::default());
let mut bad_session = client::connect(bad_config, SFTP_ADDRESS, AcceptAnyServerKey).await?;
let bad_auth = bad_session.authenticate_password(DEFAULT_ACCESS_KEY, "wrong-secret").await?;
assert!(!bad_auth.success(), "bad-password authentication must not succeed");
// Discard the disconnect Result. A server that already rejected
// auth can return an error here, but the assert above already
// pins the auth outcome.
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("PASS: bad-password authentication rejected");
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
// The rustfs binary spawned for this suite serves both SFTP on port
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
// initialising after TCP is listening, so list_buckets is polled
// until it succeeds before any cross-protocol assertion runs.
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
info!("PASS: S3 endpoint reachable from cross-protocol client");
// --- SFTP write, S3 read: SHA256 round-trip ---
// SFTP creates the object, then assert_cross_protocol_sha_match
// fetches it via both S3 GetObject and SFTP READ and compares
// each result against the SHA256 of the original payload. Both
// sides must match byte-exact, which proves the storage layer
// returns the same bytes regardless of wire protocol.
info!("Testing SFTP: SFTP write then S3 read, SHA256 round-trip");
let sftp_to_s3_key = "sftp_written.bin";
let sftp_to_s3_path = format!("/{bucket}/{sftp_to_s3_key}");
let sftp_to_s3_content: Vec<u8> = (0..S3_WRITTEN_SIZE).map(|i| (i as u8).wrapping_mul(17)).collect();
let mut wf = sftp
.open_with_flags(&sftp_to_s3_path, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE)
.await?;
wf.write_all(&sftp_to_s3_content).await?;
wf.flush().await?;
wf.shutdown().await?;
assert_cross_protocol_sha_match(&s3, &sftp, bucket, sftp_to_s3_key, &sftp_to_s3_content).await?;
info!("PASS: SFTP-written object matches via S3 GetObject and SFTP READ");
// --- S3 write, SFTP read: SHA256 round-trip ---
// aws-sdk-s3 PutObject writes a fixed deterministic payload. Both
// sides then read it back and SHA-compare.
info!("Testing SFTP: S3 write then SFTP read, SHA256 round-trip");
let s3_to_sftp_key = "s3_written.bin";
let s3_to_sftp_content: Vec<u8> = (0..S3_WRITTEN_SIZE).map(|i| (i as u8).wrapping_mul(31)).collect();
s3.put_object()
.bucket(bucket)
.key(s3_to_sftp_key)
.body(ByteStream::from(s3_to_sftp_content.clone()))
.send()
.await
.map_err(|e| anyhow!("S3 PutObject {}/{} failed: {:?}", bucket, s3_to_sftp_key, e))?;
assert_cross_protocol_sha_match(&s3, &sftp, bucket, s3_to_sftp_key, &s3_to_sftp_content).await?;
info!("PASS: S3-written object matches via S3 GetObject and SFTP READ");
// --- Cross-API directory visibility: SFTP mkdir, S3 ListObjectsV2 ---
// SFTP mkdir writes a __XLDIR__ marker. The rustfs S3 listing path
// decodes that marker back to a trailing-slash key, so the asserted
// pattern is "subdir_sftp/".
info!("Testing SFTP: SFTP-created sub-directory visible via S3 ListObjectsV2");
let sftp_subdir_name = "subdir_sftp";
let sftp_subdir_path = format!("/{bucket}/{sftp_subdir_name}");
sftp.create_dir(&sftp_subdir_path).await?;
let listed = s3
.list_objects_v2()
.bucket(bucket)
.prefix(sftp_subdir_name)
.send()
.await
.map_err(|e| anyhow!("S3 ListObjectsV2 {} failed: {:?}", bucket, e))?;
let listed_keys: Vec<String> = listed
.contents()
.iter()
.filter_map(|obj| obj.key().map(|s| s.to_string()))
.collect();
let visible_via_s3 = listed_keys
.iter()
.any(|k| k == &format!("{sftp_subdir_name}/") || k == &format!("{sftp_subdir_name}{XLDIR_SUFFIX}"));
assert!(
visible_via_s3,
"SFTP-created sub-directory must appear in S3 ListObjectsV2: keys returned were {listed_keys:?}"
);
info!("PASS: SFTP mkdir visible to S3 ListObjectsV2");
// --- Cross-API directory visibility: S3 marker, SFTP readdir ---
// aws-sdk-s3 PutObject writes a zero-byte marker keyed with the
// __XLDIR__ suffix. SFTP readdir must decode the marker back to a
// bare directory entry whose file_type reports as a directory.
info!("Testing SFTP: S3-created __XLDIR__ marker visible via SFTP readdir");
let s3_subdir_name = "subdir_s3";
let s3_subdir_marker_key = format!("{s3_subdir_name}{XLDIR_SUFFIX}");
s3.put_object()
.bucket(bucket)
.key(&s3_subdir_marker_key)
.body(ByteStream::from_static(b""))
.send()
.await
.map_err(|e| anyhow!("S3 PutObject {}/{} failed: {:?}", bucket, s3_subdir_marker_key, e))?;
let bucket_entries: Vec<(String, bool)> = sftp
.read_dir(&bucket_path)
.await?
.map(|entry| (entry.file_name(), entry.file_type().is_dir()))
.collect();
let visible_via_sftp = bucket_entries.iter().any(|(name, is_dir)| name == s3_subdir_name && *is_dir);
assert!(
visible_via_sftp,
"S3-created marker must appear as a directory in SFTP readdir: entries were {bucket_entries:?}"
);
info!("PASS: S3 marker visible to SFTP readdir as a directory");
// --- Pre-cleanup of cross-protocol fixtures ---
// Removes the new files and sub-directories so the existing rmdir
// call below operates against an empty bucket.
info!("Testing SFTP: pre-cleanup of cross-protocol fixtures");
sftp.remove_file(&format!("/{bucket}/{sftp_to_s3_key}")).await?;
sftp.remove_file(&format!("/{bucket}/{s3_to_sftp_key}")).await?;
sftp.remove_dir(&sftp_subdir_path).await?;
sftp.remove_dir(&format!("/{bucket}/{s3_subdir_name}")).await?;
info!("PASS: cross-protocol fixtures removed");
// --- 9. Cleanup: delete objects, rmdir bucket, confirm root empty ---
info!("Testing SFTP: delete objects then rmdir bucket");
sftp.remove_file(&renamed).await?;
sftp.remove_file(&big_path).await?;
sftp.remove_dir(&bucket_path).await?;
info!("PASS: delete + rmdir leaves the root empty");
let final_entries: Vec<String> = sftp.read_dir("/").await?.map(|e| e.file_name()).collect();
assert!(!final_entries.iter().any(|n| n == bucket), "bucket must be gone after rmdir");
info!("PASS: root listing no longer includes the deleted bucket");
drop(sftp);
session.disconnect(russh::Disconnect::ByApplication, "", "en").await?;
info!("SFTP core tests passed");
Ok::<(), anyhow::Error>(())
}
.await;
// Discard kill/wait errors on the teardown path: the test result
// above is the binding outcome, and a server that has already
// exited produces an error here that carries no useful signal.
server_process.kill_and_wait().await;
result
}
/// Idle-timeout regression: the server must close an SFTP session that
/// remains inactive past RUSTFS_SFTP_IDLE_TIMEOUT.
///
/// Spawns its own rustfs binary on dedicated SFTP and S3 ports so it can run
/// independently of the core protocol suite. The disconnect check issues a
/// cheap SFTP request after the wait window. The same error path runs in any
/// client when the server-initiated SSH_MSG_DISCONNECT arrives. The assertion
/// does not pin a specific russh error variant because the exact error
/// returned on server-initiated disconnect depends on timing.
pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow!("{}", e))?;
let host_key_dir = PathBuf::from(&env.temp_dir).join("sftp_host_keys");
generate_host_key(&host_key_dir).await?;
info!("Starting SFTP server with idle timeout {} s on {}", IDLE_TIMEOUT_SECS, IDLE_SFTP_ADDRESS);
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav,sftp"));
let host_key_dir_str = host_key_dir
.to_str()
.ok_or_else(|| anyhow!("host key dir path is not utf-8"))?;
let mut server_process = ServerProcess::new(
Command::new(&binary_path)
.env(ENV_SFTP_ENABLE, "true")
.env(ENV_SFTP_ADDRESS, IDLE_SFTP_ADDRESS)
.env(ENV_SFTP_HOST_KEY_DIR, host_key_dir_str)
.env(ENV_SFTP_READ_ONLY, "false")
.env(ENV_SFTP_PART_SIZE, PART_SIZE_ENV)
.env(ENV_SFTP_IDLE_TIMEOUT, IDLE_TIMEOUT_SECS.to_string())
.env(ENV_RUSTFS_ADDRESS, IDLE_S3_ADDRESS)
.arg(&env.temp_dir)
.spawn()?,
);
let result = async {
ProtocolTestEnvironment::wait_for_port_ready(IDLE_SFTP_PORT, 30)
.await
.map_err(|e| anyhow!("{}", e))?;
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
// Confirm the session is live before the wait so a failure in the
// post-wait read can be attributed to the idle timer rather than to
// a setup defect.
let pwd = sftp.canonicalize(".").await?;
assert!(!pwd.is_empty(), "server must resolve '.' to a non-empty absolute path");
info!("Idle wait: sleeping {} s past idle timeout {} s", IDLE_WAIT_SECS, IDLE_TIMEOUT_SECS);
sleep(Duration::from_secs(IDLE_WAIT_SECS)).await;
let post_idle = sftp.read_dir("/").await;
assert!(
post_idle.is_err(),
"SFTP request after idle wait must error once the server has closed the session"
);
info!("PASS: SFTP request after idle wait returned an error");
drop(sftp);
// Discard the disconnect Result. The server has already closed the
// session via the idle-timeout path the test is probing. A client
// disconnect against a half-closed transport may itself return Err
// with no useful signal.
let _ = session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
Ok::<(), anyhow::Error>(())
}
.await;
// Discard kill/wait errors on the teardown path: the test result above
// is the binding outcome, and a server that has already exited produces
// an error here that carries no useful signal.
server_process.kill_and_wait().await;
result
}
@@ -0,0 +1,194 @@
// 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.
//! Shared helpers for SFTP protocol tests
//!
//! An accept-any host-key handler, an ed25519 host-key generator that
//! matches the rustfs config loader permission gates, a russh client
//! connector that authenticates with the default access key, and an
//! SFTP-read-to-vec helper.
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY};
use anyhow::{Result, anyhow};
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use russh::client::{self, Handle};
use russh::keys::ssh_key::LineEnding;
use russh::keys::{Algorithm, PrivateKey, PublicKey};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::OpenFlags;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Child;
use tokio::time::sleep;
use tracing::info;
/// Accept-any server-key client handler. The test server uses a host key
/// generated fresh at the start of each run, so strict verification would
/// always fail. The suite exercises the auth path, not the host-key-trust
/// path, which is out of scope for this suite.
pub struct AcceptAnyServerKey;
impl client::Handler for AcceptAnyServerKey {
type Error = anyhow::Error;
async fn check_server_key(&mut self, _server_public_key: &PublicKey) -> Result<bool, Self::Error> {
Ok(true)
}
}
/// Generate an ed25519 host key pair in host_key_dir with mode 0600. The
/// key is generated in-process via russh::keys so the test suite has no
/// host-tooling dependency on ssh-keygen, which is absent on Alpine,
/// distroless, scratch, and Windows images. The RustFS config loader
/// accepts the OpenSSH private-key format that PrivateKey::to_openssh
/// emits. Both the private key and the .pub file need 0600 because the
/// loader scans every entry in the directory and rejects the whole
/// directory as insecure unless each file is 0600 or 0400.
pub async fn generate_host_key(host_key_dir: &Path) -> Result<()> {
tokio::fs::create_dir_all(host_key_dir).await?;
let key_path = host_key_dir.join("ssh_host_ed25519_key");
let pub_path = host_key_dir.join("ssh_host_ed25519_key.pub");
let private_key =
PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).map_err(|e| anyhow!("ed25519 key generation failed: {e}"))?;
let private_pem = private_key
.to_openssh(LineEnding::LF)
.map_err(|e| anyhow!("OpenSSH private-key encode failed: {e}"))?;
let public_text = private_key
.public_key()
.to_openssh()
.map_err(|e| anyhow!("OpenSSH public-key encode failed: {e}"))?;
tokio::fs::write(&key_path, private_pem.as_bytes()).await?;
tokio::fs::write(&pub_path, format!("{public_text}\n")).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for path in [&key_path, &pub_path] {
let mut perm = std::fs::metadata(path)?.permissions();
perm.set_mode(0o600);
std::fs::set_permissions(path, perm)?;
}
}
Ok(())
}
/// Owns a spawned rustfs server child process and guarantees the process
/// is sent SIGKILL even if the test panics. The wrapper exists because
/// tokio::process::Child does not kill on Drop on stable Rust, so a
/// panicking test would otherwise leak a running rustfs binary that
/// keeps its listener port held until the test runner exits.
///
/// Use kill_and_wait on the success and Err paths to reap the child
/// cleanly; Drop only fires the synchronous SIGKILL when those paths
/// were skipped (panic unwind, runtime abort).
pub struct ServerProcess {
inner: Option<Child>,
}
impl ServerProcess {
pub fn new(child: Child) -> Self {
Self { inner: Some(child) }
}
/// Borrow the inner Child for callers that need stdout piping or
/// other tokio::process APIs.
pub fn child_mut(&mut self) -> &mut Child {
self.inner.as_mut().expect("ServerProcess: child already taken")
}
/// Async kill plus wait. Idempotent. Use on every success and Err
/// path. After this returns, Drop becomes a no-op.
pub async fn kill_and_wait(&mut self) {
if let Some(mut child) = self.inner.take() {
let _ = child.kill().await;
let _ = child.wait().await;
}
}
}
impl Drop for ServerProcess {
fn drop(&mut self) {
if let Some(child) = self.inner.as_mut() {
// Synchronous SIGKILL via the kernel. Runs even on panic
// unwind. wait() is skipped here (Drop cannot await), so
// the process becomes a zombie reaped by the runtime.
let _ = child.start_kill();
}
}
}
/// Open a russh client session against the given address, authenticate
/// with the default access key, request the SFTP subsystem, and return
/// the session handle plus the SFTP wrapper. The handle is returned so
/// the caller can keep the underlying SSH transport alive for the full
/// session and disconnect cleanly afterwards.
pub async fn connect_sftp_to(address: &str) -> Result<(Handle<AcceptAnyServerKey>, SftpSession)> {
let config = Arc::new(client::Config::default());
let mut session = client::connect(config, address, AcceptAnyServerKey).await?;
let auth = session.authenticate_password(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY).await?;
if !auth.success() {
return Err(anyhow!("SFTP password auth rejected"));
}
let channel = session.channel_open_session().await?;
channel.request_subsystem(true, "sftp").await?;
let sftp = SftpSession::new(channel.into_stream()).await?;
Ok((session, sftp))
}
/// Read an SFTP object into memory.
pub async fn sftp_read_full(sftp: &SftpSession, path: &str) -> Result<Vec<u8>> {
let mut file = sftp.open_with_flags(path, OpenFlags::READ).await?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).await?;
file.shutdown().await?;
Ok(buf)
}
/// Construct an aws-sdk-s3 client wired against an http rustfs endpoint.
/// Uses the same credential constants the SFTP session authenticates with so
/// both protocols see the same backend identity.
pub fn build_test_s3_client(endpoint_url: &str) -> S3Client {
let credentials = Credentials::new(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, None, None, "sftp-helpers");
let mut config = aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(endpoint_url)
.force_path_style(true)
.behavior_version_latest();
if endpoint_url.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
S3Client::from_conf(config.build())
}
/// Poll the S3 endpoint until ListBuckets returns successfully or the
/// attempt budget is exhausted. The TCP-level wait_for_port_ready check is
/// not enough on its own because rustfs accepts connections before the S3
/// stack has finished initialising.
pub async fn wait_for_s3_ready(client: &S3Client, max_attempts: u32) -> Result<()> {
for attempt in 0..max_attempts {
if client.list_buckets().send().await.is_ok() {
info!("S3 endpoint ready after {} attempts", attempt + 1);
return Ok(());
}
sleep(Duration::from_secs(1)).await;
}
Err(anyhow!("S3 endpoint did not become ready"))
}
+7 -2
View File
@@ -32,8 +32,13 @@ impl ProtocolTestEnvironment {
/// Create a new test environment
/// This environment won't stop any server when dropped
pub fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let temp_dir = format!("/tmp/rustfs_protocol_test_{}", uuid::Uuid::new_v4());
std::fs::create_dir_all(&temp_dir)?;
let mut path = std::env::temp_dir();
path.push(format!("rustfs_protocol_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path)?;
let temp_dir = path
.to_str()
.ok_or_else(|| format!("temp dir path is not utf-8: {}", path.display()))?
.to_string();
Ok(Self { temp_dir })
}
@@ -16,6 +16,10 @@
use crate::common::init_logging;
use crate::protocols::ftps_core::test_ftps_core_operations;
use crate::protocols::sftp_compliance::{
test_sftp_compliance_readonly, test_sftp_compliance_standalone, test_sftp_compliance_suite,
};
use crate::protocols::sftp_core::{test_sftp_core_operations, test_sftp_idle_timeout_disconnects};
use crate::protocols::webdav_core::test_webdav_core_operations;
use serial_test::serial;
use std::time::Instant;
@@ -68,6 +72,21 @@ impl ProtocolTestSuite {
TestDefinition {
name: "test_webdav_core_operations".to_string(),
},
TestDefinition {
name: "test_sftp_core_operations".to_string(),
},
TestDefinition {
name: "test_sftp_compliance_suite".to_string(),
},
TestDefinition {
name: "test_sftp_compliance_readonly".to_string(),
},
TestDefinition {
name: "test_sftp_idle_timeout_disconnects".to_string(),
},
TestDefinition {
name: "test_sftp_compliance_standalone".to_string(),
},
];
Self { tests }
@@ -94,6 +113,26 @@ impl ProtocolTestSuite {
info!("=== Starting WebDAV Core Test ===");
"WebDAV core operations (MKCOL, PUT, GET, DELETE, PROPFIND)"
}
"test_sftp_core_operations" => {
info!("=== Starting SFTP Core Test ===");
"SFTP core operations (banner, mkdir, put, get with SHA compare, rename, delete, rmdir)"
}
"test_sftp_compliance_suite" => {
info!("=== Starting SFTP Compliance Suite ===");
"SFTP compliance regression suite (zero-byte, mutation rejection, traversal, rename, implicit dirs, FSETSTAT)"
}
"test_sftp_compliance_readonly" => {
info!("=== Starting SFTP Read-Only Compliance Suite ===");
"SFTP read-only mode (RUSTFS_SFTP_READ_ONLY=true rejects mutations and allows reads)"
}
"test_sftp_idle_timeout_disconnects" => {
info!("=== Starting SFTP Idle-Timeout Test ===");
"SFTP idle-timeout disconnects (server closes the session past RUSTFS_SFTP_IDLE_TIMEOUT)"
}
"test_sftp_compliance_standalone" => {
info!("=== Starting SFTP Standalone-Server Compliance Suite ===");
"SFTP standalone-server compliance suite"
}
_ => "",
};
@@ -133,6 +172,11 @@ impl ProtocolTestSuite {
match test_def.name.as_str() {
"test_ftps_core_operations" => test_ftps_core_operations().await.map_err(|e| e.into()),
"test_webdav_core_operations" => test_webdav_core_operations().await.map_err(|e| e.into()),
"test_sftp_core_operations" => test_sftp_core_operations().await.map_err(|e| e.into()),
"test_sftp_compliance_suite" => test_sftp_compliance_suite().await.map_err(|e| e.into()),
"test_sftp_compliance_readonly" => test_sftp_compliance_readonly().await.map_err(|e| e.into()),
"test_sftp_idle_timeout_disconnects" => test_sftp_idle_timeout_disconnects().await.map_err(|e| e.into()),
"test_sftp_compliance_standalone" => test_sftp_compliance_standalone().await.map_err(|e| e.into()),
_ => Err(format!("Test {} not implemented", test_def.name).into()),
}
}