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
+60
View File
@@ -71,4 +71,64 @@ pub trait StorageBackend: Send + Sync {
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error>;
/// Delete a bucket (must be empty)
async fn delete_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<DeleteBucketOutput, Self::Error>;
/// Server-side copy of an object from one bucket+key to another.
/// The input carries the full S3 surface (content type, metadata map,
/// metadata directive, storage class, SSE config, conditional-copy
/// headers) so protocol drivers can map client-supplied metadata
/// onto the destination object.
async fn copy_object(
&self,
input: CopyObjectInput,
access_key: &str,
secret_key: &str,
) -> Result<CopyObjectOutput, Self::Error>;
/// Initiate a multipart upload. Returns an upload_id that identifies
/// the in-progress upload for subsequent UploadPart, CompleteMultipartUpload,
/// and AbortMultipartUpload calls. The input carries the full S3 surface
/// (content type, cache control, metadata map, storage class, SSE config,
/// object lock settings) so protocol drivers can map client-supplied
/// metadata into the upload at creation time.
async fn create_multipart_upload(
&self,
input: CreateMultipartUploadInput,
access_key: &str,
secret_key: &str,
) -> Result<CreateMultipartUploadOutput, Self::Error>;
/// Upload one part of a multipart upload. The part_number must be in
/// the range 1 to the 10 000-part S3 limit. The returned ETag
/// identifies the part in the subsequent CompleteMultipartUpload call.
async fn upload_part(
&self,
input: UploadPartInput,
access_key: &str,
secret_key: &str,
) -> Result<UploadPartOutput, Self::Error>;
/// Assemble the parts listed in the input into the final object.
/// The parts list must be sorted by part_number with no duplicates.
async fn complete_multipart_upload(
&self,
input: CompleteMultipartUploadInput,
access_key: &str,
secret_key: &str,
) -> Result<CompleteMultipartUploadOutput, Self::Error>;
/// Abort an in-progress multipart upload. Releases any storage
/// associated with the upload_id. Idempotent: calling abort on an
/// already-aborted upload_id returns success. The input carries the
/// cross-account and conditional-abort fields (expected_bucket_owner,
/// if_match_initiated_time) that non-SFTP consumers may need.
async fn abort_multipart_upload(
&self,
input: AbortMultipartUploadInput,
access_key: &str,
secret_key: &str,
) -> Result<AbortMultipartUploadOutput, Self::Error>;
/// Copy a byte range from an existing object into a part of an
/// in-progress multipart upload. Used by rename for objects larger
/// than the 5 GiB single-shot CopyObject limit.
async fn upload_part_copy(
&self,
input: UploadPartCopyInput,
access_key: &str,
secret_key: &str,
) -> Result<UploadPartCopyOutput, Self::Error>;
}
@@ -0,0 +1,746 @@
// 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.
#![cfg(test)]
//! Storage-backend double for protocol driver unit tests.
//!
//! DummyBackend is a queue-driven StorageBackend implementation with
//! per-method response queues and per-call observation logs. Each
//! async method pops the next response from its queue; an empty queue
//! returns a default not-found or not-implemented error so a test
//! that forgets to configure a branch errors at the call site rather
//! than passing silently.
//!
//! Send + Sync behind a single Mutex. Tests share state between the
//! driver-held Arc and a cloned Arc kept for observation after the
//! driver is dropped. SessionContext fixtures live next to the
//! SessionContext type in common::session.
use crate::common::client::s3::StorageBackend;
use async_trait::async_trait;
use bytes::Bytes;
use futures_util::stream::{self, StreamExt};
use s3s::dto::{
AbortMultipartUploadInput, AbortMultipartUploadOutput, CompleteMultipartUploadInput, CompleteMultipartUploadOutput,
CopyObjectInput, CopyObjectOutput, CreateBucketOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput,
DeleteBucketOutput, DeleteObjectOutput, ETag, GetObjectOutput, HeadBucketOutput, HeadObjectOutput, ListBucketsOutput,
ListObjectsV2Input, ListObjectsV2Output, PutObjectInput, PutObjectOutput, StreamingBlob, Timestamp, UploadPartCopyInput,
UploadPartCopyOutput, UploadPartInput, UploadPartOutput,
};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use tokio::sync::Notify;
/// Error type returned by DummyBackend. Display strings include substrings
/// the driver's error-mapping helpers match against, so a queued NoSuchKey
/// error is reported as a not-found status at the protocol layer and an
/// AccessDenied error is reported as a permission-denied status.
#[derive(Debug, Error)]
pub enum DummyError {
/// Display includes the NoSuchKey substring. S3-style error mappers
/// map this to not-found.
#[error("NoSuchKey: {0}")]
NoSuchKey(String),
/// Display includes the NoSuchBucket substring. S3-style error mappers
/// map this to not-found.
#[error("NoSuchBucket: {0}")]
NoSuchBucket(String),
/// Free-form error string pre-seeded by a test. Must contain one of the
/// S3 error-code substrings if the test wants a specific status code
/// from the driver's error-mapping helper.
#[error("{0}")]
Injected(String),
/// Default response when the per-method queue is empty and the method
/// has no NotFound default. Any test reaching this path has forgotten
/// to configure the branch.
#[error("DummyBackend method not configured: {0}")]
Unconfigured(&'static str),
}
/// Recorded invocation of abort_multipart_upload. Tests assert on these to
/// observe tombstone-driven abort-on-drop behaviour.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbortCall {
pub bucket: String,
pub key: String,
pub upload_id: String,
}
/// Recorded invocation of upload_part. Tests assert on these to observe
/// the sequence of parts a write path issues.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UploadPartCall {
pub bucket: String,
pub key: String,
pub upload_id: String,
pub part_number: i32,
pub content_length: Option<i64>,
}
/// Recorded invocation of complete_multipart_upload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompleteCall {
pub bucket: String,
pub key: String,
pub upload_id: String,
pub part_count: usize,
}
/// Recorded invocation of head_object.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadObjectCall {
pub bucket: String,
pub key: String,
}
struct Inner {
// Response queues. Each method pops from its own queue. Empty queue
// plus no default means a configured-miss error.
get_object: VecDeque<Result<GetObjectOutput, DummyError>>,
get_object_range: VecDeque<Result<GetObjectOutput, DummyError>>,
put_object: VecDeque<Result<PutObjectOutput, DummyError>>,
delete_object: VecDeque<Result<DeleteObjectOutput, DummyError>>,
head_object: VecDeque<Result<HeadObjectOutput, DummyError>>,
head_bucket: VecDeque<Result<HeadBucketOutput, DummyError>>,
list_objects_v2: VecDeque<Result<ListObjectsV2Output, DummyError>>,
list_buckets: VecDeque<Result<ListBucketsOutput, DummyError>>,
create_bucket: VecDeque<Result<CreateBucketOutput, DummyError>>,
delete_bucket: VecDeque<Result<DeleteBucketOutput, DummyError>>,
copy_object: VecDeque<Result<CopyObjectOutput, DummyError>>,
create_multipart_upload: VecDeque<Result<CreateMultipartUploadOutput, DummyError>>,
upload_part: VecDeque<Result<UploadPartOutput, DummyError>>,
complete_multipart_upload: VecDeque<Result<CompleteMultipartUploadOutput, DummyError>>,
abort_multipart_upload: VecDeque<Result<AbortMultipartUploadOutput, DummyError>>,
upload_part_copy: VecDeque<Result<UploadPartCopyOutput, DummyError>>,
// Observation logs.
abort_multipart_calls: Vec<AbortCall>,
upload_part_calls: Vec<UploadPartCall>,
complete_multipart_calls: Vec<CompleteCall>,
head_object_calls: Vec<HeadObjectCall>,
// Cancellation-test support. When stall_upload_part is true every
// upload_part invocation signals upload_part_entered and then awaits
// std::future::pending. The pending future is cancellable: the caller's
// select or Drop cancels it without blocking the runtime.
stall_upload_part: bool,
upload_part_entered: Option<Arc<Notify>>,
// When stall_put_object is true every put_object invocation signals
// put_object_entered and then awaits std::future::pending. Used by
// the run_backend timeout integration tests where the driver must
// observe an Elapsed deadline rather than a backend Err.
stall_put_object: bool,
put_object_entered: Option<Arc<Notify>>,
// When stall_list_objects_v2 is true every list_objects_v2
// invocation signals list_objects_v2_entered and then awaits
// std::future::pending. Used by the cursor-corruption regression
// test that pins the un-advanced cursor after a cancelled READDIR
// mid-await.
stall_list_objects_v2: bool,
list_objects_v2_entered: Option<Arc<Notify>>,
}
impl Inner {
fn new() -> Self {
Self {
get_object: VecDeque::new(),
get_object_range: VecDeque::new(),
put_object: VecDeque::new(),
delete_object: VecDeque::new(),
head_object: VecDeque::new(),
head_bucket: VecDeque::new(),
list_objects_v2: VecDeque::new(),
list_buckets: VecDeque::new(),
create_bucket: VecDeque::new(),
delete_bucket: VecDeque::new(),
copy_object: VecDeque::new(),
create_multipart_upload: VecDeque::new(),
upload_part: VecDeque::new(),
complete_multipart_upload: VecDeque::new(),
abort_multipart_upload: VecDeque::new(),
upload_part_copy: VecDeque::new(),
abort_multipart_calls: Vec::new(),
upload_part_calls: Vec::new(),
complete_multipart_calls: Vec::new(),
head_object_calls: Vec::new(),
stall_upload_part: false,
upload_part_entered: None,
stall_put_object: false,
put_object_entered: None,
stall_list_objects_v2: false,
list_objects_v2_entered: None,
}
}
}
/// Queue-driven StorageBackend test double. Holds internal state behind a
/// single Mutex. Tests configure response queues via queue_* methods,
/// wrap the backend in Arc, hand one clone to the protocol driver being
/// tested, and keep another clone for observation. Method calls are
/// fire-and-forget from the driver's perspective and synchronous on the
/// test side.
pub struct DummyBackend {
inner: Mutex<Inner>,
}
impl Default for DummyBackend {
fn default() -> Self {
Self::new()
}
}
impl DummyBackend {
/// Build an empty backend. Every method returns a default not-found or
/// configured-miss error until a queue is populated.
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner::new()),
}
}
// Queue-configuration helpers. Each test stages the responses it
// expects in order. The method pops in FIFO order.
/// Queue a head_object Ok response with the given size and mtime.
pub fn queue_head_object_ok(&self, size: u64, mtime: Option<Timestamp>) {
let out = HeadObjectOutput {
content_length: Some(size as i64),
last_modified: mtime,
..Default::default()
};
self.inner.lock().expect("lock").head_object.push_back(Ok(out));
}
/// Queue a head_object NoSuchKey response for the next call.
pub fn queue_head_object_not_found(&self) {
self.inner
.lock()
.expect("lock")
.head_object
.push_back(Err(DummyError::NoSuchKey(String::from("head_object"))));
}
/// Queue a put_object Ok response (default PutObjectOutput).
pub fn queue_put_object_ok(&self) {
self.inner
.lock()
.expect("lock")
.put_object
.push_back(Ok(PutObjectOutput::default()));
}
/// Queue a put_object error. Used by the commit_write retry tests
/// to script SlowDown / AccessDenied sequences against the
/// rustfs_utils::retry::is_s3code_in_message_retryable predicate.
pub fn queue_put_object_err(&self, err: DummyError) {
self.inner.lock().expect("lock").put_object.push_back(Err(err));
}
/// Number of unconsumed put_object responses left in the queue.
/// Used to assert that a non-retryable error did not consume more
/// than one queued response.
pub fn put_object_queue_len(&self) -> usize {
self.inner.lock().expect("lock").put_object.len()
}
/// Queue an arbitrary head_object error for the next call. Used by
/// the run_backend_with_err pass-through test that verifies the
/// backend Err reaches the caller unchanged when no timeout fires.
pub fn queue_head_object_err(&self, err: DummyError) {
self.inner.lock().expect("lock").head_object.push_back(Err(err));
}
/// Queue a create_multipart_upload Ok carrying the given upload_id.
pub fn queue_create_multipart_upload_ok(&self, upload_id: impl Into<String>) {
let out = CreateMultipartUploadOutput {
upload_id: Some(upload_id.into()),
..Default::default()
};
self.inner.lock().expect("lock").create_multipart_upload.push_back(Ok(out));
}
/// Queue an upload_part Ok response carrying the given ETag. The
/// string is wrapped in ETag::Strong. Callers that need ETag::Weak
/// can queue a custom UploadPartOutput instead of using this helper.
pub fn queue_upload_part_ok(&self, e_tag: impl Into<String>) {
let out = UploadPartOutput {
e_tag: Some(ETag::Strong(e_tag.into())),
..Default::default()
};
self.inner.lock().expect("lock").upload_part.push_back(Ok(out));
}
/// Queue an upload_part Ok response with no ETag. Exercises the
/// missing-ETag branch a driver may guard against.
pub fn queue_upload_part_ok_without_etag(&self) {
let out = UploadPartOutput {
e_tag: None,
..Default::default()
};
self.inner.lock().expect("lock").upload_part.push_back(Ok(out));
}
/// Queue an upload_part error. The error string flows through the
/// driver's error-mapping helper, so Injected("AccessDenied") produces
/// a permission-denied status at the driver boundary.
pub fn queue_upload_part_err(&self, err: DummyError) {
self.inner.lock().expect("lock").upload_part.push_back(Err(err));
}
/// Queue a complete_multipart_upload Ok response.
pub fn queue_complete_multipart_upload_ok(&self) {
self.inner
.lock()
.expect("lock")
.complete_multipart_upload
.push_back(Ok(CompleteMultipartUploadOutput::default()));
}
/// Queue a complete_multipart_upload error.
pub fn queue_complete_multipart_upload_err(&self, err: DummyError) {
self.inner.lock().expect("lock").complete_multipart_upload.push_back(Err(err));
}
/// Queue a list_objects_v2 Ok response with no contents and no
/// common prefixes. The directory-empty validate path treats this
/// as "directory is empty".
pub fn queue_list_objects_v2_ok_empty(&self) {
self.inner
.lock()
.expect("lock")
.list_objects_v2
.push_back(Ok(ListObjectsV2Output::default()));
}
/// Queue a list_objects_v2 error. Used to verify that callers do
/// not fall through to a destructive operation when the empty-check
/// itself fails.
pub fn queue_list_objects_v2_err(&self, err: DummyError) {
self.inner.lock().expect("lock").list_objects_v2.push_back(Err(err));
}
/// Queue a get_object_range error. Used to verify that the SFTP read
/// handler surfaces a non-Eof backend failure as an error-level log
/// event after the wire response has been mapped through
/// s3_error_to_sftp.
pub fn queue_get_object_range_err(&self, err: DummyError) {
self.inner.lock().expect("lock").get_object_range.push_back(Err(err));
}
/// Queue a get_object_range Ok response carrying the given bytes as
/// the streaming body. content_length is set to bytes.len().
pub fn queue_get_object_range_bytes(&self, payload: Vec<u8>) {
let size = payload.len() as i64;
let body = Bytes::from(payload);
let blob = StreamingBlob::wrap(stream::once(async move { Ok::<Bytes, std::io::Error>(body) }));
let out = GetObjectOutput {
body: Some(blob),
content_length: Some(size),
..Default::default()
};
self.inner.lock().expect("lock").get_object_range.push_back(Ok(out));
}
/// Queue a get_object_range Ok response whose body emits one
/// initial chunk and then stalls forever on the next .next() poll.
/// Used by the chunk-deadline regression test to verify that a
/// stalled mid-stream backend is reaped by the per-chunk timeout
/// rather than pinning the SFTP session task indefinitely.
/// reported_content_length sets the GetObjectOutput.content_length
/// field so the read handler is happy to keep iterating past the
/// initial chunk.
pub fn queue_get_object_range_stalling_after_chunk(&self, initial_chunk: Vec<u8>, reported_content_length: i64) {
let head = Bytes::from(initial_chunk);
let body_stream = stream::once(async move { Ok::<Bytes, std::io::Error>(head) })
.chain(stream::pending::<Result<Bytes, std::io::Error>>());
let blob = StreamingBlob::wrap(body_stream);
let out = GetObjectOutput {
body: Some(blob),
content_length: Some(reported_content_length),
..Default::default()
};
self.inner.lock().expect("lock").get_object_range.push_back(Ok(out));
}
/// Configure upload_part to stall indefinitely. Each call notifies the
/// supplied Notify once, then awaits std::future::pending, which the
/// caller cancels by dropping the future.
pub fn stall_upload_part(&self, entered: Arc<Notify>) {
let mut inner = self.inner.lock().expect("lock");
inner.stall_upload_part = true;
inner.upload_part_entered = Some(entered);
}
/// Configure put_object to stall indefinitely. Each call notifies
/// the supplied Notify once, then awaits std::future::pending. The
/// run_backend timeout integration test uses this to confirm the
/// driver's deadline fires when the backend never returns.
pub fn stall_put_object(&self, entered: Arc<Notify>) {
let mut inner = self.inner.lock().expect("lock");
inner.stall_put_object = true;
inner.put_object_entered = Some(entered);
}
/// Configure list_objects_v2 to stall indefinitely. Each call
/// notifies the supplied Notify once, then awaits
/// std::future::pending. The cursor-corruption regression test
/// uses this to cancel a READDIR mid-await and assert the
/// un-advanced cursor reissues the same first page.
pub fn stall_list_objects_v2(&self, entered: Arc<Notify>) {
let mut inner = self.inner.lock().expect("lock");
inner.stall_list_objects_v2 = true;
inner.list_objects_v2_entered = Some(entered);
}
/// Turn the list_objects_v2 stall back off so subsequent calls
/// pop from the queue normally. Used by the cursor-corruption
/// regression test after the first READDIR has been cancelled
/// mid-await, so the re-issued READDIR can complete against a
/// queued Ok response.
pub fn clear_stall_list_objects_v2(&self) {
let mut inner = self.inner.lock().expect("lock");
inner.stall_list_objects_v2 = false;
inner.list_objects_v2_entered = None;
}
// Observers. Tests call these after the driver has run to verify the
// backend received the expected calls.
/// Snapshot the abort_multipart_upload call log.
pub fn abort_multipart_calls(&self) -> Vec<AbortCall> {
self.inner.lock().expect("lock").abort_multipart_calls.clone()
}
/// Snapshot the upload_part call log.
pub fn upload_part_calls(&self) -> Vec<UploadPartCall> {
self.inner.lock().expect("lock").upload_part_calls.clone()
}
/// Snapshot the complete_multipart_upload call log.
pub fn complete_multipart_calls(&self) -> Vec<CompleteCall> {
self.inner.lock().expect("lock").complete_multipart_calls.clone()
}
/// Snapshot the head_object call log.
pub fn head_object_calls(&self) -> Vec<HeadObjectCall> {
self.inner.lock().expect("lock").head_object_calls.clone()
}
}
#[async_trait]
impl StorageBackend for DummyBackend {
type Error = DummyError;
async fn get_object(
&self,
bucket: &str,
key: &str,
_ak: &str,
_sk: &str,
_start_pos: Option<u64>,
) -> Result<GetObjectOutput, Self::Error> {
match self.inner.lock().expect("lock").get_object.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
}
}
async fn get_object_range(
&self,
bucket: &str,
key: &str,
_ak: &str,
_sk: &str,
_start_pos: u64,
_length: u64,
) -> Result<GetObjectOutput, Self::Error> {
match self.inner.lock().expect("lock").get_object_range.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
}
}
async fn put_object(&self, _input: PutObjectInput, _ak: &str, _sk: &str) -> Result<PutObjectOutput, Self::Error> {
// Decide control flow while holding the lock. Release before
// awaiting so the stall path does not hold the Mutex across
// an await point.
let (stall, entered, popped) = {
let mut inner = self.inner.lock().expect("lock");
let stall = inner.stall_put_object;
let entered = inner.put_object_entered.clone();
let popped = if stall { None } else { inner.put_object.pop_front() };
(stall, entered, popped)
};
if stall {
if let Some(n) = entered {
n.notify_one();
}
std::future::pending::<Result<PutObjectOutput, Self::Error>>().await
} else {
match popped {
Some(r) => r,
None => Ok(PutObjectOutput::default()),
}
}
}
async fn delete_object(&self, bucket: &str, key: &str, _ak: &str, _sk: &str) -> Result<DeleteObjectOutput, Self::Error> {
match self.inner.lock().expect("lock").delete_object.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
}
}
async fn head_object(&self, bucket: &str, key: &str, _ak: &str, _sk: &str) -> Result<HeadObjectOutput, Self::Error> {
{
let mut inner = self.inner.lock().expect("lock");
inner.head_object_calls.push(HeadObjectCall {
bucket: bucket.to_string(),
key: key.to_string(),
});
}
match self.inner.lock().expect("lock").head_object.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
}
}
async fn head_bucket(&self, bucket: &str, _ak: &str, _sk: &str) -> Result<HeadBucketOutput, Self::Error> {
match self.inner.lock().expect("lock").head_bucket.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchBucket(bucket.to_string())),
}
}
async fn list_objects_v2(
&self,
_input: ListObjectsV2Input,
_ak: &str,
_sk: &str,
) -> Result<ListObjectsV2Output, Self::Error> {
// Decide control flow while holding the lock. Release before
// awaiting so the stall path does not hold the Mutex across
// an await point.
let (stall, entered, popped) = {
let mut inner = self.inner.lock().expect("lock");
let stall = inner.stall_list_objects_v2;
let entered = inner.list_objects_v2_entered.clone();
let popped = if stall { None } else { inner.list_objects_v2.pop_front() };
(stall, entered, popped)
};
if stall {
if let Some(n) = entered {
n.notify_one();
}
std::future::pending::<Result<ListObjectsV2Output, Self::Error>>().await
} else {
match popped {
Some(r) => r,
None => Ok(ListObjectsV2Output::default()),
}
}
}
async fn list_buckets(&self, _ak: &str, _sk: &str) -> Result<ListBucketsOutput, Self::Error> {
match self.inner.lock().expect("lock").list_buckets.pop_front() {
Some(r) => r,
None => Ok(ListBucketsOutput::default()),
}
}
async fn create_bucket(&self, _bucket: &str, _ak: &str, _sk: &str) -> Result<CreateBucketOutput, Self::Error> {
match self.inner.lock().expect("lock").create_bucket.pop_front() {
Some(r) => r,
None => Err(DummyError::Unconfigured("create_bucket")),
}
}
async fn delete_bucket(&self, bucket: &str, _ak: &str, _sk: &str) -> Result<DeleteBucketOutput, Self::Error> {
match self.inner.lock().expect("lock").delete_bucket.pop_front() {
Some(r) => r,
None => Err(DummyError::NoSuchBucket(bucket.to_string())),
}
}
async fn copy_object(&self, _input: CopyObjectInput, _ak: &str, _sk: &str) -> Result<CopyObjectOutput, Self::Error> {
match self.inner.lock().expect("lock").copy_object.pop_front() {
Some(r) => r,
None => Err(DummyError::Unconfigured("copy_object")),
}
}
async fn create_multipart_upload(
&self,
_input: CreateMultipartUploadInput,
_ak: &str,
_sk: &str,
) -> Result<CreateMultipartUploadOutput, Self::Error> {
match self.inner.lock().expect("lock").create_multipart_upload.pop_front() {
Some(r) => r,
None => Err(DummyError::Unconfigured("create_multipart_upload")),
}
}
async fn upload_part(&self, input: UploadPartInput, _ak: &str, _sk: &str) -> Result<UploadPartOutput, Self::Error> {
// Record the call and decide the control flow while holding the
// lock. Release the lock before awaiting so the stall path does
// not hold the Mutex across an await point.
let (stall, entered, popped) = {
let mut inner = self.inner.lock().expect("lock");
inner.upload_part_calls.push(UploadPartCall {
bucket: input.bucket.to_string(),
key: input.key.to_string(),
upload_id: input.upload_id.to_string(),
part_number: input.part_number,
content_length: input.content_length,
});
let stall = inner.stall_upload_part;
let entered = inner.upload_part_entered.clone();
let popped = if stall { None } else { inner.upload_part.pop_front() };
(stall, entered, popped)
};
if stall {
if let Some(n) = entered {
n.notify_one();
}
std::future::pending::<Result<UploadPartOutput, Self::Error>>().await
} else {
match popped {
Some(r) => r,
None => Err(DummyError::Unconfigured("upload_part")),
}
}
}
async fn complete_multipart_upload(
&self,
input: CompleteMultipartUploadInput,
_ak: &str,
_sk: &str,
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
let part_count = input
.multipart_upload
.as_ref()
.and_then(|mpu| mpu.parts.as_ref().map(|p| p.len()))
.unwrap_or(0);
{
let mut inner = self.inner.lock().expect("lock");
inner.complete_multipart_calls.push(CompleteCall {
bucket: input.bucket.to_string(),
key: input.key.to_string(),
upload_id: input.upload_id.to_string(),
part_count,
});
}
match self.inner.lock().expect("lock").complete_multipart_upload.pop_front() {
Some(r) => r,
None => Err(DummyError::Unconfigured("complete_multipart_upload")),
}
}
async fn abort_multipart_upload(
&self,
input: AbortMultipartUploadInput,
_ak: &str,
_sk: &str,
) -> Result<AbortMultipartUploadOutput, Self::Error> {
{
let mut inner = self.inner.lock().expect("lock");
inner.abort_multipart_calls.push(AbortCall {
bucket: input.bucket.to_string(),
key: input.key.to_string(),
upload_id: input.upload_id.to_string(),
});
}
match self.inner.lock().expect("lock").abort_multipart_upload.pop_front() {
Some(r) => r,
None => Ok(AbortMultipartUploadOutput::default()),
}
}
async fn upload_part_copy(
&self,
_input: UploadPartCopyInput,
_ak: &str,
_sk: &str,
) -> Result<UploadPartCopyOutput, Self::Error> {
match self.inner.lock().expect("lock").upload_part_copy.pop_front() {
Some(r) => r,
None => Err(DummyError::Unconfigured("upload_part_copy")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dummy_backend_reports_not_found_by_default() {
let backend = DummyBackend::new();
let result = backend.head_object("b", "k", "ak", "sk").await;
let Err(err) = result else {
panic!("default head_object must return an error");
};
assert!(
err.to_string().contains("NoSuchKey"),
"default error must carry the NoSuchKey substring so drivers map it to not-found; got: {err}",
);
}
#[tokio::test]
async fn dummy_backend_returns_queued_head_object_response() {
let backend = DummyBackend::new();
backend.queue_head_object_ok(42, None);
let out = backend.head_object("b", "k", "ak", "sk").await.expect("queued Ok");
assert_eq!(out.content_length, Some(42));
}
#[tokio::test]
async fn dummy_backend_logs_abort_multipart_calls() {
let backend = Arc::new(DummyBackend::new());
let input = AbortMultipartUploadInput::builder()
.bucket("b".to_string())
.key("k".to_string())
.upload_id("UP-1".to_string())
.build()
.expect("build");
backend.abort_multipart_upload(input, "ak", "sk").await.expect("Ok");
let calls = backend.abort_multipart_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].upload_id, "UP-1");
}
#[tokio::test]
async fn dummy_backend_unconfigured_errors_loudly() {
let backend = DummyBackend::new();
let err = backend
.create_multipart_upload(
CreateMultipartUploadInput::builder()
.bucket("b".to_string())
.key("k".to_string())
.build()
.expect("build"),
"ak",
"sk",
)
.await
.expect_err("default create_multipart_upload must error");
assert!(err.to_string().contains("not configured"));
}
}
+312 -9
View File
@@ -24,8 +24,23 @@ use super::session::SessionContext;
/// Authorization errors
#[derive(Debug, Error)]
pub enum AuthorizationError {
/// Policy denied the principal the requested action. Distinct
/// from IamUnavailable so protocol drivers can map a deny to
/// PermissionDenied while mapping a transient IAM outage to
/// the spec-equivalent Failure (no SFTPv3 service-unavailable
/// status exists).
#[error("Access denied")]
AccessDenied,
/// The IAM layer was unreachable or returned an error other
/// than the expected Allow/Deny verdict. Indistinguishable
/// from AccessDenied at the wire boundary in earlier
/// implementations; protocol drivers now branch on this
/// variant to surface a warn log naming the failing
/// operation so operators can correlate session errors with
/// IAM degradation.
#[error("IAM system unavailable")]
IamUnavailable,
}
/// S3 actions that can be performed through the gateway
@@ -211,16 +226,56 @@ pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Act
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
super::session::Protocol::Sftp => match action {
// Bucket operations: SFTP exposes top-level buckets as directories.
S3Action::CreateBucket => true, // MKDIR at the root
S3Action::DeleteBucket => true, // RMDIR at the root
S3Action::ListBucket => true, // OPENDIR/READDIR within a bucket
S3Action::ListBuckets => true, // OPENDIR/READDIR at the root
S3Action::HeadBucket => true, // STAT/LSTAT of a bucket entry
// Object operations
S3Action::GetObject => true, // OPEN/READ
S3Action::PutObject => true, // OPEN(WRITE)/WRITE/CLOSE
S3Action::DeleteObject => true, // REMOVE
S3Action::HeadObject => true, // STAT/LSTAT/FSTAT
S3Action::CopyObject => true, // RENAME maps to copy + delete
// Multipart operations: streamed PUT path used by the write driver.
S3Action::CreateMultipartUpload => true,
S3Action::UploadPart => true,
S3Action::CompleteMultipartUpload => true,
S3Action::AbortMultipartUpload => true,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations: SFTP has no equivalent surface.
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
}
}
/// Check if a principal is allowed to perform an S3 action
pub async fn is_authorized(session_context: &SessionContext, action: &S3Action, bucket: &str, object: Option<&str>) -> bool {
/// Check if a principal is allowed to perform an S3 action.
/// Returns Ok(true) when the policy allows the action, Ok(false) when
/// the policy denies it, and Err(AuthorizationError::IamUnavailable)
/// when the IAM layer is unreachable (rustfs_iam::get fails). The
/// IamUnavailable case is distinct from a Deny so protocol drivers
/// can return a transient-failure status with a warn log instead of
/// the permanent permission-denied status that a Deny produces.
pub async fn is_authorized(
session_context: &SessionContext,
action: &S3Action,
bucket: &str,
object: Option<&str>,
) -> Result<bool, AuthorizationError> {
let iam_sys = match rustfs_iam::get() {
Ok(sys) => sys,
Err(e) => {
error!("IAM system unavailable: {}", e);
return false;
return Err(AuthorizationError::IamUnavailable);
}
};
@@ -252,25 +307,273 @@ pub async fn is_authorized(session_context: &SessionContext, action: &S3Action,
deny_only: false,
};
iam_sys.is_allowed(&args).await
Ok(iam_sys.is_allowed(&args).await)
}
/// Authorize an operation and return an error if not authorized
/// Authorize an operation and return an error if not authorized.
/// AccessDenied covers both the protocol-not-supported case and the
/// policy-denies case. IamUnavailable propagates from is_authorized
/// when the IAM layer is unreachable; protocol drivers map it to a
/// transient-failure status with a warn log rather than the
/// permanent permission-denied status that AccessDenied produces.
pub async fn authorize_operation(
session_context: &SessionContext,
action: &S3Action,
bucket: &str,
object: Option<&str>,
) -> Result<(), AuthorizationError> {
// SECURITY: the next two lines are cfg(test)-gated. Release builds strip
// them and run only the IAM path below. Implementation and verification
// recipe are in the test_auth_override submodule at the bottom of this file.
#[cfg(test)]
if let Some(decision) = test_auth_override::consult(action, bucket, object) {
return decision;
}
// check if the operation is supported
if !is_operation_supported(session_context.protocol, action) {
return Err(AuthorizationError::AccessDenied);
}
// check IAM authorization
if is_authorized(session_context, action, bucket, object).await {
Ok(())
} else {
Err(AuthorizationError::AccessDenied)
match is_authorized(session_context, action, bucket, object).await {
Ok(true) => Ok(()),
Ok(false) => Err(AuthorizationError::AccessDenied),
Err(e) => Err(e),
}
}
/// Test-only authorisation override for driver-level unit tests.
///
/// Every item in this module is gated on #[cfg(test)], and the single
/// call site in authorize_operation is also #[cfg(test)]-gated, so
/// release builds contain none of this code and run only the IAM path.
///
/// A unit test installs a decide closure via with_test_auth_override,
/// runs an async body that calls authorize_operation, and the override
/// is cleared on scope exit by a Drop guard so a panic inside the body
/// cannot leak the decision into later tests on the same thread.
#[cfg(test)]
pub mod test_auth_override {
use super::{AuthorizationError, S3Action};
use std::cell::{Cell, RefCell};
type DecideFn = Box<dyn Fn(&S3Action, &str, Option<&str>) -> bool>;
thread_local! {
/// Current per-thread Allow/Deny override. None means no test
/// has installed one and authorize_operation falls through to
/// its IAM path.
static OVERRIDE: RefCell<Option<DecideFn>> = const { RefCell::new(None) };
/// Per-thread IAM-unavailable injection. When true, consult
/// short-circuits with IamUnavailable so tests can verify the
/// IAM-outage branch without standing up a real degraded IAM
/// fixture. Takes precedence over the Allow/Deny OVERRIDE.
static IAM_UNAVAILABLE: Cell<bool> = const { Cell::new(false) };
}
/// Consult the per-thread overrides. IamUnavailable takes
/// precedence over the Allow/Deny override so a test combining
/// both flags can verify that the unavailable branch fires before
/// any policy evaluation. Returns Some(decision) when any
/// override is active on the current thread, None otherwise.
/// Called exclusively from authorize_operation's cfg(test)-gated
/// fast path.
pub(super) fn consult(action: &S3Action, bucket: &str, object: Option<&str>) -> Option<Result<(), AuthorizationError>> {
if IAM_UNAVAILABLE.with(|c| c.get()) {
return Some(Err(AuthorizationError::IamUnavailable));
}
OVERRIDE.with(|cell| {
cell.borrow().as_ref().map(|decide| {
if decide(action, bucket, object) {
Ok(())
} else {
Err(AuthorizationError::AccessDenied)
}
})
})
}
/// Install a test-only authorisation decision for the duration of the
/// supplied async body, then clear it. A Drop guard performs the
/// clearing so a panic inside the body does not leak the decision
/// into later tests on the same thread.
///
/// Example:
/// let result = with_test_auth_override(
/// |_action, _bucket, _object| true,
/// async { authorize_operation(&ctx, &action, "b", None).await },
/// ).await;
pub async fn with_test_auth_override<Fut, R>(decide: impl Fn(&S3Action, &str, Option<&str>) -> bool + 'static, body: Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
OVERRIDE.with(|cell| *cell.borrow_mut() = None);
}
}
OVERRIDE.with(|cell| *cell.borrow_mut() = Some(Box::new(decide)));
let _reset = Reset;
body.await
}
/// Inject AuthorizationError::IamUnavailable for every
/// authorize_operation call inside the supplied async body, then
/// clear the flag on scope exit (Drop guard handles the panic
/// case). Used by the IAM-outage tests that verify protocol
/// drivers map the unreachable variant to a transient-failure
/// status with a warn log rather than to PermissionDenied.
pub async fn with_test_iam_unavailable<Fut, R>(body: Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
IAM_UNAVAILABLE.with(|c| c.set(false));
}
}
IAM_UNAVAILABLE.with(|c| c.set(true));
let _reset = Reset;
body.await
}
}
/// Ergonomic re-export so tests reach the helpers via
/// common::gateway::with_test_auth_override rather than nesting
/// the submodule path.
#[cfg(test)]
pub use test_auth_override::{with_test_auth_override, with_test_iam_unavailable};
#[cfg(test)]
mod tests {
use super::*;
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
use rustfs_policy::auth::UserIdentity;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
fn test_session() -> SessionContext {
let principal = ProtocolPrincipal::new(Arc::new(UserIdentity::default()));
SessionContext::new(principal, Protocol::Sftp, IpAddr::V4(Ipv4Addr::LOCALHOST))
}
#[tokio::test]
async fn with_test_auth_override_allow_returns_ok() {
let session = test_session();
let result = with_test_auth_override(|_action, _bucket, _object| true, async {
authorize_operation(&session, &S3Action::GetObject, "b", None).await
})
.await;
assert!(result.is_ok(), "override returning true must make authorize_operation succeed");
}
#[tokio::test]
async fn with_test_auth_override_deny_returns_err() {
let session = test_session();
let result = with_test_auth_override(|_action, _bucket, _object| false, async {
authorize_operation(&session, &S3Action::PutObject, "b", Some("k")).await
})
.await;
assert!(matches!(result, Err(AuthorizationError::AccessDenied)));
}
#[tokio::test]
async fn with_test_auth_override_clears_after_body() {
let session = test_session();
// Discard the body Result. The test exercises the clear-on-return
// side-effect of with_test_auth_override, not the body's outcome.
let _ = with_test_auth_override(|_, _, _| true, async { Result::<(), ()>::Ok(()) }).await;
// After the helper returns, the IAM path runs. IAM is not
// initialised in this test binary, so is_authorized returns
// IamUnavailable. A leaked override would have produced Ok.
let result = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
#[tokio::test]
async fn with_test_auth_override_closure_sees_action_bucket_object() {
let session = test_session();
let result = with_test_auth_override(
|action, bucket, object| {
matches!(action, S3Action::UploadPart) && bucket == "only-this-bucket" && object == Some("only-this-key")
},
async {
let allowed =
authorize_operation(&session, &S3Action::UploadPart, "only-this-bucket", Some("only-this-key")).await;
let denied_by_action =
authorize_operation(&session, &S3Action::GetObject, "only-this-bucket", Some("only-this-key")).await;
let denied_by_bucket =
authorize_operation(&session, &S3Action::UploadPart, "other-bucket", Some("only-this-key")).await;
(allowed, denied_by_action, denied_by_bucket)
},
)
.await;
assert!(result.0.is_ok());
assert!(matches!(result.1, Err(AuthorizationError::AccessDenied)));
assert!(matches!(result.2, Err(AuthorizationError::AccessDenied)));
}
/// Regression guard for the SECURITY invariant: the test override
/// is reachable only under cfg(test). The body depends on items in
/// the test_auth_override module, so if a future edit moves any of
/// those items out of a cfg(test) gate the build of THIS test
/// binary still succeeds (cfg(test) is active here) but the
/// reviewer recipe documented in test_auth_override's module
/// comment will start reporting matches in release expansion. Run
/// the recipe before shipping.
#[tokio::test]
async fn override_roundtrip_confirms_consult_path_under_cfg_test() {
let session = test_session();
// Without an installed override, consult returns None and the
// IAM path runs. IAM is not initialised in tests so the path
// returns IamUnavailable.
let without = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(without, Err(AuthorizationError::IamUnavailable)));
// With an installed override, consult returns Some and
// authorize_operation returns immediately with the override's
// decision, bypassing the IAM path.
let with = with_test_auth_override(|_, _, _| true, async {
authorize_operation(&session, &S3Action::GetObject, "b", None).await
})
.await;
assert!(with.is_ok());
// After the scope, consult returns None again and the IAM path
// reclaims the authorization decision.
let after = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
assert!(matches!(after, Err(AuthorizationError::IamUnavailable)));
}
/// IamUnavailable is distinct from AccessDenied at the gateway
/// boundary, so protocol drivers can branch on it. with_test_iam_unavailable
/// short-circuits authorize_operation with the IamUnavailable
/// variant regardless of any installed Allow/Deny override, and
/// the precedence is documented in test_auth_override::consult.
#[tokio::test]
async fn with_test_iam_unavailable_returns_iam_unavailable_variant() {
let session = test_session();
let result = with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))).await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
/// IamUnavailable beats an installed Allow override, so a test
/// combining both flags exercises the documented precedence rule
/// in test_auth_override::consult: a degraded IAM is observed
/// before any policy evaluation.
#[tokio::test]
async fn with_test_iam_unavailable_takes_precedence_over_allow_override() {
let session = test_session();
let result = with_test_auth_override(
|_, _, _| true,
with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))),
)
.await;
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
}
}
+3
View File
@@ -16,6 +16,9 @@ pub mod client;
pub mod gateway;
pub mod session;
#[cfg(test)]
pub(crate) mod dummy_storage;
pub use client::s3::StorageBackend as S3StorageBackend;
pub use gateway::{AuthorizationError, S3Action, authorize_operation, is_operation_supported};
pub use session::{ProtocolPrincipal, SessionContext};
+42
View File
@@ -14,6 +14,8 @@
use rustfs_policy::auth::UserIdentity;
use std::net::IpAddr;
#[cfg(test)]
use std::net::Ipv4Addr;
use std::sync::Arc;
/// Protocol types
@@ -22,6 +24,7 @@ pub enum Protocol {
Ftps,
Swift,
WebDav,
Sftp,
}
/// Protocol principal representing an authenticated user
@@ -66,3 +69,42 @@ impl SessionContext {
self.principal.access_key()
}
}
/// Build a SessionContext suitable for driver-level unit tests. The
/// principal has an empty access key and an empty secret key. Auth
/// decisions in tests come from the gateway test override, not from
/// these credentials. The fields are inspected only when a test
/// specifically asserts on them. Callers pick the Protocol variant
/// that matches the driver under test.
#[cfg(test)]
pub fn test_session(protocol: Protocol) -> SessionContext {
let principal = ProtocolPrincipal::new(Arc::new(UserIdentity::default()));
SessionContext::new(principal, protocol, IpAddr::V4(Ipv4Addr::LOCALHOST))
}
#[cfg(test)]
mod regression_prevention {
use super::*;
// Compile-time check that every Protocol variant is acknowledged here.
// This is intentionally an exhaustive match with no wildcard arm: if a
// variant is added without being named, or if any variant is removed,
// this test file will fail to compile.
#[test]
fn protocol_variants_are_named() {
fn _check(protocol: Protocol) {
match protocol {
Protocol::Ftps => {}
Protocol::Swift => {}
Protocol::WebDav => {}
Protocol::Sftp => {}
}
}
}
#[test]
fn test_session_carries_supplied_protocol() {
assert_eq!(test_session(Protocol::Sftp).protocol, Protocol::Sftp);
assert_eq!(test_session(Protocol::Ftps).protocol, Protocol::Ftps);
}
}
+4
View File
@@ -68,4 +68,8 @@ pub mod defaults {
/// Default WebDAV server address
#[cfg(feature = "webdav")]
pub const DEFAULT_WEBDAV_ADDRESS: &str = "0.0.0.0:8080";
/// Default SFTP server address
#[cfg(feature = "sftp")]
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
}
+6
View File
@@ -26,6 +26,9 @@ pub mod swift;
#[cfg(feature = "webdav")]
pub mod webdav;
#[cfg(feature = "sftp")]
pub mod sftp;
pub use common::session::Protocol;
pub use common::{AuthorizationError, ProtocolPrincipal, S3Action, SessionContext, authorize_operation};
@@ -37,3 +40,6 @@ pub use swift::handler::SwiftService;
#[cfg(feature = "webdav")]
pub use webdav::{config::WebDavConfig, server::WebDavServer};
#[cfg(feature = "sftp")]
pub use sftp::{SftpConfig, SftpInitError, SftpServer};
+241
View File
@@ -0,0 +1,241 @@
// 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.
//! Attribute helpers and the do_stat dispatcher behind STAT, LSTAT, and
//! FSTAT. The free functions are pure conversions; the do_stat method
//! sits on SftpDriver and runs the bucket/object branching.
use super::constants::posix::{POSIX_DIR_MODE, POSIX_FILE_MODE};
use super::driver::SftpDriver;
use super::errors::{SftpError, is_not_found_error, s3_error_to_sftp};
use super::paths::parse_s3_path;
use crate::common::client::s3::StorageBackend;
use crate::common::gateway::S3Action;
use russh_sftp::protocol::{File, FileAttributes, StatusCode};
use s3s::dto::ListObjectsV2Input;
/// Build the SFTP FileAttributes struct returned by STAT, LSTAT, and
/// FSTAT. Callers are responsible for any clamping or conversion of the
/// mtime field. See timestamp_to_mtime for the conversion used when the
/// source is an s3s Timestamp.
pub(super) fn s3_attrs_to_sftp(size: u64, mtime: Option<u32>, is_dir: bool) -> FileAttributes {
let permissions = if is_dir { POSIX_DIR_MODE } else { POSIX_FILE_MODE };
FileAttributes {
size: Some(if is_dir { 0 } else { size }),
uid: Some(0),
gid: Some(0),
user: None,
group: None,
permissions: Some(permissions),
atime: mtime,
mtime,
}
}
/// Convert an s3s Timestamp into the u32 seconds field SFTPv3 expects.
/// Pre-1970 values clamp to 0. Post-2106 values clamp to u32::MAX. The
/// clamps prevent the i64-to-u32 cast from wrapping.
pub(super) fn timestamp_to_mtime(ts: Option<s3s::dto::Timestamp>) -> Option<u32> {
ts.map(|t| {
let odt: time::OffsetDateTime = t.into();
let secs = odt.unix_timestamp().clamp(0, u32::MAX as i64);
secs as u32
})
}
/// Build the ls -l style longname string for a directory entry. Delegates
/// to File::new in russh_sftp, which formats the line from the attributes
/// (type prefix "d" or "-", permission triple, size, timestamp). The
/// filename is sanitised before composition so a key containing CR or LF
/// cannot inject a forged second entry in clients that split longname
/// output on newline.
pub(super) fn generate_longname(filename: &str, attrs: &FileAttributes) -> String {
let safe = super::paths::sanitise_control_bytes(filename);
File::new(safe.as_ref(), attrs.clone()).longname
}
impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
/// Resolve the attributes for raw_path. STAT and LSTAT both call do_stat
/// because the SFTP server has no symlink concept (S3 has no symlinks).
/// Root yields default directory attrs without a network call.
///
/// Bucket paths run authorize_operation(HeadBucket) followed by a
/// HeadBucket call. Success yields default directory attributes
/// (HeadBucket exposes neither size nor mtime).
///
/// Object paths run authorize_operation(HeadObject) followed by a
/// HeadObject call. Success yields file attributes built from
/// content_length (clamped non-negative) and last_modified (clamped to
/// the u32 range).
pub(super) async fn do_stat(&self, raw_path: &str) -> Result<FileAttributes, SftpError> {
let (bucket, key) = parse_s3_path(raw_path)?;
if bucket.is_empty() {
// Root. Every authenticated principal sees root as a directory.
return Ok(s3_attrs_to_sftp(0, None, true));
}
match key {
// Bucket-level path: input resolved to a bucket with no object
// component. HeadBucket returns 200 on existence or a backend
// error mapped by s3_error_to_sftp. Default directory attrs
// on success. Size and mtime are not returned by HeadBucket.
None => {
self.authorize(&S3Action::HeadBucket, &bucket, None).await?;
self.run_backend("head_bucket", self.storage.head_bucket(&bucket, self.access_key(), self.secret_key()))
.await?;
Ok(s3_attrs_to_sftp(0, None, true))
}
// Object path: try HeadObject first (the path may be a file).
// If HeadObject returns not-found, fall back to a directory
// check: list with prefix "{key}/" and max_keys=1. If any
// content or sub-prefix exists, this path is a directory and
// gets default directory attrs. S3 has no first-class
// directories, so both explicit markers (__XLDIR__) and
// implicit prefixes (objects exist under the prefix) must be
// detected. Without this fallback, sftp clients that STAT
// before OPENDIR (OpenSSH, FileZilla) fail to list
// sub-directories.
Some(object_key) => {
self.authorize(&S3Action::HeadObject, &bucket, Some(&object_key)).await?;
match self
.run_backend_with_err(
"head_object",
self.storage
.head_object(&bucket, &object_key, self.access_key(), self.secret_key()),
)
.await?
{
Ok(out) => {
let size = out.content_length.unwrap_or(0).max(0) as u64;
let mtime = timestamp_to_mtime(out.last_modified);
Ok(s3_attrs_to_sftp(size, mtime, false))
}
Err(e) if is_not_found_error(&e) => {
// No object at this key. Check whether it is a
// directory by listing with the key as a prefix.
let prefix = format!("{object_key}/");
self.authorize(&S3Action::ListBucket, &bucket, Some(prefix.as_str())).await?;
let input = ListObjectsV2Input::builder()
.bucket(bucket.clone())
.prefix(Some(prefix))
.delimiter(Some("/".to_string()))
.max_keys(Some(1))
.build()
.map_err(|e| s3_error_to_sftp("build_list_objects", e))?;
let out = self
.run_backend(
"list_objects_v2",
self.storage.list_objects_v2(input, self.access_key(), self.secret_key()),
)
.await?;
let has_contents = out.contents.map(|c| !c.is_empty()).unwrap_or(false);
let has_prefixes = out.common_prefixes.map(|c| !c.is_empty()).unwrap_or(false);
if has_contents || has_prefixes {
Ok(s3_attrs_to_sftp(0, None, true))
} else {
tracing::debug!(
bucket = %bucket,
key = %object_key,
"STAT fallback: HeadObject not-found and list returned no contents or prefixes. Returning NoSuchFile",
);
Err(SftpError::code(StatusCode::NoSuchFile))
}
}
Err(e) => Err(s3_error_to_sftp("head_object", e)),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sftp::constants::posix::POSIX_TYPE_MASK;
#[test]
fn s3_attrs_to_sftp_directory_has_dir_type_bit() {
use crate::constants::paths::{DIR_MODE, DIR_PERMISSIONS};
let attrs = s3_attrs_to_sftp(0, None, true);
let mode = attrs.permissions.unwrap();
assert_eq!(mode & POSIX_TYPE_MASK, DIR_MODE, "S_IFDIR bit must be set");
assert_eq!(mode & 0o777, DIR_PERMISSIONS);
assert!(attrs.is_dir());
}
#[test]
fn s3_attrs_to_sftp_file_has_regular_type_bit() {
use crate::constants::paths::{FILE_MODE, FILE_PERMISSIONS};
let attrs = s3_attrs_to_sftp(42, Some(1_700_000_000), false);
let mode = attrs.permissions.unwrap();
assert_eq!(mode & POSIX_TYPE_MASK, FILE_MODE, "S_IFREG bit must be set");
assert_eq!(mode & 0o777, FILE_PERMISSIONS);
assert_eq!(attrs.size, Some(42));
assert_eq!(attrs.mtime, Some(1_700_000_000));
assert!(attrs.is_regular());
}
#[test]
fn generate_longname_prefixes_d_for_directory() {
let attrs = s3_attrs_to_sftp(0, Some(0), true);
let line = generate_longname("mybucket", &attrs);
assert!(line.starts_with('d'), "dir longname must start with d, got {line}");
}
#[test]
fn generate_longname_prefixes_dash_for_file() {
let attrs = s3_attrs_to_sftp(100, Some(0), false);
let line = generate_longname("file.txt", &attrs);
assert!(line.starts_with('-'), "file longname must start with -, got {line}");
}
#[test]
fn generate_longname_strips_lf_in_filename() {
let attrs = s3_attrs_to_sftp(100, Some(0), false);
let line = generate_longname("evil\nfile.txt", &attrs);
assert!(!line.contains('\n'), "longname must not contain raw LF, got {line:?}");
assert!(
line.contains("evil?file.txt"),
"longname must include the sanitised filename, got {line:?}"
);
}
#[test]
fn timestamp_conversion_handles_none() {
assert_eq!(timestamp_to_mtime(None), None);
}
#[test]
fn timestamp_to_mtime_clamps_negative_to_zero() {
let pre_epoch = s3s::dto::Timestamp::from(time::OffsetDateTime::from_unix_timestamp(-86400).expect("valid timestamp"));
assert_eq!(timestamp_to_mtime(Some(pre_epoch)), Some(0));
}
#[test]
fn timestamp_to_mtime_clamps_overflow_to_u32_max() {
let far_future = s3s::dto::Timestamp::from(
time::OffsetDateTime::from_unix_timestamp(u32::MAX as i64 + 86400).expect("valid timestamp"),
);
assert_eq!(timestamp_to_mtime(Some(far_future)), Some(u32::MAX));
}
#[test]
fn posix_mode_constants_match_documented_values() {
assert_eq!(POSIX_DIR_MODE, 0o040755);
assert_eq!(POSIX_FILE_MODE, 0o100644);
assert_eq!(POSIX_TYPE_MASK, 0o170000);
}
}
+841
View File
@@ -0,0 +1,841 @@
// 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,
};
use std::net::SocketAddr;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// 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 world-readable or
/// group-readable bits set. Mode must be 0o600 or 0o400 so a
/// local non-root user cannot impersonate the SFTP server.
#[error("host key file has insecure permissions {mode:#o}: {path} (must be 0o600 or 0o400)")]
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 mode 0o600 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 not a Unix-family target. The
/// host-key permission enforcement (mode 0o600 / 0o400 check)
/// requires Unix mode bits and has no equivalent on this platform,
/// so SFTP refuses to start rather than load host keys with weaker
/// guarantees.
#[error("SFTP requires a Unix-family 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 emit a warn log 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!(
requested = n,
min = HANDLES_PER_SESSION_MIN,
max = HANDLES_PER_SESSION_MAX,
default = DEFAULT_HANDLES_PER_SESSION,
"RUSTFS_SFTP_HANDLES_PER_SESSION out of range. Falling back to the default.",
);
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 emit a warn log 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!(
requested = n,
min = BACKEND_OP_TIMEOUT_MIN_SECS,
max = BACKEND_OP_TIMEOUT_MAX_SECS,
default = DEFAULT_BACKEND_OP_TIMEOUT_SECS,
"RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS out of range. Falling back to the default.",
);
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 emit a warn log
/// 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!(
requested = n,
min = READ_CACHE_WINDOW_MIN,
max = READ_CACHE_WINDOW_MAX,
default = READ_CACHE_WINDOW_DEFAULT,
"RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES out of range. Set to 0 to disable the cache, or to a value between the named bounds. Falling back to the default.",
);
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 emit a warn log 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!(
requested = n,
min = READ_CACHE_TOTAL_MEM_MIN,
default = READ_CACHE_TOTAL_MEM_DEFAULT,
"RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES below minimum. Falling back to the default.",
);
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.
///
/// Returns SftpInitError::UnsupportedPlatform when built for a
/// non-Unix target. The mode-bit permission enforcement has no
/// portable equivalent off Unix, and starting SFTP without it
/// would silently weaken host-key protection.
#[cfg(not(unix))]
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(),
})
}
#[cfg(unix)]
pub async fn load_host_keys(host_key_dir: &Path) -> Result<Vec<russh::keys::PrivateKey>, SftpInitError> {
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!(
path = %path.display(),
err = %e,
"cannot stat file, skipping"
);
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!(
path = %path.display(),
size = file_size,
"skipping file: size outside valid key range"
);
continue;
}
// Permission check: hard error on insecure permissions.
// A world-readable private key lets any local user impersonate
// the SFTP server. OpenSSH enforces the same restriction.
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SftpInitError::InsecureHostKeyPermissions { path, mode });
}
let data = match tokio::fs::read_to_string(&path).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
path = %path.display(),
err = %e,
"cannot read file, skipping"
);
continue;
}
};
match russh::keys::decode_secret_key(&data, None) {
Ok(key) => {
tracing::info!(
path = %path.display(),
algorithm = ?key.algorithm(),
"loaded host key"
);
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!(
path = %path.display(),
err = %e,
"file looks like a private key but failed to decode (passphrase-protected keys are not supported)"
);
} else {
tracing::debug!(
path = %path.display(),
err = %e,
"not a valid private key, skipping"
);
}
}
}
}
if keys.is_empty() {
return Err(SftpInitError::NoHostKeysFound {
path: host_key_dir.to_path_buf(),
});
}
// Sort keys by algorithm preference: Ed25519 first, then ECDSA,
// then RSA. russh offers keys to clients in array order during
// key exchange. The ordering controls which algorithm the
// client attempts first.
keys.sort_by_key(|k| match k.algorithm() {
russh::keys::Algorithm::Ed25519 => 0,
russh::keys::Algorithm::Ecdsa { .. } => 1,
russh::keys::Algorithm::Rsa { .. } => 2,
_ => 3,
});
tracing::info!(
count = keys.len(),
dir = %host_key_dir.display(),
"host key loading complete"
);
Ok(keys)
}
}
#[cfg(test)]
mod tests {
use super::*;
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 emits no contiguous private-key marker that
// secret scanners would flag. Throwaway test-vector keys.
const PEM_BOUNDARY_DASHES: &str = "-----";
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.
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,)
}
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",
)
}
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.
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]
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]
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]
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]
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]
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]
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]
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]
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);
}
}
+375
View File
@@ -0,0 +1,375 @@
// 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.
//! Named constants for the SFTP protocol implementation, grouped by purpose.
//!
//! s3_error_codes: AWS S3 error-code substrings the driver matches when
//! classifying backend errors into SFTP status codes.
//!
//! http_error_codes: HTTP status-code substrings the driver matches when
//! a backend reports an HTTP error by number rather than by S3 code.
//!
//! posix: POSIX mode bits (S_IFDIR, S_IFREG, permission triples) returned
//! in SFTP FileAttributes for S3 resources.
//!
//! protocol: SFTP protocol version supported by the driver and the SSH
//! subsystem name clients request.
//!
//! limits: caps, defaults, and AWS-imposed constants used across the SFTP
//! driver and server.
/// S3 error-code substrings matched by the driver when classifying backend
/// errors into SFTP status codes. The constants below are fragments of the
/// public AWS S3 error-code vocabulary, which backends include in their
/// error messages.
pub mod s3_error_codes {
/// AWS S3 error code returned by HeadObject / GetObject when the
/// key does not exist.
pub const NO_SUCH_KEY: &str = "NoSuchKey";
/// AWS S3 error code returned by HeadBucket when the bucket does
/// not exist.
pub const NO_SUCH_BUCKET: &str = "NoSuchBucket";
/// Generic "not found" string emitted by S3-compatible backends
/// (MinIO, Wasabi, ecstore) that do not always use the AWS
/// NoSuchKey / NoSuchBucket vocabulary on every miss.
pub const NOT_FOUND: &str = "NotFound";
/// AWS error code returned when an IAM policy denies the requested
/// action on the resource.
pub const ACCESS_DENIED: &str = "AccessDenied";
/// Generic forbidden string emitted by S3-compatible backends that
/// do not always use the AWS AccessDenied vocabulary.
pub const FORBIDDEN: &str = "Forbidden";
/// Returned by AbortMultipartUpload when the upload_id is no
/// longer live (already completed, already aborted, or reclaimed
/// by the bucket lifecycle rule). Drop's retry loop downgrades
/// this to a debug log to avoid noise when the tombstone-retry
/// path races a successful inline completion.
pub const NO_SUCH_UPLOAD: &str = "NoSuchUpload";
}
/// HTTP status-code substrings matched by the driver when a backend
/// reports an HTTP error by number rather than by S3 error code. These
/// are a different vocabulary from s3_error_codes (HTTP wire statuses
/// rather than S3 API error codes) and kept in a separate module.
pub mod http_error_codes {
pub const NOT_FOUND: &str = "404";
pub const FORBIDDEN: &str = "403";
}
/// POSIX mode bits returned in SFTP FileAttributes for S3 resources.
/// SFTPv3 draft section 5 defines the permissions field as a u32
/// carrying POSIX stat.h mode bits. S3 has no POSIX mode metadata, so
/// the server returns a fixed type bit (S_IFDIR for buckets and
/// prefixes, S_IFREG for objects) combined with a conventional
/// permission triple. Clients that inspect the type bit to distinguish
/// files from directories would otherwise treat every entry as a
/// regular file.
pub mod posix {
use crate::constants::paths::{DIR_MODE, DIR_PERMISSIONS, FILE_MODE, FILE_PERMISSIONS};
/// Directory mode returned for bucket and prefix entries.
/// S_IFDIR | 0o755 = 0o040755.
pub const POSIX_DIR_MODE: u32 = DIR_MODE | DIR_PERMISSIONS;
/// Regular-file mode returned for object entries.
/// S_IFREG | 0o644 = 0o100644.
pub const POSIX_FILE_MODE: u32 = FILE_MODE | FILE_PERMISSIONS;
/// POSIX file-type mask (S_IFMT). Isolates the four high bits of a
/// mode value so the file-type field can be compared against
/// S_IFDIR, S_IFREG, S_IFLNK, and the other POSIX type constants.
/// Compiled in test builds only; the runtime path reads the full
/// mode from POSIX_DIR_MODE / POSIX_FILE_MODE.
#[cfg(test)]
pub const POSIX_TYPE_MASK: u32 = 0o170000;
}
/// SFTP protocol identifiers and version numbers.
pub mod protocol {
/// SFTP protocol version supported by this server. The wire format and
/// packet semantics are defined by the SFTP Internet Draft
/// draft-ietf-secsh-filexfer-02. Later drafts (versions 4 to 6) change
/// the attribute and timestamp encodings. Supporting them would require
/// a separate driver type, not a parameter on the version-3 driver.
pub const SFTP_VERSION: u32 = 3;
/// SSH subsystem name that clients request to start SFTP.
pub const SFTP_SUBSYSTEM_NAME: &str = "sftp";
}
/// Limits, defaults, and AWS-defined constants used across the SFTP
/// driver and server. Three roles share this module.
///
/// AWS-imposed limits. S3_COPY_OBJECT_MAX_SIZE, S3_MIN_PART_SIZE,
/// S3_MAX_PART_SIZE, and S3_MAX_MULTIPART_PARTS reflect the S3 API
/// contract and do not change per deployment.
///
/// Operational bounds. DEFAULT_HANDLES_PER_SESSION, the
/// BACKEND_OP_TIMEOUT trio (DEFAULT, MIN, MAX), the READ_CACHE_*
/// values, and SHUTDOWN_DRAIN_TIMEOUT_SECS govern per-session and
/// process-wide resource use. Each has a paired RUSTFS_SFTP_* env var
/// for operator override.
///
/// SSH transport overrides. SSH_MAXIMUM_PACKET_SIZE,
/// SSH_CHANNEL_BUFFER_SIZE, and SSH_EVENT_BUFFER_SIZE override russh
/// defaults so the inbound mpsc absorbs client pipelining during
/// multi-MB transfers.
pub mod limits {
/// Maximum payload size accepted from a single READ request, in bytes.
/// Matches OpenSSH's default chunk size and bounds per-request memory.
pub const MAX_READ_LEN: u32 = 256 * 1024;
/// Default number of simultaneously-open SFTP handles per session.
/// Used when RUSTFS_SFTP_HANDLES_PER_SESSION is unset or out of
/// range. 64 covers the typical OpenSSH / rsync / WinSCP
/// pipelining ceiling.
pub const DEFAULT_HANDLES_PER_SESSION: usize = 64;
/// Lower validation bound on RUSTFS_SFTP_HANDLES_PER_SESSION.
/// Below this a single client opening one file plus a directory
/// listing already runs out of handles.
pub const HANDLES_PER_SESSION_MIN: usize = 8;
/// Upper validation bound on RUSTFS_SFTP_HANDLES_PER_SESSION.
/// Each handle can hold a part_size-sized buffer (write path), so
/// at default part_size = 16 MiB the worst-case session memory
/// is 16 GiB at this cap.
pub const HANDLES_PER_SESSION_MAX: usize = 1024;
/// Seconds between SSH keepalive probes. Passed into
/// russh::server::Config at server-build time. russh sends an
/// SSH-level keepalive request after this many seconds of silence.
/// If the client does not respond after KEEPALIVE_MAX consecutive
/// probes the connection is closed.
///
/// This detects dead TCP connections where the client disappeared
/// without sending FIN (network failure, killed process, etc).
/// Active but slow connections are unaffected because they still
/// respond to the small SSH keepalive packets even during large
/// transfers. OpenSSH's ServerAliveInterval defaults to 15 seconds
/// on the client side. 15 seconds on the server side is consistent
/// with that.
pub const KEEPALIVE_INTERVAL_SECS: u64 = 15;
/// Number of consecutive missed keepalive responses before russh
/// closes the connection. Passed into russh::server::Config at
/// server-build time. With KEEPALIVE_INTERVAL_SECS = 15, a truly
/// dead connection is closed within ~45 seconds.
pub const KEEPALIVE_MAX: usize = 3;
/// Wallclock deadline applied to russh::server::run_stream while
/// the SSH KEX and password auth handshake completes. A peer that
/// completes TCP and stalls before KEXINIT (or that drives KEX or
/// auth so slowly that no SSH-layer timer fires) is dropped after
/// this many seconds, freeing the spawn-task slot. Inactivity and
/// keepalive timers do not cover this window because they run
/// inside the post-handshake session loop.
pub const HANDSHAKE_DEADLINE_SECS: u64 = 30;
/// Tick interval for the per-session wedge watchdog. Worst-case
/// detection latency is WEDGE_FAST_KILL_SILENCE_SECS + one tick.
pub const WEDGE_WATCHDOG_TICK_SECS: u64 = 15;
/// Silence threshold at which a session whose underlying TCP socket
/// is in CLOSE_WAIT is force-cancelled by the watchdog.
///
/// A healthy session is never simultaneously silent at the SFTP
/// handler AND in CLOSE_WAIT: peer FIN normally surfaces as Ok(0)
/// on the SSH library read poll within milliseconds. 30 s leaves
/// room for two keepalive intervals (15 s each) before the
/// watchdog overrides, so a transient scheduler stall does not
/// trip it.
pub const WEDGE_FAST_KILL_SILENCE_SECS: u64 = 30;
/// Fallback silence threshold. The only kill path on non-Linux
/// targets, where /proc/net/tcp is unavailable and the watchdog's
/// CLOSE_WAIT probe always returns None. On Linux it is the
/// backstop for cases where /proc/net/tcp is unreadable for some
/// other reason (filesystem permissions, namespace tricks) or
/// where the wedge surfaces in a state other than CLOSE_WAIT.
/// 1800 s sits above russh's default inactivity_timeout (600 s)
/// so russh's own inactivity close fires first on a healthy idle session.
pub const WEDGE_FALLBACK_KILL_SILENCE_SECS: u64 = 1800;
// The three constants below override russh defaults for the SSH
// transport the SFTP subsystem runs on. russh defaults
// (channel_buffer_size 100, event_buffer_size 10) are tight enough
// that the inbound mpsc fills under client pipelining, the
// session-loop reading arm blocks on chan.send(...).await, and
// inbound CHANNEL_WINDOW_ADJUST stops being drained. PuTTY-derived
// stacks (FileZilla, Cyberduck) reach the limit during multi-MB
// downloads.
/// Maximum SSH packet size advertised by the server, in bytes.
/// Matches russh's default. Set explicitly so behaviour does not
/// depend on russh's chosen default.
pub const SSH_MAXIMUM_PACKET_SIZE: u32 = 32 * 1024;
/// Capacity of the bounded mpsc that russh's session loop uses
/// for inbound CHANNEL_DATA. russh default is 100. Raised to
/// defer fill past typical client pipelining depths.
pub const SSH_CHANNEL_BUFFER_SIZE: usize = 1024;
/// Capacity of the bounded mpsc that russh's session loop uses
/// for channel-level events. russh default is 10. Raised to
/// defer fill past typical client pipelining depths.
pub const SSH_EVENT_BUFFER_SIZE: usize = 1024;
// The four constants below are S3 protocol limits defined by the AWS
// S3 API. They are not SFTP operational policy and do not change per
// deployment. The ecstore client crate defines the same four values
// under different names (ABS_MIN_PART_SIZE, MAX_PART_SIZE,
// MAX_PARTS_COUNT, MAX_SINGLE_PUT_OBJECT_SIZE). They live here as
// SFTP-scoped copies because the protocols crate must not depend on
// ecstore internals: the StorageBackend trait abstraction would leak.
/// S3 CopyObject single-shot size limit (5 GiB). Source objects
/// larger than this require UploadPartCopy. Mirrors the
/// MAX_SINGLE_PUT_OBJECT_SIZE constant in ecstore but cannot be
/// imported from there.
pub const S3_COPY_OBJECT_MAX_SIZE: u64 = 5 * 1024 * 1024 * 1024;
/// S3 minimum part size in bytes (5 MiB). Every part of a multipart
/// upload except the last must be at least this size, or
/// CompleteMultipartUpload returns EntityTooSmall. Mirrors ecstore's
/// ABS_MIN_PART_SIZE but cannot be imported from there.
pub const S3_MIN_PART_SIZE: u64 = 5 * 1024 * 1024;
/// S3 maximum part size in bytes (5 GiB). Any single UploadPart call
/// carrying a body larger than this is rejected with EntityTooLarge.
/// Mirrors the MAX_PART_SIZE constant in ecstore but cannot be
/// imported from there. AWS sets S3_COPY_OBJECT_MAX_SIZE and
/// S3_MAX_PART_SIZE independently to 5 GiB; the values are not
/// coupled. Future S3 versions could move them apart, so they
/// remain separate constants.
pub const S3_MAX_PART_SIZE: u64 = 5 * 1024 * 1024 * 1024;
/// Maximum number of parts in a single multipart upload (S3 limit).
/// Exceeding this causes UploadPart to fail. Mirrors ecstore's
/// MAX_PARTS_COUNT but cannot be imported from there.
pub const S3_MAX_MULTIPART_PARTS: i32 = 10_000;
/// Maximum seconds the SFTP server waits for session tasks to
/// finish after a shutdown signal before the runtime cancels them.
/// This is the cleanup-grace window for the Drop impl on each
/// SftpDriver (which issues AbortMultipartUpload for live
/// upload_ids), not a transfer-completion window. In-flight
/// transfers do not need to finish inside this timer. Cancellation
/// past this timeout leaves any remaining upload_ids to the bucket
/// AbortIncompleteMultipartUpload lifecycle rule.
pub const SHUTDOWN_DRAIN_TIMEOUT_SECS: u64 = 30;
/// Maximum number of buckets returned by the root READDIR. S3
/// ListBuckets is not paginated so the backend can hand back an
/// arbitrarily long response. Truncating here bounds the Vec
/// allocation and keeps the SSH channel window usage low for a
/// principal with many visible buckets. Overflow is logged as a
/// warn so operators know truncation happened.
pub const ROOT_LISTING_MAX_ENTRIES: usize = 10_000;
/// Maximum entries requested per ListObjectsV2 page for READDIR.
/// The S3 default is 1000. Asking for a specific value keeps the
/// per-page allocation and SSH channel window usage under operator
/// control. Each entry's longname is bounded by a filename plus a
/// fixed-width header, so 1000 entries stays under the 2 MiB
/// channel window.
pub const READDIR_PAGE_MAX_KEYS: i32 = 1_000;
/// Default per-call deadline applied to every StorageBackend
/// invocation issued by the SFTP driver. A backend that does not
/// respond within this many seconds returns Failure to the client
/// and emits a warn log naming the backend method. Used when
/// RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS is unset or out of range.
/// The keepalive timer (KEEPALIVE_INTERVAL_SECS times KEEPALIVE_MAX,
/// approximately 45 s) closes a stuck SSH transport but cannot detect
/// a backend that accepted the request and never returned a body.
/// This deadline closes that gap.
pub const DEFAULT_BACKEND_OP_TIMEOUT_SECS: u64 = 60;
/// Lower validation bound on RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS.
/// Below 5 s a healthy backend under load (cold-cache HEAD on a
/// large bucket, multipart Complete on hundreds of parts) can
/// time out under normal operating conditions.
pub const BACKEND_OP_TIMEOUT_MIN_SECS: u64 = 5;
/// Upper validation bound on RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS.
/// 600 s is the longest single backend call expected in normal
/// use. Above that the SSH keepalive (about 45 s) takes over the
/// liveness role.
pub const BACKEND_OP_TIMEOUT_MAX_SECS: u64 = 600;
/// Maximum number of retries the small-file PutObject path in
/// commit_write attempts after a transient backend error
/// (SlowDown, RequestTimeout, Throttling, InternalError, etc).
/// Three retries covers the typical S3 retry-after window without
/// holding the SFTP CLOSE response open beyond the keepalive
/// timer. Total elapsed before giving up is the sum of
/// COMMIT_WRITE_BACKOFF_MS plus the cumulative call time.
pub const COMMIT_WRITE_MAX_RETRIES: usize = 3;
/// Backoff schedule between commit_write PutObject retries, in
/// milliseconds. Index zero is the wait between attempt 0 and
/// attempt 1, and so on. The exponential 250 / 500 / 1000 cadence
/// matches typical S3 SDK defaults and stays inside the worst-case
/// 2 s combined wait that a CLOSE response can absorb without the
/// client surfacing a hang.
pub const COMMIT_WRITE_BACKOFF_MS: [u64; COMMIT_WRITE_MAX_RETRIES] = [250, 500, 1000];
/// Per-handle read cache window size in bytes. On a cache miss
/// the driver fetches at most this many bytes from the backend,
/// then returns the requested portion to the client and stores
/// the rest in the per-handle buffer. With the 4 MiB default and
/// the 256 KiB MAX_READ_LEN, sixteen FXP_READs are returned from
/// one backend call. Overridable per installation via
/// RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES.
pub const READ_CACHE_WINDOW_DEFAULT: u64 = 4 * 1024 * 1024;
/// Lower validation bound on RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES
/// for non-zero values. The cache-window floor reflects MAX_READ_LEN.
/// Below it a single MAX_READ_LEN FXP_READ cannot be satisfied from
/// one cached chunk, so the per-handle allocation costs memory with
/// no benefit. To turn the cache off entirely, use the
/// READ_CACHE_DISABLED sentinel.
pub const READ_CACHE_WINDOW_MIN: u64 = MAX_READ_LEN as u64;
/// Sentinel value for RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES that
/// disables the per-handle read cache. The populate path is
/// short-circuited, no buffer is retained between FXP_READs, and
/// the process-wide accumulator is not touched. Each FXP_READ
/// takes one backend call.
pub const READ_CACHE_DISABLED: u64 = 0;
/// Upper validation bound on RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES.
/// Bounds single-handle memory at a value that fits inside
/// READ_CACHE_TOTAL_MEM_DEFAULT even with four concurrent
/// handles open.
pub const READ_CACHE_WINDOW_MAX: u64 = 64 * 1024 * 1024;
/// Process-wide ceiling on cumulative read cache memory across
/// every live SFTP handle. When the accumulator plus a new
/// window would exceed this value, the populate call is skipped.
/// The read still completes from the freshly-fetched bytes
/// without storing them in the cache. The next FXP_READ on the
/// same handle issues a fresh backend call instead of being
/// returned from the buffer. Overridable per installation via
/// RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES.
pub const READ_CACHE_TOTAL_MEM_DEFAULT: u64 = 256 * 1024 * 1024;
/// Lower validation bound on
/// RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES. Below this value, even
/// a single window at the default window size cannot be stored
/// without breaching the cap, leaving every read on the no-cache
/// path.
pub const READ_CACHE_TOTAL_MEM_MIN: u64 = 16 * 1024 * 1024;
}
+615
View File
@@ -0,0 +1,615 @@
// 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.
//! Directory iteration and the bucket/sub-directory mkdir/rmdir
//! helpers. Drives the cursor walks and emptiness checks that the
//! Handler trait's opendir/readdir/mkdir/rmdir methods consume.
use super::attrs::{generate_longname, s3_attrs_to_sftp, timestamp_to_mtime};
use super::constants::limits::{READDIR_PAGE_MAX_KEYS, ROOT_LISTING_MAX_ENTRIES};
use super::driver::SftpDriver;
use super::errors::{SftpError, s3_error_to_sftp};
use super::paths::{last_path_component, parse_s3_path, relative_filename};
use super::state::{DirCursor, HandleState, ListingContinuation};
use crate::common::client::s3::StorageBackend;
use crate::common::gateway::S3Action;
use bytes::Bytes;
use futures_util::stream;
use russh_sftp::protocol::{File, Handle, Name, StatusCode};
use rustfs_utils::path;
use s3s::dto::{ListObjectsV2Input, PutObjectInput, StreamingBlob};
/// Build the conventional "." and ".." directory entries that prefix
/// the first READDIR response on every directory handle. SFTPv3 does
/// not mandate these, but POSIX clients require them. Emitting both
/// keeps the directory listing compatible with OpenSSH sftp,
/// FileZilla, and WinSCP. Both are returned as directories so clients
/// render ".." as the up-navigation shortcut.
pub(super) fn dot_entries() -> Vec<File> {
let attrs = s3_attrs_to_sftp(0, None, true);
vec![
File {
filename: ".".to_string(),
longname: generate_longname(".", &attrs),
attrs: attrs.clone(),
},
File {
filename: "..".to_string(),
longname: generate_longname("..", &attrs),
attrs,
},
]
}
impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
/// Fetch one S3 ListObjectsV2 page for a Listing cursor, convert it to
/// File entries (subdirectories from common_prefixes, objects from
/// contents), and advance the cursor's continuation state for the next
/// call. Caller passes the cursor by mutable reference. The helper
/// updates the embedded continuation token in place.
///
/// Returns an empty Vec when the cursor is already Done. Returns
/// StatusCode::Failure if called with a Root cursor. Callers must
/// route Root to fetch_bucket_list instead.
pub(super) async fn next_listing_page(&self, cursor: &mut DirCursor) -> Result<Vec<File>, SftpError> {
let DirCursor::Listing {
bucket,
prefix,
continuation,
..
} = cursor
else {
return Err(SftpError::code(StatusCode::Failure));
};
// Cursor already exhausted by a prior page. No network round trip.
// The empty return signals the caller's EOF translation on the next
// READDIR.
if matches!(continuation, ListingContinuation::Done) {
return Ok(Vec::new());
}
// Re-authorise ListBucket on every page rather than relying on
// the OPENDIR-time check. S3 evaluates policy once per
// list_objects_v2 wire call. Matching that means a policy
// revoked mid-iteration takes effect on the next page rather
// than at session end.
self.authorize(&S3Action::ListBucket, bucket, None).await?;
let mut builder = ListObjectsV2Input::builder()
.bucket(bucket.clone())
.prefix(Some(prefix.clone()))
.delimiter(Some("/".to_string()))
.max_keys(Some(READDIR_PAGE_MAX_KEYS));
if let ListingContinuation::Next(token) = continuation {
builder = builder.continuation_token(Some(token.clone()));
}
let input = builder.build().map_err(|e| s3_error_to_sftp("build_list_objects", e))?;
let out = self
.run_backend(
"list_objects_v2",
self.storage.list_objects_v2(input, self.access_key(), self.secret_key()),
)
.await?;
let mut entries = Vec::new();
// common_prefixes contains subdirectory entries produced by the
// delimiter="/" split. Each prefix ends with "/".
// last_path_component returns the final component, or None if
// the prefix has no component (e.g. "/" on its own).
if let Some(common) = out.common_prefixes {
for cp in common {
let Some(p) = cp.prefix else { continue };
let Some(name) = last_path_component(&p) else { continue };
let attrs = s3_attrs_to_sftp(0, None, true);
entries.push(File {
filename: name.to_string(),
longname: generate_longname(name, &attrs),
attrs,
});
}
}
// contents holds object entries at the current level. __XLDIR__
// marker objects are excluded. relative_filename returns None
// for entries whose key contains a "/" after the prefix (those
// belong under a sub-prefix and would have appeared via
// common_prefixes).
if let Some(contents) = out.contents {
for obj in contents {
let Some(full_key) = obj.key else { continue };
if full_key.ends_with(path::GLOBAL_DIR_SUFFIX) {
continue;
}
let Some(name) = relative_filename(&full_key, prefix.as_str()) else { continue };
let size = obj.size.unwrap_or(0).max(0) as u64;
let mtime = timestamp_to_mtime(obj.last_modified);
let attrs = s3_attrs_to_sftp(size, mtime, false);
entries.push(File {
filename: name.to_string(),
longname: generate_longname(name, &attrs),
attrs,
});
}
}
// Advance the continuation cursor. is_truncated without a token is
// a backend inconsistency. Handle as Done rather than risk looping
// forever on an absent token.
*continuation = match (out.is_truncated.unwrap_or(false), out.next_continuation_token) {
(true, Some(token)) => ListingContinuation::Next(token),
_ => ListingContinuation::Done,
};
Ok(entries)
}
/// Return Err when the RMDIR target still has objects or
/// sub-prefixes. The check authorises ListBucket, then issues a
/// single list_objects_v2 capped at one entry: presence of any
/// contents or common_prefixes blocks the deletion. The empty
/// input prefix addresses a whole bucket. A non-empty prefix
/// addresses a sub-directory.
///
/// A list_objects_v2 failure aborts the operation. The caller
/// must not fall through to a destructive call when this returns
/// Err.
pub(super) async fn validate_directory_empty(&self, bucket: &str, prefix: &str) -> Result<(), SftpError> {
let prefix_for_authorization = if prefix.is_empty() { None } else { Some(prefix) };
self.authorize(&S3Action::ListBucket, bucket, prefix_for_authorization)
.await?;
// For sub-directory prefixes, max_keys=2 because the backend
// may return the directory's own __XLDIR__ marker (decoded to
// the prefix itself, e.g. "subdir/") as a content entry.
// max_keys=2 ensures the listing returns one entry past the
// marker so real content is visible. For bucket-level checks
// (prefix is empty) max_keys=1 is sufficient since there is no
// marker to filter.
let max_keys = if prefix.is_empty() { 1 } else { 2 };
let mut builder = ListObjectsV2Input::builder()
.bucket(bucket.to_string())
.delimiter(Some("/".to_string()))
.max_keys(Some(max_keys));
if !prefix.is_empty() {
builder = builder.prefix(Some(prefix.to_string()));
}
let input = builder.build().map_err(|e| s3_error_to_sftp("build_list_objects", e))?;
// Issue list_objects_v2. On Err the destructive caller never
// runs because validate_directory_empty returns the Err.
let out = self
.run_backend(
"list_objects_v2",
self.storage.list_objects_v2(input, self.access_key(), self.secret_key()),
)
.await?;
// Count content entries that are not the directory's own marker.
// The RustFS ecfs backend decodes __XLDIR__ markers back to
// trailing-slash keys in list responses, so the marker for
// "subdir/" appears as a content entry with key "subdir/". That
// entry must not count as content when checking emptiness.
let real_content_count = out
.contents
.as_ref()
.map(|c| c.iter().filter(|obj| obj.key.as_deref() != Some(prefix)).count())
.unwrap_or(0);
let has_prefixes = out.common_prefixes.map(|c| !c.is_empty()).unwrap_or(false);
if real_content_count > 0 || has_prefixes {
return Err(SftpError::code(StatusCode::Failure));
}
Ok(())
}
/// Authorise and issue ListBuckets, then convert the response into
/// File entries (one per bucket the principal can see). Called lazily
/// by readdir_cursor on the first READDIR of a Root cursor. The
/// S3Action::ListBuckets authorisation runs here rather than at
/// OPENDIR so a client without ListAllMyBuckets can still open the
/// root directory handle.
///
/// ListBuckets is not batched in the S3 API. A single response
/// carries the full set. Truncate at ROOT_LISTING_MAX_ENTRIES so a
/// principal with many visible buckets produces a bounded Vec and
/// does not exceed the SSH channel window with a single response.
pub(super) async fn fetch_bucket_list(&self) -> Result<Vec<File>, SftpError> {
self.authorize(&S3Action::ListBuckets, "", None).await?;
let out = self
.run_backend("list_buckets", self.storage.list_buckets(self.access_key(), self.secret_key()))
.await?;
let mut entries = Vec::new();
let mut truncated_at: Option<usize> = None;
// buckets is Option at the SDK level. None means no content
// (distinct from Some(empty Vec)). Both cases produce an empty
// result here.
if let Some(buckets) = out.buckets {
let total = buckets.len();
for bucket in buckets {
if entries.len() >= ROOT_LISTING_MAX_ENTRIES {
truncated_at = Some(total);
break;
}
// Bucket.name is Option in the SDK type. Skip entries
// where the name is None since there is no SFTP path
// that maps to an unnamed bucket.
let Some(name) = bucket.name else { continue };
let mtime = timestamp_to_mtime(bucket.creation_date);
let attrs = s3_attrs_to_sftp(0, mtime, true);
entries.push(File {
filename: name.clone(),
longname: generate_longname(&name, &attrs),
attrs,
});
}
}
if let Some(total) = truncated_at {
tracing::warn!(
returned = entries.len(),
total = total,
cap = ROOT_LISTING_MAX_ENTRIES,
"root READDIR truncated: principal has more buckets than the cap",
);
}
Ok(entries)
}
/// MKDIR for a bucket-level path: authorise and issue CreateBucket.
pub(super) async fn mkdir_bucket(&self, bucket: &str) -> Result<(), SftpError> {
self.authorize(&S3Action::CreateBucket, bucket, None).await?;
self.run_backend("create_bucket", self.storage.create_bucket(bucket, self.access_key(), self.secret_key()))
.await?;
Ok(())
}
/// MKDIR for a sub-directory path: write a zero-byte object at
/// encode_dir_object(prefix + "/"). The encoding maps "foo/" to
/// "foo__XLDIR__", which matches the RustFS marker convention used
/// by the S3, Swift, and WebDAV backends.
pub(super) async fn mkdir_subdir_marker(&self, bucket: &str, object_key: &str) -> Result<(), SftpError> {
let marker_key = path::encode_dir_object(&format!("{object_key}/"));
self.authorize(&S3Action::PutObject, bucket, Some(&marker_key)).await?;
let body = stream::once(async { Ok::<Bytes, std::io::Error>(Bytes::new()) });
let streaming = StreamingBlob::wrap(body);
let input = PutObjectInput::builder()
.bucket(bucket.to_string())
.key(marker_key.clone())
.content_length(Some(0))
.body(Some(streaming))
.build()
.map_err(|e| s3_error_to_sftp("build_put_object", e))?;
self.run_backend("put_object", self.storage.put_object(input, self.access_key(), self.secret_key()))
.await?;
Ok(())
}
/// RMDIR for a bucket-level path: validate empty, then authorise
/// and issue DeleteBucket.
pub(super) async fn rmdir_bucket(&self, bucket: &str) -> Result<(), SftpError> {
self.validate_directory_empty(bucket, "").await?;
self.authorize(&S3Action::DeleteBucket, bucket, None).await?;
self.run_backend("delete_bucket", self.storage.delete_bucket(bucket, self.access_key(), self.secret_key()))
.await?;
Ok(())
}
/// RMDIR for a sub-directory path: validate no objects under the
/// prefix, then authorise and delete the __XLDIR__ marker that
/// represents the directory.
pub(super) async fn rmdir_subdir_marker(&self, bucket: &str, object_key: &str) -> Result<(), SftpError> {
let prefix = format!("{object_key}/");
self.validate_directory_empty(bucket, &prefix).await?;
let marker_key = path::encode_dir_object(&prefix);
self.authorize(&S3Action::DeleteObject, bucket, Some(&marker_key)).await?;
self.run_backend(
"delete_object",
self.storage
.delete_object(bucket, &marker_key, self.access_key(), self.secret_key()),
)
.await?;
Ok(())
}
/// Create one READDIR response for a directory handle.
///
/// Updates the cursor in place: emits dots on the first call (tracked
/// by dots_emitted), fetches the next page of content (lazily on
/// first call for Root, per-page for Listing), and advances the
/// continuation state for Listing cursors via next_listing_page.
///
/// Returns the assembled Vec of File entries. An empty Vec means the
/// cursor is exhausted. The caller (readdir handler) translates that
/// into Err(StatusCode::Eof) before sending it on the wire.
pub(super) async fn readdir_cursor(&self, cursor: &mut DirCursor) -> Result<Vec<File>, SftpError> {
let mut out = Vec::new();
match cursor {
DirCursor::Root {
buckets_delivered,
dots_emitted,
} => {
if !*dots_emitted {
out.extend(dot_entries());
*dots_emitted = true;
}
if !*buckets_delivered {
out.extend(self.fetch_bucket_list().await?);
*buckets_delivered = true;
}
}
DirCursor::Listing { dots_emitted, .. } => {
if !*dots_emitted {
out.extend(dot_entries());
*dots_emitted = true;
}
out.extend(self.next_listing_page(cursor).await?);
}
}
Ok(out)
}
/// OPENDIR body shared with the Handler trait wrapper. Resolves the
/// path, builds the DirCursor, and allocates a directory handle.
/// Root paths build a Root cursor without any backend call so the
/// listing IAM gate runs at the first READDIR. Non-root paths verify
/// ListBucket and HeadBucket synchronously here.
pub(super) async fn opendir_inner(&mut self, id: u32, path: &str) -> Result<Handle, SftpError> {
let (bucket, key) = parse_s3_path(path)?;
let cursor = if bucket.is_empty() {
DirCursor::Root {
buckets_delivered: false,
dots_emitted: false,
}
} else {
let prefix = match &key {
None => String::new(),
Some(k) if k.ends_with('/') => k.clone(),
Some(k) => format!("{k}/"),
};
self.authorize(
&S3Action::ListBucket,
&bucket,
if prefix.is_empty() { None } else { Some(prefix.as_str()) },
)
.await?;
self.run_backend("head_bucket", self.storage.head_bucket(&bucket, self.access_key(), self.secret_key()))
.await?;
DirCursor::Listing {
bucket,
prefix,
continuation: ListingContinuation::Initial,
dots_emitted: false,
}
};
let handle = self.allocate_handle(HandleState::Dir(cursor))?;
Ok(Handle { id, handle })
}
/// READDIR body shared with the Handler trait wrapper. Removes the
/// handle from the table to obtain exclusive ownership of the
/// DirCursor, dispatches by handle type, re-inserts the handle,
/// and translates an empty page into Eof. The wrapper logs non-Eof
/// failures explicitly so Eof stays silent in the operator log.
pub(super) async fn readdir_inner(&mut self, id: u32, handle: String) -> Result<Name, SftpError> {
let mut state = self
.handles
.remove(&handle)
.ok_or_else(|| SftpError::code(StatusCode::Failure))?;
let result = match &mut state {
// READDIR on a file or write handle is a protocol error.
HandleState::File { .. } | HandleState::Write { .. } => Err(SftpError::code(StatusCode::Failure)),
HandleState::Dir(cursor) => {
// Insert a pre-advance copy of the cursor into the table
// before the await. If the listing future is cancelled,
// the next READDIR finds the un-advanced cursor and
// reissues the same page. No entries are duplicated
// because no batch was sent on the wire before
// cancellation.
self.handles.insert(handle.clone(), HandleState::Dir(cursor.clone()));
self.readdir_cursor(cursor).await
}
};
// Overwrite the tombstone (or replace the File/Write state we
// removed above) with the updated local state.
self.handles.insert(handle, state);
// An empty file list means the cursor has no more entries.
// Return Eof so the wire response carries the spec sentinel.
match result {
Ok(files) if files.is_empty() => Err(SftpError::code(StatusCode::Eof)),
Ok(files) => Ok(Name { id, files }),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::super::state::{DirCursor, HandleState, ListingContinuation};
use super::super::test_support::{TEST_PART_SIZE, build_driver, capture_tracing_at};
use crate::common::dummy_storage::{DummyBackend, DummyError};
use crate::common::gateway::with_test_auth_override;
use russh_sftp::protocol::StatusCode;
use russh_sftp::server::Handler;
use std::sync::Arc;
use tokio::sync::Notify;
use tracing::Level;
#[tokio::test]
async fn validate_directory_empty_propagates_list_error() {
// Safety contract: when the empty-check list_objects_v2 itself
// fails, validate_directory_empty must return Err. A
// fall-through to the destructive caller would convert a
// transient backend failure into silent data loss.
let backend = Arc::new(DummyBackend::new());
backend.queue_list_objects_v2_err(DummyError::Injected("list_objects_v2 transient failure".into()));
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
let result = with_test_auth_override(|_, _, _| true, driver.validate_directory_empty("b", "")).await;
assert!(result.is_err(), "list_objects_v2 error must propagate as Err");
}
#[tokio::test]
async fn validate_directory_empty_returns_ok_when_listing_is_empty() {
let backend = Arc::new(DummyBackend::new());
backend.queue_list_objects_v2_ok_empty();
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
let result = with_test_auth_override(|_, _, _| true, driver.validate_directory_empty("b", "")).await;
assert!(result.is_ok(), "empty listing must return Ok");
}
/// A READDIR cancelled mid-await of list_objects_v2 must leave the
/// pre-advance cursor copy in the handle table so the next READDIR
/// reissues the same first page. Without this, a cancellation
/// could either lose the cursor (next READDIR fails) or skip past
/// the entries that were never sent on the wire (silent data
/// hiding). The first page never went out, so re-issue cannot
/// produce a duplicate.
#[tokio::test]
async fn cancelled_readdir_leaves_cursor_unadvanced_for_re_issue() {
let backend = Arc::new(DummyBackend::new());
let entered = Arc::new(Notify::new());
backend.stall_list_objects_v2(entered.clone());
let mut driver = build_driver(backend.clone(), TEST_PART_SIZE);
let cursor = DirCursor::Listing {
bucket: "b".to_string(),
prefix: String::new(),
continuation: ListingContinuation::Initial,
dots_emitted: true,
};
let handle_id = driver.allocate_handle(HandleState::Dir(cursor)).expect("allocate");
let readdir_fut = driver.readdir(1, handle_id.clone());
with_test_auth_override(|_, _, _| true, async {
tokio::select! {
biased;
_ = entered.notified() => {
// list_objects_v2 has been entered. Drop readdir_fut on
// exit from this block; the surviving handle entry
// must be the pre-advance tombstone.
}
_ = readdir_fut => {
panic!("readdir must stall inside list_objects_v2, not complete");
}
}
})
.await;
// The handle table must still hold the cursor in Initial state.
// readdir's pre-advance insert ran before the await; the post-
// await re-insert never ran because the future was dropped.
let surviving = driver.handles.get(&handle_id).expect("handle must survive cancellation");
let HandleState::Dir(DirCursor::Listing {
continuation,
dots_emitted,
..
}) = surviving
else {
panic!("surviving handle must be a Listing cursor");
};
assert!(
matches!(continuation, ListingContinuation::Initial),
"cancelled READDIR must leave the cursor in Initial state",
);
assert!(*dots_emitted, "dots_emitted must survive cancellation unchanged");
// Re-issue READDIR. Turn the stall off and queue a single Ok
// page so the second call completes without exercising the
// stall path. The cursor's Initial state means the second
// request is identical to the cancelled one (no continuation
// token, no skipped entries).
backend.clear_stall_list_objects_v2();
backend.queue_list_objects_v2_ok_empty();
let result = with_test_auth_override(|_, _, _| true, driver.readdir(2, handle_id)).await;
// Empty page returns Eof per readdir's empty-Name-to-Eof translation.
let err = result.expect_err("re-issued READDIR against an empty listing must return Eof, not Ok");
assert!(
matches!(StatusCode::from(err), StatusCode::Eof),
"re-issued READDIR against an empty listing must return Eof, not Failure",
);
}
/// READDIR on an exhausted cursor returns the spec-mandated Eof
/// sentinel. The handler must surface Eof on the wire and stay
/// silent in the operator log so a normal directory listing burst
/// does not generate one error-level event per page.
#[tokio::test]
async fn readdir_past_eof_emits_no_error_level_event() {
let backend = Arc::new(DummyBackend::new());
backend.queue_list_objects_v2_ok_empty();
let mut driver = build_driver(Arc::clone(&backend), TEST_PART_SIZE);
let cursor = DirCursor::Listing {
bucket: "b".to_string(),
prefix: String::new(),
continuation: ListingContinuation::Initial,
dots_emitted: true,
};
let handle_id = driver.allocate_handle(HandleState::Dir(cursor)).expect("allocate");
let (result, captured) =
capture_tracing_at(Level::ERROR, with_test_auth_override(|_, _, _| true, driver.readdir(7, handle_id))).await;
let err = result.expect_err("exhausted cursor must return Eof");
assert!(matches!(StatusCode::from(err), StatusCode::Eof));
assert!(
!captured.contains("ERROR"),
"Eof return must not produce an error-level event, captured: {captured}"
);
assert!(
!captured.contains("SFTP READDIR failed"),
"Eof return must not log SFTP READDIR failed, captured: {captured}"
);
}
/// A non-Eof failure on the readdir path is a real operator-visible
/// problem. Dropping err(Debug) from the instrument attribute
/// removed the auto-logging seam, so the handler logs explicitly.
/// This pins the substitute path so a future refactor cannot
/// silently let real backend failures pass without an error-level
/// event.
#[tokio::test]
async fn readdir_backend_failure_emits_error_level_event() {
let backend = Arc::new(DummyBackend::new());
backend.queue_list_objects_v2_err(DummyError::Injected("backend exploded".into()));
let mut driver = build_driver(Arc::clone(&backend), TEST_PART_SIZE);
let cursor = DirCursor::Listing {
bucket: "b".to_string(),
prefix: String::new(),
continuation: ListingContinuation::Initial,
dots_emitted: true,
};
let handle_id = driver.allocate_handle(HandleState::Dir(cursor)).expect("allocate");
let (result, captured) =
capture_tracing_at(Level::ERROR, with_test_auth_override(|_, _, _| true, driver.readdir(8, handle_id))).await;
let err = result.expect_err("backend error must propagate as Err");
assert!(!matches!(StatusCode::from(err), StatusCode::Eof), "backend error must not be Eof");
assert!(
captured.contains("ERROR"),
"non-Eof backend failure must produce an error-level event, captured: {captured}"
);
assert!(
captured.contains("SFTP READDIR failed"),
"error-level event must carry the SFTP READDIR failed message, captured: {captured}"
);
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
// 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.
//! SftpError type and the helpers that convert backend errors and
//! authorisation failures into SftpError, plus the success Status
//! payload constructor.
use super::constants::{http_error_codes, s3_error_codes};
use russh_sftp::protocol::{Status, StatusCode};
use std::fmt::Display;
/// Error type for SFTP operations. Converts to StatusCode for the wire.
#[derive(Debug)]
pub struct SftpError(pub(super) StatusCode);
impl From<SftpError> for StatusCode {
fn from(err: SftpError) -> Self {
err.0
}
}
impl SftpError {
pub(super) fn code(code: StatusCode) -> Self {
Self(code)
}
}
/// Map an S3 backend error into an SFTP status code and log the underlying
/// detail server-side. The wire response only carries the status code. The
/// full error is written to the server log for operator diagnosis. Error
/// strings that mention the common "not found" or "access denied" patterns
/// are mapped to the matching SFTP status. Everything else is Failure.
pub(super) fn s3_error_to_sftp<E: Display>(op: &str, err: E) -> SftpError {
let msg = err.to_string();
let code = if msg.contains(s3_error_codes::NO_SUCH_KEY)
|| msg.contains(s3_error_codes::NO_SUCH_BUCKET)
|| msg.contains(s3_error_codes::NOT_FOUND)
|| msg.contains(http_error_codes::NOT_FOUND)
{
StatusCode::NoSuchFile
} else if msg.contains(s3_error_codes::ACCESS_DENIED)
|| msg.contains(s3_error_codes::FORBIDDEN)
|| msg.contains(http_error_codes::FORBIDDEN)
{
StatusCode::PermissionDenied
} else {
StatusCode::Failure
};
tracing::warn!(op = %op, err = %msg, "SFTP backend error");
SftpError::code(code)
}
/// Returns SftpError(PermissionDenied), the status used when
/// authorize_operation rejects an operation with AccessDenied.
pub(super) fn auth_err() -> SftpError {
SftpError::code(StatusCode::PermissionDenied)
}
/// Returns SftpError(Failure) when the IAM layer is unreachable.
/// SFTPv3 has no service-unavailable status, so Failure is the
/// closest fit. The warn log includes the operation and target so an
/// IAM outage produces a distinct server-side signal from a policy
/// deny.
pub(super) fn auth_err_unreachable(op: &str, bucket: &str, key: Option<&str>) -> SftpError {
tracing::warn!(
op = op,
bucket = %bucket,
key = key.unwrap_or("-"),
"SFTP authorisation rejected because the IAM system was unreachable"
);
SftpError::code(StatusCode::Failure)
}
/// Build the SSH_FX_OK Status payload returned by write operation
/// handlers on success (CLOSE, REMOVE, MKDIR, RMDIR, RENAME, SETSTAT,
/// FSETSTAT).
pub(super) fn ok_status(id: u32) -> Status {
Status {
id,
status_code: StatusCode::Ok,
error_message: String::new(),
language_tag: "en".to_string(),
}
}
/// Classify an S3 backend error string as the not-found category that
/// distinguishes the EXCLUDE create accept path (object does not exist)
/// from a backend failure that needs propagating. Mirrors the prefix set
/// recognised by s3_error_to_sftp.
pub(super) fn is_not_found_error<E: Display>(err: &E) -> bool {
let msg = err.to_string();
msg.contains(s3_error_codes::NO_SUCH_KEY)
|| msg.contains(s3_error_codes::NO_SUCH_BUCKET)
|| msg.contains(s3_error_codes::NOT_FOUND)
|| msg.contains(http_error_codes::NOT_FOUND)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ok_status_has_ok_code_and_empty_message() {
let status = ok_status(17);
assert_eq!(status.id, 17);
assert!(matches!(status.status_code, StatusCode::Ok));
assert!(status.error_message.is_empty());
assert_eq!(status.language_tag, "en");
}
#[test]
fn is_not_found_recognises_standard_error_patterns() {
struct E(&'static str);
impl std::fmt::Display for E {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
assert!(is_not_found_error(&E("S3Error: NoSuchKey")));
assert!(is_not_found_error(&E("backend returned NoSuchBucket")));
assert!(is_not_found_error(&E("NotFound (404)")));
assert!(is_not_found_error(&E("response status 404")));
assert!(!is_not_found_error(&E("AccessDenied")));
assert!(!is_not_found_error(&E("generic backend failure")));
}
#[test]
fn s3_error_to_sftp_maps_access_denied_to_permission_denied() {
struct E(&'static str);
impl std::fmt::Display for E {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
let check = |msg: &'static str| -> StatusCode { StatusCode::from(s3_error_to_sftp("test", E(msg))) };
assert!(matches!(check("AccessDenied"), StatusCode::PermissionDenied));
assert!(matches!(check("Forbidden"), StatusCode::PermissionDenied));
assert!(matches!(check("403"), StatusCode::PermissionDenied));
assert!(matches!(check("NoSuchKey"), StatusCode::NoSuchFile));
assert!(matches!(check("something unexpected"), StatusCode::Failure));
}
}
+352
View File
@@ -0,0 +1,352 @@
// 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.
//! Per-session lifecycle bookkeeping plus the kernel TCP-state probe.
//!
//! Holds the per-session activity stamp and the weak-ref registry the
//! accept loop walks. Both are load-bearing infrastructure for the
//! per-session wedge watchdog (wedge_watchdog.rs): the watchdog uses
//! the activity stamp to decide whether a session is silent, and the
//! TCP-state probe to disambiguate slow operations from CLOSE_WAIT.
//!
//! Activity stamps are written from every SFTP handler entry/exit and
//! from auth_password / subsystem_request. They are read by the
//! watchdog tick loop.
//!
//! The TCP-state probe parses /proc/net/tcp and /proc/net/tcp6, looks
//! up the row matching the (local, peer) tuple, and returns the kernel
//! TCP state. Only Linux exposes the procfs files. On other targets
//! the probe returns None and the watchdog falls back to its absolute
//! silence threshold. Live ports are hex'd in the kernel's
//! per-architecture byte order (little-endian within each 4-byte chunk).
use std::fmt::Write as _;
use std::net::{IpAddr, SocketAddr};
use std::sync::Mutex;
use std::sync::Weak;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
// Procfs (/proc/net/tcp[6]) parsing constants. Format reference:
// kernel net/ipv4/tcp_ipv4.c::tcp4_seq_show and
// net/ipv6/tcp_ipv6.c::tcp6_seq_show.
/// Length of an IPv6 address in bytes.
const IPV6_BYTES: usize = 16;
/// Length of an IPv4 address in bytes.
const IPV4_BYTES: usize = 4;
/// Hex characters used to render one byte in the procfs format
/// (matches the {:02X} format spec at the call sites).
const HEX_CHARS_PER_BYTE: usize = 2;
/// Hex characters used to render the 16-bit port in the procfs format
/// (matches the {:04X} format spec at the call sites).
const PORT_HEX_CHARS: usize = 4;
/// Number of bytes per chunk in the IPv6 procfs format. Bytes inside
/// each chunk are emitted in reverse (little-endian within the chunk).
const TCP6_CHUNK_BYTES: usize = 4;
/// Number of 4-byte chunks the IPv6 procfs format renders. The
/// const_assert below pins this against IPV6_BYTES so any future drift
/// surfaces at compile time.
const TCP6_CHUNK_COUNT: usize = IPV6_BYTES / TCP6_CHUNK_BYTES;
const _: () = assert!(TCP6_CHUNK_COUNT * TCP6_CHUNK_BYTES == IPV6_BYTES);
/// First line of /proc/net/tcp[6] is the column header. Data rows
/// follow.
const PROC_NET_TCP_HEADER_LINES: usize = 1;
/// Linux TCP_ESTABLISHED state value (include/uapi/linux/tcp.h).
const TCP_STATE_ESTABLISHED: u8 = 0x01;
/// Linux TCP_CLOSE_WAIT state value (include/uapi/linux/tcp.h).
const TCP_STATE_CLOSE_WAIT: u8 = 0x08;
/// Procfs renders the TCP state as a hexadecimal byte.
const TCP_STATE_RADIX: u32 = 16;
/// Per-session activity record. Constructed once per accepted SSH
/// connection in the accept loop, cloned via Arc into the SshSessionHandler
/// and the SftpDriver, registered weakly into the SessionRegistry so an
/// outside observer can enumerate live sessions without holding their
/// lifetime.
#[allow(dead_code)]
pub struct SessionDiag {
pub session_id: u64,
pub local: SocketAddr,
pub peer: SocketAddr,
pub accepted_at: Instant,
pub last_activity_ms: AtomicU64,
}
impl SessionDiag {
pub(super) fn new(local: SocketAddr, peer: SocketAddr) -> Self {
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
Self {
session_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
local,
peer,
accepted_at: Instant::now(),
last_activity_ms: AtomicU64::new(now_ms),
}
}
/// Update last_activity_ms to now. One Relaxed atomic store after
/// one SystemTime read.
pub(super) fn stamp(&self) {
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
self.last_activity_ms.store(now_ms, Ordering::Relaxed);
}
}
/// Mutex-guarded vector of weak references to live SessionDiags. The
/// accept loop pushes a new Weak on every connection; consumers walk
/// the vector and upgrade each Weak to read the stamp, retaining only
/// those whose strong count is still positive.
pub(super) type SessionRegistry = Mutex<Vec<Weak<SessionDiag>>>;
pub(super) fn new_session_registry() -> SessionRegistry {
Mutex::new(Vec::new())
}
/// Kernel TCP state for one connection, as reported by /proc/net/tcp[6].
/// Values follow the Linux TCP state numbering used in the procfs files.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(super) enum TcpState {
/// 0x01. Connection is open and exchanging data.
Established,
/// 0x08. Peer FIN'd, the local application has not yet closed
/// the socket. This is the wedge signature.
CloseWait,
/// Any other state (FIN_WAIT_1, FIN_WAIT_2, LAST_ACK, TIME_WAIT,
/// CLOSING, etc.) carrying the raw hex byte for diagnostics. The
/// watchdog treats these as not-yet-wedge: the connection is in a
/// transient close handshake or steady non-wedge state.
Other(u8),
}
/// Look up the kernel TCP state for the connection between (local, peer).
/// Reads /proc/net/tcp and /proc/net/tcp6, matches by hex'd address-port
/// tuple, and returns the parsed state.
///
/// Returns None when:
/// - /proc/net/tcp[6] cannot be read (non-Linux target, missing /proc).
/// - No row matches the requested (local, peer) tuple. Either the
/// connection has been finalised by the kernel and removed from the
/// table, or one or both addresses do not have a renderable form
/// for the relevant procfs file.
pub(super) fn probe_tcp_state(local: SocketAddr, peer: SocketAddr) -> Option<TcpState> {
if let Ok(content) = std::fs::read_to_string("/proc/net/tcp")
&& let Some(state) = lookup_tcp_state(&content, local, peer, false)
{
return Some(state);
}
if let Ok(content) = std::fs::read_to_string("/proc/net/tcp6")
&& let Some(state) = lookup_tcp_state(&content, local, peer, true)
{
return Some(state);
}
None
}
/// Search procfs content for a row matching (local, peer). The
/// ipv6_file flag selects the address-rendering convention. tcp6
/// uses 32-character hex strings and tcp uses 8-character, both with
/// little-endian byte order within each 4-byte chunk.
fn lookup_tcp_state(content: &str, local: SocketAddr, peer: SocketAddr, ipv6_file: bool) -> Option<TcpState> {
let local_hex = render_proc_net_tcp_addr(local, ipv6_file)?;
let peer_hex = render_proc_net_tcp_addr(peer, ipv6_file)?;
for line in content.lines().skip(PROC_NET_TCP_HEADER_LINES) {
let mut fields = line.split_whitespace();
let _sl = fields.next()?;
let f_local = fields.next()?;
let f_peer = fields.next()?;
let f_state = fields.next()?;
if f_local == local_hex && f_peer == peer_hex {
let raw = u8::from_str_radix(f_state, TCP_STATE_RADIX).ok()?;
let state = if raw == TCP_STATE_ESTABLISHED {
TcpState::Established
} else if raw == TCP_STATE_CLOSE_WAIT {
TcpState::CloseWait
} else {
TcpState::Other(raw)
};
return Some(state);
}
}
None
}
/// Render an IpAddr and port pair for the /proc/net/tcp[6] format. Returns
/// None when the SocketAddr cannot be expressed in the chosen file's
/// convention (e.g., a non-IPv4-mapped IPv6 address asked for tcp).
///
/// Format details:
/// - tcp: 8-character upper-case hex of the IPv4 octets in
/// little-endian order, then ':', then 4-character upper-case hex
/// of the port.
/// - tcp6: 32-character upper-case hex of the IPv6 octets in 4
/// chunks of 4 bytes, little-endian within each chunk, then ':',
/// then the same 4-character port suffix as tcp.
///
/// IPv4 SocketAddrs presented to tcp6 are mapped via ::ffff:a.b.c.d
/// before rendering. IPv4-mapped IPv6 SocketAddrs presented to tcp
/// are unwrapped before rendering. Mismatches return None.
fn render_proc_net_tcp_addr(addr: SocketAddr, ipv6_file: bool) -> Option<String> {
// Rendered length: address bytes encoded as 2 hex chars each + ':'
// separator + 4 hex port digits. Same shape for tcp and tcp6;
// only the address byte count differs.
const COLON_LEN: usize = 1;
let port = addr.port();
let addr_bytes = if ipv6_file { IPV6_BYTES } else { IPV4_BYTES };
let rendered_len = addr_bytes * HEX_CHARS_PER_BYTE + COLON_LEN + PORT_HEX_CHARS;
let mut s = String::with_capacity(rendered_len);
if !ipv6_file {
let v4 = match addr.ip() {
IpAddr::V4(v4) => v4,
IpAddr::V6(v6) => v6.to_ipv4_mapped()?,
};
let octets = v4.octets();
for i in (0..IPV4_BYTES).rev() {
write!(&mut s, "{:02X}", octets[i]).ok()?;
}
} else {
let bytes: [u8; IPV6_BYTES] = match addr.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped().octets(),
IpAddr::V6(v6) => v6.octets(),
};
for chunk_idx in 0..TCP6_CHUNK_COUNT {
let start = chunk_idx * TCP6_CHUNK_BYTES;
for i in 0..TCP6_CHUNK_BYTES {
write!(&mut s, "{:02X}", bytes[start + (TCP6_CHUNK_BYTES - 1) - i]).ok()?;
}
}
}
write!(&mut s, ":{:04X}", port).ok()?;
Some(s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
#[test]
fn render_ipv4_loopback_for_tcp_file() {
let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
assert_eq!(render_proc_net_tcp_addr(addr, false).as_deref(), Some("0100007F:08AE"));
}
#[test]
fn render_ipv4_loopback_mapped_for_tcp6_file() {
let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
assert_eq!(
render_proc_net_tcp_addr(addr, true).as_deref(),
Some("0000000000000000FFFF00000100007F:08AE")
);
}
#[test]
fn render_native_ipv6_for_tcp6_file() {
let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 2222, 0, 0));
// ::1 is fifteen zero bytes followed by 0x01. Chunks (LE within
// each 4-byte word): 00000000 00000000 00000000 01000000.
assert_eq!(
render_proc_net_tcp_addr(addr, true).as_deref(),
Some("00000000000000000000000001000000:08AE")
);
}
#[test]
fn render_native_ipv6_for_tcp_file_returns_none() {
let addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 2222, 0, 0));
// ::1 is not IPv4-mapped, so it cannot be rendered for tcp.
assert!(render_proc_net_tcp_addr(addr, false).is_none());
}
#[test]
fn render_distinct_ipv4_for_tcp_file() {
// Distinct octets pin the byte-reversal direction. The
// loopback test cannot do this because three of four octets
// are zero. Port 0xFFFF pins the port-hex width at 4.
let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(1, 2, 3, 4), 0xFFFF));
assert_eq!(render_proc_net_tcp_addr(addr, false).as_deref(), Some("04030201:FFFF"));
}
#[test]
fn render_distinct_ipv6_bytes_for_tcp6_file() {
// Bytes 00..0F, one distinct value per octet, exercise every
// index in the chunk-and-reverse loop. Each 4-byte chunk is
// emitted little-endian-within-chunk, so chunk 0 (bytes
// 00 01 02 03) renders as "03020100" and so on through chunk 3.
let addr = SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF]),
0xCAFE,
0,
0,
));
assert_eq!(
render_proc_net_tcp_addr(addr, true).as_deref(),
Some("03020100070605040B0A09080F0E0D0C:CAFE")
);
}
#[test]
fn render_ipv4_mapped_ipv6_for_tcp_file_unwraps() {
// ::ffff:1.2.3.4 presented to the tcp file is unwrapped to
// 1.2.3.4 and rendered as the IPv4 form. Covers the
// to_ipv4_mapped() branch in the tcp arm. Port 0 pins the
// leading-zero render.
let addr = SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF, 1, 2, 3, 4]),
0,
0,
0,
));
assert_eq!(render_proc_net_tcp_addr(addr, false).as_deref(), Some("04030201:0000"));
}
#[test]
fn lookup_finds_close_wait_in_tcp_file() {
let content = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\
0: 0100007F:08AE 0100007F:DEAD 08 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0\n";
let local = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
let peer = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0xDEAD));
assert_eq!(lookup_tcp_state(content, local, peer, false), Some(TcpState::CloseWait));
}
#[test]
fn lookup_finds_established_in_tcp6_file() {
let content = " sl local_address remote_address st\n\
0: 0000000000000000FFFF00000100007F:08AE 0000000000000000FFFF00000100007F:DEAD 01 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0\n";
// SocketAddr is IPv4 form but the row is IPv4-mapped IPv6 in tcp6.
let local = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
let peer = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0xDEAD));
assert_eq!(lookup_tcp_state(content, local, peer, true), Some(TcpState::Established));
}
#[test]
fn lookup_returns_none_when_no_match() {
let content = " sl local_address rem_address st\n\
0: 0100007F:08AE 0100007F:CAFE 01 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0\n";
let local = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
let peer = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0xDEAD));
assert_eq!(lookup_tcp_state(content, local, peer, false), None);
}
#[test]
fn lookup_returns_other_for_unfamiliar_state() {
let content = " sl local_address rem_address st\n\
0: 0100007F:08AE 0100007F:DEAD 05 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0\n";
let local = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2222));
let peer = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0xDEAD));
// 0x05 = FIN_WAIT_2, an Other state from the watchdog's view.
assert_eq!(lookup_tcp_state(content, local, peer, false), Some(TcpState::Other(0x05)));
}
}
+126
View File
@@ -0,0 +1,126 @@
// 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.
//! SFTP protocol support for RustFS.
//!
//! Provides an SSH server with the SFTP file transfer subsystem enabled.
//! Each SFTP operation is translated into one or more S3 API calls against
//! the local RustFS object store via the StorageBackend trait.
//!
//! The module is feature-gated behind the sftp feature and is composed of
//! seven user-facing submodules:
//!
//! - config: configuration loading from environment variables, plus host
//! key discovery and validation.
//! - constants: protocol limits, timeouts, and other named numeric values
//! used by the server and driver.
//! - server: russh handler implementation, password authentication against
//! IAM, and subsystem dispatch onto the SFTP driver.
//! - driver: SFTP operation handlers that translate each request into one
//! or more S3 calls on the supplied storage backend.
//! - lifecycle: per-session activity record, the registry the accept loop
//! walks, and the kernel TCP-state probe used by the watchdog.
//! - wedge_watchdog: per-session liveness watchdog that observes both the
//! SFTP-handler activity stamp and the TCP socket state.
//! - read_cache: per-handle in-memory read-ahead cache with a process-wide
//! memory ceiling.
//!
//! Configuration contract. Eleven RUSTFS_SFTP_* environment variables drive
//! the server: RUSTFS_SFTP_ENABLE, RUSTFS_SFTP_ADDRESS, RUSTFS_SFTP_HOST_KEY_DIR,
//! RUSTFS_SFTP_IDLE_TIMEOUT, RUSTFS_SFTP_PART_SIZE, RUSTFS_SFTP_READ_ONLY,
//! RUSTFS_SFTP_BANNER, RUSTFS_SFTP_HANDLES_PER_SESSION,
//! RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS, RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES,
//! RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES. Defaults and validation bounds
//! live on the constants in the limits module.
//!
//! Architecture. Two cross-cutting subsystems backstop session reliability
//! and read throughput:
//!
//! - Session-liveness watchdog. Every accepted connection runs under a
//! per-session watchdog that observes the SFTP-handler activity stamp
//! and the kernel TCP state for the connection. Sessions that fall
//! silent at the SFTP layer while the kernel reports CLOSE_WAIT are
//! cancelled on a bounded schedule. The watchdog backstops resource
//! accumulation regardless of which layer stalled. On Linux the
//! detection latency is on the order of 45 seconds; on non-Linux
//! targets the watchdog falls back to an inactivity ceiling on the
//! order of 30 minutes.
//!
//! - Per-handle read cache. Each open File handle holds an in-memory
//! buffer. On a cache miss the driver fetches a configurable byte
//! window from the backend, returns the requested portion, and stores
//! the rest. Subsequent reads inside that window are served from
//! memory. Total cache memory across every live handle is bounded by
//! a shared atomic accumulator enforced against the process-wide
//! ceiling. On ceiling breach the populate is skipped and the read
//! serves correctly via a single backend call without storing the
//! bytes for re-use.
//!
//! Authentication mirrors the S3 baseline: identities are looked up through
//! rustfs_iam and the supplied secret is compared in constant time against
//! the stored secret. Failures are logged via tracing warn and return an SSH
//! authentication rejection.
//!
//! Public types: SftpServer is the entry point an embedder constructs and
//! drives. SftpConfig and SftpInitError are the configuration and error
//! types returned by configuration loading. SftpDriver is the per-session
//! handler dispatch type. SftpError is the error type returned by SFTP
//! operations.
//!
//! Platform support. Host-key permission enforcement uses Unix mode bits.
//! On non-Unix targets SftpConfig::load_host_keys returns
//! SftpInitError::UnsupportedPlatform and the SFTP listener does not start.
//!
//! Peer-initiated signal requests on an open SFTP channel are intercepted
//! by the russh::server::Handler::signal override on SshSessionHandler in
//! server.rs, which logs the probe and rejects without acting.
pub mod config;
pub(crate) mod constants;
pub mod server;
mod attrs;
mod dir;
mod driver;
mod errors;
mod lifecycle;
mod paths;
mod read;
mod read_cache;
mod state;
mod wedge_watchdog;
mod write;
#[cfg(test)]
mod test_support;
pub use config::{SftpConfig, SftpInitError};
pub use driver::SftpDriver;
pub use errors::SftpError;
pub use server::SftpServer;
#[cfg(test)]
mod tests {
use super::*;
use crate::common::session::Protocol;
// Compile-time check that Protocol::Sftp, SftpConfig, and SftpInitError
// remain exported. Renaming or removing any of these breaks the test.
#[test]
fn sftp_module_and_variant_exist() {
let _variant = Protocol::Sftp;
let _config_type_name = std::any::type_name::<SftpConfig>();
let _error_type_name = std::any::type_name::<SftpInitError>();
}
}
+342
View File
@@ -0,0 +1,342 @@
// 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.
//! Path manipulation helpers used across the SFTP driver. Pure
//! functions: no driver state, no async, no backend calls.
use super::errors::SftpError;
use russh_sftp::protocol::StatusCode;
use rustfs_utils::path;
/// Prefix the input with "/" if it is empty or relative. SFTP paths are
/// addressed as absolute against the server root. Clients may submit a
/// relative form (e.g. "." or "foo/bar"). Both forms normalise to the
/// same absolute starting point before any cleaning or splitting runs.
pub(super) fn ensure_absolute(path: &str) -> String {
if path.is_empty() || !path.starts_with('/') {
format!("/{path}")
} else {
path.to_string()
}
}
/// Return the last path component of a slash-separated string, stripping
/// any trailing slash. Returns None when the input has no usable component
/// (empty input, or a string consisting solely of slashes).
pub(super) fn last_path_component(s: &str) -> Option<&str> {
let trimmed = s.trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
Some(trimmed.rsplit('/').next().unwrap_or(trimmed))
}
/// Extract the single filename component of full_key relative to prefix.
/// Returns None when full_key does not start with prefix, when the
/// residual is empty (key equalled prefix exactly), or when the residual
/// contains a slash (entry belongs under a sub-prefix and should have
/// appeared via common_prefixes under delimiter="/").
pub(super) fn relative_filename<'a>(full_key: &'a str, prefix: &str) -> Option<&'a str> {
let residual = full_key.strip_prefix(prefix)?;
if residual.is_empty() || residual.contains('/') {
return None;
}
Some(residual)
}
/// Canonicalise an incoming SFTP path and split it into an optional bucket
/// and object key.
///
/// An empty input is treated as root ("/"). An input that does not start
/// with "/" is prefixed with one and then addressed as an absolute path.
/// The result is passed through rustfs_utils::path::clean, which collapses
/// "." and ".." segments. Rooted ".." past the top is dropped by clean,
/// so no resulting path can escape the storage root. Keys containing the
/// reserved GLOBAL_DIR_SUFFIX marker ("__XLDIR__") are rejected because
/// that marker is the backend's internal encoding for directory objects
/// and is not part of the client-visible namespace.
///
/// Returns Ok((bucket, None)) for the root, Ok(("bucket", None)) for a
/// bucket-level directory, and Ok(("bucket", Some("key"))) otherwise.
/// Returns Err(BadMessage) for reserved or malformed inputs, including
/// any input containing an embedded NUL, CR, or LF byte. NUL is never
/// legitimate in a POSIX path component or an S3 key. CR and LF are
/// rejected at this boundary so a path emitted on a tracing field
/// cannot inject a line into the operator log; downstream warn paths
/// (skip-abort, stat fallback, REMOVE refusal) emit the bucket and key
/// without further sanitisation.
pub(super) fn parse_s3_path(input: &str) -> Result<(String, Option<String>), SftpError> {
if input.contains(['\0', '\r', '\n']) {
return Err(SftpError::code(StatusCode::BadMessage));
}
let cleaned = path::clean(&ensure_absolute(input));
// clean may return ".", "/", or a rooted path. It never returns a path
// that escapes above the root when the input is rooted, but reject any
// lingering ".." defensively in case the path::clean contract changes
// or has an edge case the canonicalisation misses.
if cleaned == "." || cleaned == ".." || cleaned.starts_with("../") {
return Ok((String::new(), None));
}
let (bucket, object) = path::path_to_bucket_object(&cleaned);
if object.contains(path::GLOBAL_DIR_SUFFIX) {
return Err(SftpError::code(StatusCode::BadMessage));
}
let key = if object.is_empty() { None } else { Some(object) };
Ok((bucket, key))
}
/// Replace C0 control bytes (other than tab) with the literal byte 0x3F
/// ("?"). POSIX filenames and S3 keys permit CR, LF, BEL, ESC, and the
/// other low-ASCII control bytes, but echoing them verbatim into the
/// SSH_FXP_NAME longname field or into a tracing emit lets a hostile key
/// inject a forged second entry or split a log line. Tab (0x09) is
/// kept because it is the column separator inside the longname format.
/// NUL is rejected at the parse boundary.
pub(super) fn sanitise_control_bytes(input: &str) -> std::borrow::Cow<'_, str> {
let needs_sanitise = input.bytes().any(|b| b < 0x20 && b != b'\t');
if !needs_sanitise {
return std::borrow::Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
if (ch as u32) < 0x20 && ch != '\t' {
out.push('?');
} else {
out.push(ch);
}
}
std::borrow::Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
use russh_sftp::protocol::StatusCode;
#[test]
fn parse_s3_path_root() {
let (bucket, key) = parse_s3_path("/").unwrap();
assert!(bucket.is_empty());
assert!(key.is_none());
let (bucket, key) = parse_s3_path("").unwrap();
assert!(bucket.is_empty());
assert!(key.is_none());
}
#[test]
fn parse_s3_path_bucket_only() {
let (bucket, key) = parse_s3_path("/mybucket").unwrap();
assert_eq!(bucket, "mybucket");
assert!(key.is_none());
}
#[test]
fn parse_s3_path_bucket_and_key() {
let (bucket, key) = parse_s3_path("/mybucket/path/to/file.txt").unwrap();
assert_eq!(bucket, "mybucket");
assert_eq!(key.as_deref(), Some("path/to/file.txt"));
}
#[test]
fn parse_s3_path_rejects_embedded_nul_byte() {
let err = parse_s3_path("/bucket/key\0withnul").expect_err("NUL must be rejected");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
let err = parse_s3_path("\0").expect_err("NUL-only input must be rejected");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
}
#[test]
fn parse_s3_path_rejects_carriage_return() {
let err = parse_s3_path("/bucket/line\r/inject").expect_err("CR must be rejected");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
}
#[test]
fn parse_s3_path_rejects_line_feed() {
let err = parse_s3_path("/bucket/line\n/inject").expect_err("LF must be rejected");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
}
#[test]
fn parse_s3_path_rejects_xldir_marker() {
let err = parse_s3_path("/bucket/__XLDIR__").expect_err("__XLDIR__ must be rejected");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
}
#[test]
fn parse_s3_path_collapses_dotdot_without_escaping_root() {
let (bucket, key) = parse_s3_path("/../../bucket/key").unwrap();
assert_eq!(bucket, "bucket");
assert_eq!(key.as_deref(), Some("key"));
}
#[test]
fn parse_s3_path_cleans_dotdot_between_segments() {
let (bucket, key) = parse_s3_path("/bucket/sub/../file").unwrap();
assert_eq!(bucket, "bucket");
assert_eq!(key.as_deref(), Some("file"));
}
#[test]
fn parse_s3_path_strips_trailing_slash_on_subdir_path() {
let (bucket, key) = parse_s3_path("/bucket/subdir/").unwrap();
assert_eq!(bucket, "bucket");
assert_eq!(key.as_deref(), Some("subdir"));
}
#[test]
fn parse_s3_path_strips_trailing_slash_on_nested_subdir_path() {
let (bucket, key) = parse_s3_path("/bucket/a/b/c/").unwrap();
assert_eq!(bucket, "bucket");
assert_eq!(key.as_deref(), Some("a/b/c"));
}
#[test]
fn parse_s3_path_collapses_bucket_trailing_slash_to_no_key() {
let (bucket, key) = parse_s3_path("/bucket/").unwrap();
assert_eq!(bucket, "bucket");
assert!(key.is_none());
}
#[test]
fn sanitise_control_bytes_passes_plain_ascii_unchanged() {
let input = "weekly-report-Q1.pdf";
let out = sanitise_control_bytes(input);
assert_eq!(out.as_ref(), input);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn sanitise_control_bytes_replaces_lf() {
assert_eq!(sanitise_control_bytes("weekly\nreport.pdf").as_ref(), "weekly?report.pdf");
}
#[test]
fn sanitise_control_bytes_replaces_cr() {
assert_eq!(sanitise_control_bytes("report\rpdf").as_ref(), "report?pdf");
}
#[test]
fn sanitise_control_bytes_replaces_crlf() {
assert_eq!(sanitise_control_bytes("a\r\nb").as_ref(), "a??b");
}
#[test]
fn sanitise_control_bytes_preserves_tab() {
let input = "col1\tcol2";
let out = sanitise_control_bytes(input);
assert_eq!(out.as_ref(), input);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn sanitise_control_bytes_replaces_other_c0_controls() {
assert_eq!(sanitise_control_bytes("alarm\x07bell\x1bescape").as_ref(), "alarm?bell?escape");
}
#[test]
fn sanitise_control_bytes_preserves_unicode_above_c0() {
let input = "report-Q1-é-中文.pdf";
let out = sanitise_control_bytes(input);
assert_eq!(out.as_ref(), input);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn ensure_absolute_prefixes_relative_input() {
assert_eq!(ensure_absolute("foo/bar"), "/foo/bar");
assert_eq!(ensure_absolute(""), "/");
assert_eq!(ensure_absolute("."), "/.");
}
#[test]
fn ensure_absolute_passes_through_absolute_input() {
assert_eq!(ensure_absolute("/"), "/");
assert_eq!(ensure_absolute("/foo"), "/foo");
assert_eq!(ensure_absolute("/a/b/c"), "/a/b/c");
}
#[test]
fn last_path_component_extracts_final_segment() {
assert_eq!(last_path_component("foo/bar/baz"), Some("baz"));
assert_eq!(last_path_component("foo/bar/baz/"), Some("baz"));
assert_eq!(last_path_component("singleton"), Some("singleton"));
assert_eq!(last_path_component("singleton/"), Some("singleton"));
}
#[test]
fn last_path_component_returns_none_for_empty_or_slashes_only() {
assert_eq!(last_path_component(""), None);
assert_eq!(last_path_component("/"), None);
assert_eq!(last_path_component("///"), None);
}
#[test]
fn relative_filename_returns_single_component_residual() {
assert_eq!(relative_filename("foo/bar.txt", "foo/"), Some("bar.txt"));
assert_eq!(relative_filename("file.txt", ""), Some("file.txt"));
}
#[test]
fn relative_filename_rejects_non_matching_prefix() {
assert_eq!(relative_filename("other/bar.txt", "foo/"), None);
}
#[test]
fn relative_filename_rejects_residual_with_slash() {
assert_eq!(relative_filename("foo/sub/bar.txt", "foo/"), None);
}
#[test]
fn relative_filename_rejects_empty_residual() {
assert_eq!(relative_filename("foo/", "foo/"), None);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig {
cases: 10_000,
.. proptest::prelude::ProptestConfig::default()
})]
#[test]
fn parse_s3_path_never_leaks_control_bytes_or_traversal_in_ok_output(
input in proptest::prelude::any::<String>(),
) {
match parse_s3_path(&input) {
Err(err) => {
proptest::prop_assert!(
matches!(StatusCode::from(err), StatusCode::BadMessage),
"parse_s3_path rejected input with an unexpected status",
);
}
Ok((bucket, key)) => {
proptest::prop_assert!(!bucket.contains('/'));
proptest::prop_assert!(!bucket.contains(['\0', '\r', '\n']));
if let Some(k) = key.as_deref() {
proptest::prop_assert!(!k.contains(['\0', '\r', '\n']));
proptest::prop_assert!(!k.split('/').any(|seg| seg == ".."));
proptest::prop_assert!(!k.starts_with('/'));
}
}
}
}
}
}
+550
View File
@@ -0,0 +1,550 @@
// 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.
//! Read-side operation handlers: open_read and the body of the read()
//! Handler trait method.
use super::attrs::{s3_attrs_to_sftp, timestamp_to_mtime};
use super::constants::limits::{MAX_READ_LEN, READ_CACHE_DISABLED};
use super::driver::SftpDriver;
use super::errors::{SftpError, s3_error_to_sftp};
use super::paths::parse_s3_path;
use super::state::HandleState;
use crate::common::client::s3::StorageBackend;
use crate::common::gateway::S3Action;
use futures_util::StreamExt;
use russh_sftp::protocol::{Data, Handle, StatusCode};
impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
/// Read-side OPEN: authorise GetObject, HEAD the object to capture
/// size and mtime, allocate a File handle. Errors are mapped through
/// s3_error_to_sftp so a missing object returns NoSuchFile and a
/// permission failure as PermissionDenied.
pub(super) async fn open_read(&mut self, id: u32, filename: &str) -> Result<Handle, SftpError> {
let (bucket, key) = parse_s3_path(filename)?;
let Some(object_key) = key else {
return Err(SftpError::code(StatusCode::NoSuchFile));
};
if bucket.is_empty() {
return Err(SftpError::code(StatusCode::NoSuchFile));
}
self.authorize(&S3Action::GetObject, &bucket, Some(&object_key)).await?;
// Fetch object metadata (size, last-modified) without downloading
// the body. These are cached on the handle so READ can detect EOF
// and FSTAT can answer without another backend call.
let head = self
.run_backend(
"head_object",
self.storage
.head_object(&bucket, &object_key, self.access_key(), self.secret_key()),
)
.await?;
let size = head.content_length.unwrap_or(0).max(0) as u64;
let mtime = timestamp_to_mtime(head.last_modified);
let attrs = s3_attrs_to_sftp(size, mtime, false);
let read_cache = self.new_read_cache();
let handle = self.allocate_handle(HandleState::File {
bucket,
key: object_key,
size,
attrs,
read_cache,
})?;
Ok(Handle { id, handle })
}
/// Body of the SSH_FXP_READ handler. Returns up to len bytes starting
/// at offset, capped at MAX_READ_LEN and the cached object size.
/// Zero-length requests are rejected with BadMessage at the boundary.
/// Offsets at or past end-of-file return Eof without a network call.
///
/// Cache-aware. When the requested bytes are already in the
/// per-handle cached chunk, they are returned without a backend
/// round trip. Otherwise a window-sized range is fetched from the
/// backend, the cache is populated when the new chunk would not
/// push the process-wide memory total past the configured
/// ceiling, and the requested bytes are returned from the fetched
/// data. When the populate call is skipped due to the memory
/// ceiling, the read still completes from the fetched bytes.
/// Only the caching step is dropped, at the cost of one backend
/// call per FXP_READ.
///
/// When read_cache_window is set to READ_CACHE_DISABLED the cache
/// is bypassed entirely. The cache-hit probe always misses
/// because the buffer is never populated, the fetch length equals
/// the requested length, and try_populate_read_cache returns
/// early without touching the process-wide accumulator.
pub(super) async fn read_inner(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result<Data, SftpError> {
if len == 0 {
// Reject zero-length reads at the boundary. The S3 range header
// would otherwise underflow when calculating the inclusive end
// offset.
return Err(SftpError::code(StatusCode::BadMessage));
}
// Cap the client-requested length to MAX_READ_LEN (256 KiB) to
// bound the per-request memory allocation.
let capped_len = len.min(MAX_READ_LEN);
let (bucket, key, size) = self.with_handle_ref(&handle, |state| match state {
HandleState::File { bucket, key, size, .. } => Ok((bucket.clone(), key.clone(), *size)),
HandleState::Dir(_) | HandleState::Write { .. } => Err(SftpError::code(StatusCode::Failure)),
})?;
// Reading at or past EOF returns Eof without a backend call.
// Clamp the read length to the remaining bytes.
if offset >= size {
return Err(SftpError::code(StatusCode::Eof));
}
let remaining = size - offset;
let actual_len = (capped_len as u64).min(remaining);
// Cache-hit fast path. Probe the cache while only borrowing
// the handle table. No backend call, no auth call, no await,
// so cancellation cannot fire between the probe and the
// return.
let cached = self.with_handle_ref(&handle, |state| match state {
HandleState::File { read_cache, .. } => Ok(read_cache.get(offset, actual_len).map(|s| s.to_vec())),
_ => Err(SftpError::code(StatusCode::Failure)),
})?;
if let Some(data) = cached {
return Ok(Data { id, data });
}
// Cache miss. Authorise and fetch a window-sized range. The
// fetch length is normally read_cache_window. Near EOF it
// shrinks to the remaining bytes so a tail read does not
// over-fetch past the object. The fetch length is also held
// at or above actual_len so that when read_cache_window is
// smaller than actual_len, or when read_cache_window is the
// READ_CACHE_DISABLED sentinel (0), the backend call still
// returns the bytes the client requested.
self.authorize(&S3Action::GetObject, &bucket, Some(&key)).await?;
let fetch_len = self.read_cache_window.max(actual_len).min(remaining);
let window_bytes = self.fetch_object_range(&bucket, &key, offset, fetch_len).await?;
if window_bytes.is_empty() {
return Err(SftpError::code(StatusCode::Eof));
}
// Slice the response from the front of the fetched bytes.
// The remainder is offered to the cache below for reuse on
// subsequent reads inside the same chunk.
let response_len = actual_len.min(window_bytes.len() as u64) as usize;
let data = window_bytes[..response_len].to_vec();
self.try_populate_read_cache(&handle, offset, window_bytes);
Ok(Data { id, data })
}
/// Issue one get_object_range backend call and drain the response
/// body into a contiguous buffer. Each per-chunk await is wrapped
/// in the same per-call deadline that bounds the outer
/// get_object_range. A backend that returns a body and then stalls
/// mid-stream returns Failure here rather than pinning the session
/// task on body.next().
async fn fetch_object_range(&self, bucket: &str, key: &str, offset: u64, fetch_len: u64) -> Result<Vec<u8>, SftpError> {
let out = self
.run_backend(
"get_object_range",
self.storage
.get_object_range(bucket, key, self.access_key(), self.secret_key(), offset, fetch_len),
)
.await?;
let Some(mut body) = out.body else {
return Err(SftpError::code(StatusCode::Failure));
};
let mut buf = Vec::with_capacity(usize::try_from(fetch_len).unwrap_or(0));
loop {
let chunk_timeout = std::time::Duration::from_secs(self.backend_op_timeout_secs);
let next = match tokio::time::timeout(chunk_timeout, body.next()).await {
Ok(next) => next,
Err(_elapsed) => {
return Err(s3_error_to_sftp(
"get_object_stream",
format!("stream chunk timed out after {} seconds", self.backend_op_timeout_secs),
));
}
};
let Some(chunk) = next else { break };
let bytes = chunk.map_err(|e| s3_error_to_sftp("get_object_stream", e))?;
buf.extend_from_slice(&bytes);
}
Ok(buf)
}
/// Populate the per-handle read cache when the projected total
/// memory across all live caches would stay at or below the
/// configured ceiling. The check is a best-effort peek-then-add.
/// Under concurrent populate calls from many sessions the
/// projected total can briefly drift above the limit by at most
/// (concurrent_populates * window_bytes). The limit is a soft
/// cap. When the projected total exceeds the limit, the bytes
/// are dropped without storing them, and a subsequent FXP_READ
/// inside the same chunk-aligned range issues a fresh backend
/// call instead of being served from cache.
///
/// The accumulator load and the populate call run with no
/// intervening await, so the snapshot is still valid when the
/// populate call executes.
fn try_populate_read_cache(&mut self, handle: &str, offset: u64, window_bytes: Vec<u8>) {
if self.read_cache_window == READ_CACHE_DISABLED {
return;
}
let cap_now = self.read_cache_in_use.load(std::sync::atomic::Ordering::Relaxed);
let cache_state = match self.handles.get(handle) {
Some(HandleState::File { read_cache, .. }) => read_cache.capacity() as u64,
_ => return,
};
let new_cap = window_bytes.capacity() as u64;
let projected = cap_now.saturating_sub(cache_state).saturating_add(new_cap);
if projected > self.read_cache_total_mem_limit {
return;
}
if let Some(state) = self.handles.get_mut(handle)
&& let HandleState::File { read_cache, .. } = state
{
read_cache.populate(offset, window_bytes);
}
}
}
#[cfg(test)]
mod tests {
use super::super::constants::limits::READ_CACHE_DISABLED;
use super::super::state::HandleState;
use super::super::test_support::{
TEST_PART_SIZE, build_driver, build_driver_with_read_cache, build_driver_with_timeout, capture_tracing_at, file_handle,
};
use crate::common::dummy_storage::{DummyBackend, DummyError};
use crate::common::gateway::with_test_auth_override;
use russh_sftp::protocol::{FileAttributes, StatusCode};
use russh_sftp::server::Handler;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::Level;
#[tokio::test]
async fn read_with_len_zero_returns_bad_message_before_backend_call() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 100, FileAttributes::default()))
.expect("allocate");
let err = driver
.read(1, handle_id, 0, 0)
.await
.expect_err("len=0 must return BadMessage");
assert!(matches!(StatusCode::from(err), StatusCode::BadMessage));
}
#[tokio::test]
async fn read_at_offset_past_size_returns_eof_before_backend_call() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend.clone(), TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 10, FileAttributes::default()))
.expect("allocate");
let err = driver
.read(2, handle_id, 10, 4)
.await
.expect_err("offset==size must return Eof");
assert!(matches!(StatusCode::from(err), StatusCode::Eof));
}
#[tokio::test]
async fn read_normal_path_returns_bytes_from_backend() {
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_bytes(b"hello".to_vec());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 5, FileAttributes::default()))
.expect("allocate");
let data = with_test_auth_override(|_, _, _| true, driver.read(3, handle_id, 0, 1024))
.await
.expect("read must succeed");
assert_eq!(data.data, b"hello".to_vec());
}
/// Read past end-of-file is the spec-mandated SFTP termination
/// signal. The handler must return Eof on the wire and stay silent
/// in the log so a normal download burst does not generate one
/// error-level event per file.
#[tokio::test]
async fn read_past_eof_emits_no_error_level_event() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 10, FileAttributes::default()))
.expect("allocate");
let (result, captured) = capture_tracing_at(Level::ERROR, async { driver.read(11, handle_id, 10, 4).await }).await;
let err = result.expect_err("offset==size must return Eof");
assert!(matches!(StatusCode::from(err), StatusCode::Eof));
assert!(
!captured.contains("ERROR"),
"Eof return must not produce an error-level event, captured: {captured}"
);
assert!(
!captured.contains("SFTP READ failed"),
"Eof return must not log SFTP READ failed, captured: {captured}"
);
}
/// A non-Eof failure on the read path is operator-visible. The
/// assertion below confirms a backend error produces an
/// error-level event.
#[tokio::test]
async fn read_backend_failure_emits_error_level_event() {
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_err(DummyError::Injected("backend exploded".into()));
let mut driver = build_driver(Arc::clone(&backend), TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 1024, FileAttributes::default()))
.expect("allocate");
let (result, captured) =
capture_tracing_at(Level::ERROR, with_test_auth_override(|_, _, _| true, driver.read(12, handle_id, 0, 256))).await;
let err = result.expect_err("backend error must propagate as Err");
assert!(!matches!(StatusCode::from(err), StatusCode::Eof), "backend error must not be Eof");
assert!(
captured.contains("ERROR"),
"non-Eof backend failure must produce an error-level event, captured: {captured}"
);
assert!(
captured.contains("SFTP READ failed"),
"error-level event must carry the SFTP READ failed message, captured: {captured}"
);
}
/// run_backend wraps the outer get_object_range call in the per-call
/// deadline, but the body iteration inside read_inner is a separate
/// stream of awaits. A backend that returns the body and then stalls
/// mid-stream pins the session task on body.next() until something
/// else closes the connection. The per-chunk timeout closes that gap.
/// This test queues a body that emits one chunk and stalls forever
/// on the next .next() poll, runs read with a 1 s backend deadline,
/// and asserts that the call returns Failure within the deadline plus
/// a generous buffer rather than waiting on the outer 10 s guard.
#[tokio::test(flavor = "current_thread")]
async fn read_chunk_stall_returns_failure_within_deadline() {
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_stalling_after_chunk(b"prefix".to_vec(), 4096);
let timeout_secs: u64 = 1;
let mut driver = build_driver_with_timeout(Arc::clone(&backend), TEST_PART_SIZE, timeout_secs);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 4096, FileAttributes::default()))
.expect("allocate");
let start = Instant::now();
let outcome = tokio::time::timeout(
Duration::from_secs(10),
with_test_auth_override(|_, _, _| true, driver.read(14, handle_id, 0, 4096)),
)
.await;
let elapsed = start.elapsed();
let inner = outcome.expect("per-chunk deadline must fire before the outer 10 s guard");
let err = inner.expect_err("stalled body must surface as Err");
assert!(
!matches!(StatusCode::from(err), StatusCode::Eof),
"stalled body must not be reported as Eof"
);
assert!(
elapsed < Duration::from_secs(timeout_secs + 4),
"stalled body must time out within {} s, elapsed: {:?}",
timeout_secs + 4,
elapsed,
);
}
/// Sequential reads on the same handle are served from the cache
/// after the first miss. The DummyBackend queues exactly one
/// get_object_range response sized to the configured window. With
/// the cache wired the driver consumes that one response on the
/// first read. Subsequent reads inside the cached chunk are
/// returned from the buffer without a second backend call. The
/// queue is empty after the first response, so any second backend
/// call would return NoSuchKey and fail the test.
#[tokio::test]
async fn sequential_reads_cache_hit_after_first_miss() {
let window: u64 = 64 * 1024;
let object_size: u64 = window;
let payload: Vec<u8> = (0..object_size as usize).map(|i| i as u8).collect();
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_bytes(payload.clone());
let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024);
let handle_id = driver
.allocate_handle(file_handle("b", "k", object_size, FileAttributes::default()))
.expect("allocate");
let chunk: u32 = 8 * 1024;
let mut offset: u64 = 0;
let mut assembled: Vec<u8> = Vec::with_capacity(object_size as usize);
let mut reads: u32 = 0;
while offset < object_size {
let data = with_test_auth_override(|_, _, _| true, driver.read(20 + reads, handle_id.clone(), offset, chunk))
.await
.expect("read inside the cached window must succeed without a second backend call");
assert!(!data.data.is_empty(), "non-empty hit");
assembled.extend_from_slice(&data.data);
offset += data.data.len() as u64;
reads += 1;
assert!(reads < 100, "loop guard: reads must terminate inside the window");
}
assert_eq!(assembled, payload, "assembled bytes must match seed");
assert!(reads > 1, "test must drive more than one FXP_READ to exercise the cache");
}
/// A read sequence that crosses two windows triggers exactly two
/// backend calls. Two responses sized to the window are queued.
/// Reads within window 1 are served from the buffer after the
/// miss that fetched it. The boundary read at offset == window
/// falls outside the cached chunk and triggers a second backend
/// call to fetch window 2.
#[tokio::test]
async fn read_crossing_two_windows_triggers_two_backend_calls() {
let window: u64 = 64 * 1024;
let object_size: u64 = window * 2;
let first_window: Vec<u8> = vec![0xAA_u8; window as usize];
let second_window: Vec<u8> = vec![0xBB_u8; window as usize];
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_bytes(first_window.clone());
backend.queue_get_object_range_bytes(second_window.clone());
let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024);
let handle_id = driver
.allocate_handle(file_handle("b", "k", object_size, FileAttributes::default()))
.expect("allocate");
// First read fetches from the backend and populates window 1.
let r1 = with_test_auth_override(|_, _, _| true, driver.read(30, handle_id.clone(), 0, 1024))
.await
.expect("first read must succeed");
assert!(r1.data.iter().all(|b| *b == 0xAA), "first read must come from window 1");
// Second read inside the cached chunk is served from the
// buffer. No second backend call yet.
let r2 = with_test_auth_override(|_, _, _| true, driver.read(31, handle_id.clone(), 1024, 1024))
.await
.expect("mid-window read must succeed from cache");
assert!(r2.data.iter().all(|b| *b == 0xAA), "mid-window read still in window 1");
// Reading at offset == window falls outside the cached chunk
// and triggers the second backend call.
let r3 = with_test_auth_override(|_, _, _| true, driver.read(32, handle_id.clone(), window, 1024))
.await
.expect("read at offset=window must succeed via second backend call");
assert!(r3.data.iter().all(|b| *b == 0xBB), "read at window boundary must come from window 2");
// A read inside the second cached chunk is served from the
// buffer. The queue is empty by now, so any third backend
// call would fail.
let r4 = with_test_auth_override(|_, _, _| true, driver.read(33, handle_id, window + 1024, 1024))
.await
.expect("mid-window-2 read must succeed from cache");
assert!(r4.data.iter().all(|b| *b == 0xBB), "mid-window-2 read still in window 2");
}
/// A partial-hit FXP_READ at the window edge returns only the
/// portion of the requested range that sits inside the cached
/// chunk. The driver must not issue a backend call to make up
/// the rest of the requested length on the same FXP_READ. The
/// next FXP_READ from the client triggers the refresh.
#[tokio::test]
async fn partial_window_edge_hit_returns_short_read() {
let window: u64 = 1024;
let object_size: u64 = window * 2;
let first_window: Vec<u8> = vec![0xCC_u8; window as usize];
let second_window: Vec<u8> = vec![0xDD_u8; window as usize];
let backend = Arc::new(DummyBackend::new());
backend.queue_get_object_range_bytes(first_window);
backend.queue_get_object_range_bytes(second_window);
let mut driver = build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, window, 1024 * 1024 * 1024);
let handle_id = driver
.allocate_handle(file_handle("b", "k", object_size, FileAttributes::default()))
.expect("allocate");
// Populate window 1 with a full read.
let _ = with_test_auth_override(|_, _, _| true, driver.read(40, handle_id.clone(), 0, window as u32))
.await
.expect("populate window 1");
// Ask for 256 bytes starting 64 bytes before window end. Only
// 64 bytes are in the window. The driver must return 64.
let edge = with_test_auth_override(|_, _, _| true, driver.read(41, handle_id, window - 64, 256))
.await
.expect("partial-hit read must succeed");
assert_eq!(edge.data.len(), 64, "partial hit must return only the in-window portion");
assert!(edge.data.iter().all(|b| *b == 0xCC), "partial hit bytes must come from window 1");
}
/// With READ_CACHE_DISABLED set as the window value the cache is
/// bypassed entirely. Each FXP_READ must hit the backend, and no
/// buffer is retained between reads. Verified by queueing one
/// backend response per expected FXP_READ; if any read short-
/// circuited via the cache the queue would still hold a response
/// at the end, and a subsequent read would return an extra
/// backend payload. A separate assertion confirms the per-handle
/// ReadCache buf stays at zero capacity across the read sequence.
#[tokio::test]
async fn read_cache_disabled_hits_backend_on_every_read() {
let chunk_size: usize = 4 * 1024;
let read_count: u32 = 5;
let object_size: u64 = (chunk_size as u64) * (read_count as u64);
let backend = Arc::new(DummyBackend::new());
for i in 0..read_count {
let payload = vec![(i + 1) as u8; chunk_size];
backend.queue_get_object_range_bytes(payload);
}
let mut driver =
build_driver_with_read_cache(Arc::clone(&backend), TEST_PART_SIZE, READ_CACHE_DISABLED, 1024 * 1024 * 1024);
let handle_id = driver
.allocate_handle(file_handle("b", "k", object_size, FileAttributes::default()))
.expect("allocate");
for i in 0..read_count {
let offset = (chunk_size as u64) * (i as u64);
let data = with_test_auth_override(|_, _, _| true, driver.read(50 + i, handle_id.clone(), offset, chunk_size as u32))
.await
.expect("each read must succeed via the backend");
assert_eq!(data.data.len(), chunk_size, "read must return full requested length");
let expected_byte = (i + 1) as u8;
assert!(
data.data.iter().all(|b| *b == expected_byte),
"read {i} payload must come from the i-th queued backend response"
);
let cap = driver.with_handle_ref(&handle_id, |state| match state {
HandleState::File { read_cache, .. } => Ok(read_cache.capacity()),
_ => Ok(usize::MAX),
});
assert_eq!(cap.expect("handle present"), 0, "ReadCache buf must stay empty when disabled");
}
}
}
+229
View File
@@ -0,0 +1,229 @@
// 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.
//! Per-handle read cache.
//!
//! One in-memory buffer per open File handle. The driver fetches a
//! chunk of bytes from the backend in a single call and holds it in
//! the buffer. Subsequent reads inside that chunk are served from
//! memory instead of one backend call per read. The chunk size is
//! configurable. With the 4 MiB default and the 256 KiB client read
//! size, sixteen FXP_READs are served from one backend call.
//!
//! Total cache memory across every live handle in the process is
//! bounded by a shared atomic accumulator. Each ReadCache holds an
//! Arc to that accumulator. The populate method adjusts the
//! accumulator by the difference between the old and new buf
//! capacities. The Drop impl subtracts the live capacity when the
//! cache is dropped. Before calling the populate method, the driver
//! checks the projected total against the operator-supplied limit.
//! When a populate call would push the total past the limit, the
//! driver skips populate and serves the read with a single backend
//! call without storing the bytes.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// One cached chunk of bytes for a single open File handle. The
/// chunk covers a contiguous byte range starting at window_offset.
/// The buf field stores the bytes for the range [window_offset,
/// window_offset + buf.len()).
pub(super) struct ReadCache {
buf: Vec<u8>,
window_offset: u64,
/// Process-wide accumulator of live cache memory in bytes. The
/// Drop impl subtracts the live buf.capacity(). The populate
/// method subtracts the old capacity and adds the new.
in_use: Arc<AtomicU64>,
}
impl ReadCache {
/// Build an empty cache bound to the shared in_use accumulator.
/// The buf field starts empty. No bytes are allocated until the
/// first call to the populate method.
pub(super) fn new(in_use: Arc<AtomicU64>) -> Self {
Self {
buf: Vec::new(),
window_offset: 0,
in_use,
}
}
/// Return the slice of cached bytes covering up to len bytes
/// starting at offset, or None when offset falls outside the
/// cached chunk. When the requested range extends past the end
/// of the cached chunk, only the portion inside the chunk is
/// returned. SFTPv3 draft section 6.4 allows a READ to return
/// fewer bytes than requested. A subsequent FXP_READ for the
/// remainder fetches a fresh chunk aligned to the new offset.
pub(super) fn get(&self, offset: u64, len: u64) -> Option<&[u8]> {
if self.buf.is_empty() || len == 0 {
return None;
}
if offset < self.window_offset {
return None;
}
let end = self.window_offset.saturating_add(self.buf.len() as u64);
if offset >= end {
return None;
}
let start = (offset - self.window_offset) as usize;
let avail = self.buf.len() - start;
let take = len.min(avail as u64) as usize;
Some(&self.buf[start..start + take])
}
/// Replace the cached chunk with bytes starting at offset. Any
/// previously cached bytes are dropped. The shared in_use
/// accumulator is adjusted by the difference between the old and
/// new buf capacities.
pub(super) fn populate(&mut self, offset: u64, bytes: Vec<u8>) {
let old_cap = self.buf.capacity() as u64;
self.in_use.fetch_sub(old_cap, Ordering::Relaxed);
self.buf = bytes;
self.window_offset = offset;
let new_cap = self.buf.capacity() as u64;
self.in_use.fetch_add(new_cap, Ordering::Relaxed);
}
/// Live size of the cached buf in bytes. Equal to buf.capacity().
pub(super) fn capacity(&self) -> usize {
self.buf.capacity()
}
}
impl Drop for ReadCache {
fn drop(&mut self) {
let live = self.buf.capacity() as u64;
if live != 0 {
self.in_use.fetch_sub(live, Ordering::Relaxed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> (ReadCache, Arc<AtomicU64>) {
let acc = Arc::new(AtomicU64::new(0));
let cache = ReadCache::new(Arc::clone(&acc));
(cache, acc)
}
#[test]
fn new_cache_returns_none_for_any_get() {
let (cache, _acc) = fresh();
assert!(cache.get(0, 1).is_none());
assert!(cache.get(0, 1024).is_none());
assert!(cache.get(1_000_000, 64).is_none());
}
#[test]
fn after_populate_get_hits_within_window() {
let (mut cache, _acc) = fresh();
let payload: Vec<u8> = (0..1024_u32).map(|i| i as u8).collect();
cache.populate(100, payload.clone());
let slice = cache.get(100, 64).expect("hit at window start");
assert_eq!(slice, &payload[..64]);
let slice = cache.get(200, 32).expect("hit inside window");
assert_eq!(slice, &payload[100..132]);
let slice = cache.get(100 + 1024 - 1, 1).expect("hit at last byte");
assert_eq!(slice, &payload[1023..1024]);
}
#[test]
fn get_at_or_past_window_end_returns_none() {
let (mut cache, _acc) = fresh();
cache.populate(100, vec![0u8; 256]);
// window covers [100, 356), so offset 356 is one past the end.
assert!(cache.get(356, 1).is_none());
assert!(cache.get(1024, 64).is_none());
}
#[test]
fn get_before_window_start_returns_none() {
let (mut cache, _acc) = fresh();
cache.populate(100, vec![0u8; 256]);
assert!(cache.get(0, 64).is_none());
assert!(cache.get(99, 1).is_none());
}
#[test]
fn partial_hit_at_window_edge_returns_in_window_portion() {
let (mut cache, _acc) = fresh();
let payload: Vec<u8> = (0..256_u16).map(|i| i as u8).collect();
cache.populate(100, payload.clone());
// window covers [100, 356), so offset 350 leaves 6 bytes in
// window when 64 are requested.
let slice = cache.get(350, 64).expect("partial hit");
assert_eq!(slice.len(), 6, "must truncate to in-window bytes");
assert_eq!(slice, &payload[250..256]);
}
#[test]
fn multiple_populates_discard_previous_window() {
let (mut cache, acc) = fresh();
cache.populate(100, vec![0xAA_u8; 256]);
let acc_after_first = acc.load(Ordering::Relaxed);
assert!(acc_after_first >= 256, "accumulator must include first window capacity");
cache.populate(1000, vec![0xBB_u8; 512]);
// Reads against the previous chunk must miss now.
assert!(cache.get(100, 1).is_none(), "first chunk discarded");
assert!(cache.get(0, 1).is_none());
// Reads against the new chunk return its bytes.
let slice = cache.get(1000, 4).expect("hit in second chunk");
assert_eq!(slice, &[0xBB, 0xBB, 0xBB, 0xBB]);
let acc_after_second = acc.load(Ordering::Relaxed);
assert!(
acc_after_second >= 512,
"accumulator must include second window capacity (got {acc_after_second})"
);
}
#[test]
fn capacity_reports_buf_capacity() {
let (mut cache, _acc) = fresh();
assert_eq!(cache.capacity(), 0, "empty cache reports zero capacity");
cache.populate(0, vec![0u8; 1024]);
assert!(
cache.capacity() >= 1024,
"populated cache must report buf capacity at least equal to bytes copied in (got {})",
cache.capacity(),
);
}
#[test]
fn drop_releases_accumulator() {
let acc = Arc::new(AtomicU64::new(0));
{
let mut cache = ReadCache::new(Arc::clone(&acc));
cache.populate(0, vec![0u8; 1024]);
assert!(acc.load(Ordering::Relaxed) >= 1024);
}
assert_eq!(acc.load(Ordering::Relaxed), 0, "accumulator drained on Drop");
}
#[test]
fn populate_then_get_zero_len_returns_none() {
let (mut cache, _acc) = fresh();
cache.populate(100, vec![0u8; 256]);
assert!(cache.get(100, 0).is_none(), "zero-length get returns None");
}
}
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
// 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.
//! Per-session state types for the SFTP driver.
//!
//! Operation implementations are defined in the relevant modules
//! (attrs.rs, read.rs, write.rs, dir.rs, driver.rs). state.rs holds
//! only type definitions and associated state definitions.
use super::read_cache::ReadCache;
use russh_sftp::protocol::FileAttributes;
use s3s::dto::ETag;
/// State held per open handle.
///
/// File handles cache the object size so READ can detect end-of-file without
/// re-issuing HeadObject on every call. Directory handles carry the S3
/// continuation token so each READDIR response corresponds to one S3
/// ListObjectsV2 page. This bounds response size without imposing an
/// arbitrary batch limit. Write handles run a multipart state machine:
/// small files buffer in memory and upload via a single PutObject at CLOSE,
/// large files transition to streaming multipart uploads as the buffer fills.
/// See WritePhase for the full state machine.
pub(super) enum HandleState {
File {
bucket: String,
key: String,
/// Object size captured at OPEN time. READ uses this to return EOF
/// once the offset reaches or exceeds the end of the object, as
/// required by SFTPv3 draft section 6.4.
size: u64,
/// Attributes captured at OPEN time so FSTAT can answer without a
/// second HeadObject.
attrs: FileAttributes,
/// Per-handle cached chunk of bytes fetched on the previous
/// READ miss. FXP_READs whose target range sits inside the
/// cached chunk are served from the buffer without a backend
/// round trip. Constructed empty in open_read. Dropped when
/// CLOSE removes the handle from the table, or when the
/// SftpDriver Drop impl runs at session teardown.
read_cache: ReadCache,
},
Dir(DirCursor),
Write {
bucket: String,
key: String,
/// Attributes returned by FSTAT against this handle.
/// The size field tracks the running total of bytes received so
/// a client polling FSTAT during a transfer sees the progress.
attrs: FileAttributes,
/// Multipart upload lifecycle state. See WritePhase.
phase: WritePhase,
},
}
/// Write-side state machine for a single open write handle.
///
/// Transitions are strictly forward. A handle begins in Buffering. Once the
/// first full part is ready, the driver issues CreateMultipartUpload and
/// transitions to Streaming. On any UploadPart failure the phase moves to
/// Failed, which rejects further writes and releases the upload_id via
/// AbortMultipartUpload at CLOSE. There is no recovery from Failed.
///
///
/// OPEN
/// |
/// v
/// Buffering --CLOSE--> PutObject (small file) ---------> DONE
/// |
/// | buffer >= part_size
/// | CreateMultipartUpload ok
/// v
/// Streaming --CLOSE--> UploadPart (tail) then
/// | ^ CompleteMultipartUpload --------> DONE
/// | | (large file)
/// | |
/// | | buffer >= part_size
/// | | UploadPart ok (loop)
/// | |
/// | UploadPart fails
/// v
/// Failed --CLOSE--> AbortMultipartUpload ---> (handle gone, no object)
///
/// Retry: CreateMultipartUpload fails -> stay in Buffering,
/// retry on next flush.
///
pub(super) enum WritePhase {
/// No multipart upload has been started. Bytes accumulate in part_buffer.
/// On CLOSE the buffered bytes upload via a single PutObject. If
/// CreateMultipartUpload fails on the first full-part flush, the phase
/// stays in Buffering and the next full-part flush retries the call:
/// a transient S3 error is invisible to the client.
Buffering {
/// Bytes received via WRITE not yet flushed to S3. Bounded by
/// part_size: the while-loop in write() drains it below part_size
/// before returning.
part_buffer: Vec<u8>,
},
/// CreateMultipartUpload has been issued. Full parts flush at the
/// part_size boundary. On CLOSE, the final partial part is uploaded
/// via UploadPart and the upload is finalised via
/// CompleteMultipartUpload.
Streaming {
/// upload_id returned by CreateMultipartUpload. Required by every
/// subsequent UploadPart, CompleteMultipartUpload, and
/// AbortMultipartUpload call.
upload_id: String,
/// Cached result of authorize_operation for AbortMultipartUpload,
/// evaluated at CreateMultipartUpload time. Drop consults this
/// to decide whether to issue AbortMultipartUpload without
/// running an async auth call (Drop is synchronous). close()
/// consults it too for consistency: same policy decision, same
/// observable outcome. False means the principal's IAM policy
/// denies AbortMultipartUpload, so cleanup is deferred to the
/// bucket's AbortIncompleteMultipartUpload lifecycle rule. The
/// flag is cached for one upload's lifetime: a policy edit
/// between the cache and the abort attempt is not honoured in
/// this session.
abort_authorized: bool,
/// Bytes received via WRITE not yet flushed to S3.
part_buffer: Vec<u8>,
/// Parts already uploaded. Passed to CompleteMultipartUpload in
/// order. Each entry carries the part number and the ETag returned
/// by UploadPart.
uploaded_parts: Vec<CompletedPart>,
/// Part number to use for the next UploadPart call. S3 part numbers
/// begin at 1 and increase monotonically.
next_part_number: i32,
},
/// An UploadPart call failed. The upload_id is retained so close()
/// can call AbortMultipartUpload when policy permits. Further
/// writes are rejected.
Failed {
/// upload_id returned by the CreateMultipartUpload call that opened
/// the now-failed upload.
upload_id: String,
/// Carried forward from Streaming at the point of failure. See
/// the identically named field on Streaming for the contract.
abort_authorized: bool,
},
}
/// Record of one successfully uploaded part. Carries the part number and
/// ETag needed by CompleteMultipartUpload to assemble the final object.
#[derive(Clone)]
pub(super) struct CompletedPart {
pub(super) part_number: i32,
pub(super) e_tag: ETag,
}
/// Identifier plus cached abort authorisation for one S3 multipart
/// upload. Holds the upload_id and the result of the AbortMultipartUpload
/// IAM probe issued at CreateMultipartUpload time. Holding the two
/// fields together prevents drift: any code path with the upload_id
/// also has the abort decision in scope without re-probing IAM, and the
/// synchronous Drop on SftpDriver can honour a Deny-Abort policy from
/// the cached flag without an async call.
///
/// Cloneable so a tombstone copy can live in the handle table while a
/// write_dispatch await holds a working copy. The fields are one String
/// and one bool, so cloning is cheap.
#[derive(Clone, Debug)]
pub(super) struct MultipartUpload {
pub(super) upload_id: String,
pub(super) abort_authorized: bool,
}
/// Directory iteration state.
///
/// Root lists buckets. ListBuckets is not batched: one response carries
/// every bucket the principal can see. Bucket and prefix listings walk
/// ListObjectsV2 one batch at a time, using continuation_token to cross
/// batch boundaries. The dots_emitted flag ensures the conventional "."
/// and ".." entries are produced exactly once, on the first READDIR call.
///
/// Clone is derived so the READDIR handler can install a cancellation-safety
/// tombstone (the pre-advance cursor) in the handle table before the
/// list_objects_v2 await. A cancelled READDIR leaves the tombstone so the
/// client's next READDIR resumes from the un-advanced position.
#[derive(Clone)]
pub(super) enum DirCursor {
Root {
buckets_delivered: bool,
dots_emitted: bool,
},
Listing {
bucket: String,
/// Object prefix terminated by "/", or empty when listing the root
/// of a bucket. S3 list_objects_v2 with a trailing-slash prefix
/// returns entries immediately under the prefix.
prefix: String,
/// Position in the ListObjectsV2 batch walk. Initial before the
/// first batch, Next(token) between batches, Done once S3 reports
/// the listing is exhausted.
continuation: ListingContinuation,
dots_emitted: bool,
},
}
/// Position within a batched S3 ListObjectsV2 walk. The state machine is
/// total: every transition arrives at exactly one of these variants.
/// Initial means no batch has been fetched and the next call to
/// next_listing_page issues list_objects_v2 with no continuation_token.
/// Next(token) means a previous batch returned this continuation token
/// and the next call passes it to list_objects_v2 to fetch the following
/// batch. Done means the listing is exhausted and subsequent calls
/// return an empty Vec without a network round trip.
#[derive(Clone)]
pub(super) enum ListingContinuation {
Initial,
Next(String),
Done,
}
#[cfg(test)]
mod tests {
use super::super::constants::limits::{S3_COPY_OBJECT_MAX_SIZE, S3_MAX_MULTIPART_PARTS, S3_MAX_PART_SIZE, S3_MIN_PART_SIZE};
#[test]
fn multipart_constants_match_s3_limits() {
// S3_COPY_OBJECT_MAX_SIZE 5 GiB is the CopyObject single-shot ceiling.
// S3_MIN_PART_SIZE 5 MiB is the S3 minimum for non-final parts.
// S3_MAX_PART_SIZE 5 GiB is the S3 maximum for any single part.
// S3_MAX_MULTIPART_PARTS 10000 is the S3 cap on parts per upload.
assert_eq!(S3_COPY_OBJECT_MAX_SIZE, 5 * 1024 * 1024 * 1024);
assert_eq!(S3_MIN_PART_SIZE, 5 * 1024 * 1024);
assert_eq!(S3_MAX_PART_SIZE, 5 * 1024 * 1024 * 1024);
assert_eq!(S3_MAX_MULTIPART_PARTS, 10_000);
}
}
+216
View File
@@ -0,0 +1,216 @@
// 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 #[cfg(test)] helpers used by the per-file test modules in
//! attrs.rs, dir.rs, driver.rs, errors.rs, paths.rs, read.rs, and
//! write.rs. The helpers cover the two test seams: build_driver
//! (constructs a driver around a DummyBackend without a real IAM or S3
//! backend) and write_handle (assembles a HandleState::Write under a
//! given WritePhase without touching the driver).
//!
//! #![allow(dead_code)] silences the rust-analyzer reachability analysis,
//! which does not always follow pub(super) chains across #[cfg(test)] gates.
#![allow(dead_code)]
use super::constants::limits::{
DEFAULT_BACKEND_OP_TIMEOUT_SECS, DEFAULT_HANDLES_PER_SESSION, READ_CACHE_TOTAL_MEM_DEFAULT, READ_CACHE_WINDOW_DEFAULT,
};
use super::driver::SftpDriver;
use super::lifecycle::SessionDiag;
use super::read_cache::ReadCache;
use super::state::{HandleState, WritePhase};
use crate::common::dummy_storage::DummyBackend;
use crate::common::session::{Protocol, test_session};
use russh_sftp::protocol::FileAttributes;
use std::io::Write;
use std::sync::{Arc, Mutex};
use tracing::Level;
use tracing_subscriber::fmt::MakeWriter;
pub(super) const TEST_PART_SIZE: u64 = 5 * 1024 * 1024;
fn test_session_diag() -> Arc<SessionDiag> {
let local = "127.0.0.1:2222".parse().expect("loopback parses");
let peer = "127.0.0.1:0".parse().expect("loopback parses");
Arc::new(SessionDiag::new(local, peer))
}
/// Build a HandleState::File ready to be inserted directly into a
/// driver handle table without running open_read. The read cache is
/// bound to a fresh per-call accumulator, decoupled from any
/// driver-owned accumulator so the test does not have to thread one
/// through. Tests that need to assert against the driver's
/// accumulator should drive open_read instead.
pub(super) fn file_handle(bucket: &str, key: &str, size: u64, attrs: FileAttributes) -> HandleState {
HandleState::File {
bucket: bucket.to_string(),
key: key.to_string(),
size,
attrs,
read_cache: ReadCache::new(Arc::new(std::sync::atomic::AtomicU64::new(0))),
}
}
/// Build a HandleState::Write with the given bucket, key, and
/// WritePhase, ready to be inserted directly into a driver handle
/// table without running open_write. Default FileAttributes are used.
pub(super) fn write_handle(bucket: &str, key: &str, phase: WritePhase) -> HandleState {
HandleState::Write {
bucket: bucket.to_string(),
key: key.to_string(),
attrs: FileAttributes::default(),
phase,
}
}
/// Build a read-write SftpDriver around the given backend and part
/// size. Handles per session, backend-op timeout, read-cache window,
/// read-cache total-memory ceiling, and the read-cache accumulator
/// take their defaults from the constants module.
pub(super) fn build_driver(backend: Arc<DummyBackend>, part_size: u64) -> SftpDriver<DummyBackend> {
let session_diag = test_session_diag();
SftpDriver::new(
backend,
test_session(Protocol::Sftp),
false,
part_size,
DEFAULT_HANDLES_PER_SESSION,
DEFAULT_BACKEND_OP_TIMEOUT_SECS,
READ_CACHE_WINDOW_DEFAULT,
READ_CACHE_TOTAL_MEM_DEFAULT,
Arc::new(std::sync::atomic::AtomicU64::new(0)),
session_diag,
)
}
/// Build a read-only SftpDriver around the given backend and part
/// size. The read-only flag is set so write operations return
/// PermissionDenied. Other parameters take their defaults from the
/// constants module.
pub(super) fn build_readonly_driver(backend: Arc<DummyBackend>, part_size: u64) -> SftpDriver<DummyBackend> {
let session_diag = test_session_diag();
SftpDriver::new(
backend,
test_session(Protocol::Sftp),
true,
part_size,
DEFAULT_HANDLES_PER_SESSION,
DEFAULT_BACKEND_OP_TIMEOUT_SECS,
READ_CACHE_WINDOW_DEFAULT,
READ_CACHE_TOTAL_MEM_DEFAULT,
Arc::new(std::sync::atomic::AtomicU64::new(0)),
session_diag,
)
}
/// Build a driver with custom read-cache window and total-memory
/// ceiling values. The remaining parameters match build_driver and
/// take their defaults from the constants module.
pub(super) fn build_driver_with_read_cache(
backend: Arc<DummyBackend>,
part_size: u64,
read_cache_window: u64,
read_cache_total_mem_limit: u64,
) -> SftpDriver<DummyBackend> {
let session_diag = test_session_diag();
SftpDriver::new(
backend,
test_session(Protocol::Sftp),
false,
part_size,
DEFAULT_HANDLES_PER_SESSION,
DEFAULT_BACKEND_OP_TIMEOUT_SECS,
read_cache_window,
read_cache_total_mem_limit,
Arc::new(std::sync::atomic::AtomicU64::new(0)),
session_diag,
)
}
/// Build a driver with a custom backend timeout for the integration
/// tests that exercise the deadline path against a stalling
/// DummyBackend primitive.
pub(super) fn build_driver_with_timeout(
backend: Arc<DummyBackend>,
part_size: u64,
backend_op_timeout_secs: u64,
) -> SftpDriver<DummyBackend> {
let session_diag = test_session_diag();
SftpDriver::new(
backend,
test_session(Protocol::Sftp),
false,
part_size,
DEFAULT_HANDLES_PER_SESSION,
backend_op_timeout_secs,
READ_CACHE_WINDOW_DEFAULT,
READ_CACHE_TOTAL_MEM_DEFAULT,
Arc::new(std::sync::atomic::AtomicU64::new(0)),
session_diag,
)
}
/// Tracing writer that appends every emitted byte to a shared buffer.
/// Tests assert on the captured text to discriminate between Err
/// returns that produce a log event and Err returns that stay silent.
#[derive(Clone)]
pub(super) struct CapturingWriter(Arc<Mutex<Vec<u8>>>);
impl Write for CapturingWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("lock").extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturingWriter {
type Writer = CapturingWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
/// Run the given async block with a fresh tracing subscriber that
/// records every event at the given minimum level into the returned
/// buffer. The subscriber is registered as the default for the
/// duration of the call and removed before this function returns.
/// tokio::test runs on a current-thread runtime so the thread-local
/// default subscriber covers every poll of the future.
///
/// Forces a callsite interest-cache rebuild after install. Without it,
/// a parallel test that triggered the same callsite under a NoSubscriber
/// default first can leave the callsite cached as disabled, so events
/// emitted under this thread's new default never reach the buffer.
pub(super) async fn capture_tracing_at<F, T>(min_level: Level, fut: F) -> (T, String)
where
F: std::future::Future<Output = T>,
{
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let writer = CapturingWriter(Arc::clone(&buf));
let subscriber = tracing_subscriber::fmt()
.with_max_level(min_level)
.with_writer(writer)
.with_ansi(false)
.with_target(true)
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
tracing::callsite::rebuild_interest_cache();
let value = fut.await;
let captured = String::from_utf8(buf.lock().expect("lock").clone()).expect("utf8");
(value, captured)
}
+318
View File
@@ -0,0 +1,318 @@
// 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.
//! Per-session liveness watchdog.
//!
//! Detects sessions that are silent at the SFTP handler layer while
//! the underlying TCP connection is in CLOSE_WAIT, and cancels them
//! so the server does not accumulate orphaned per-session resources
//! (handle table entries, in-flight multipart uploads, read caches).
//!
//! The watchdog runs one tokio task per session. Every
//! WEDGE_WATCHDOG_TICK_SECS it inspects the session's last-activity
//! stamp and the kernel TCP state for the connection. A wedged
//! session shows two coincident signals: silence past
//! WEDGE_FAST_KILL_SILENCE_SECS, and a TCP state of CLOSE_WAIT
//! (peer FIN'd, application has not closed). A healthy idle session
//! shows ESTABLISHED. Two consecutive positive ticks are required
//! before the watchdog cancels.
//!
//! The TCP-state probe lives in lifecycle::probe_tcp_state and reads
//! /proc/net/tcp[6] to look up the row matching the session's local
//! and peer addresses. CLOSE_WAIT is unambiguous, so a slow S3
//! backend operation that pipelines into a still-ESTABLISHED socket
//! cannot be misdiagnosed as a wedge.
//!
//! Platform-conditional detection latency. On Linux the procfs probe
//! gives a fast-kill window of WEDGE_FAST_KILL_SILENCE_SECS plus one
//! tick (approximately 45 s) from the moment a session enters
//! CLOSE_WAIT. On macOS, Windows, and other non-Linux targets the
//! /proc/net/tcp files are unavailable, the read returns Err, the
//! probe returns None, and the watchdog falls back to
//! WEDGE_FALLBACK_KILL_SILENCE_SECS (approximately 30 minutes).
//! Server-side resource accumulation is bounded in both cases. The
//! recommended deployment platform is Linux.
//!
//! On cancel the watchdog calls shutdown(Both) on the duplicated
//! socket so russh's inner select unwedges via EOF propagation,
//! then signals the shared CancellationToken so the outer session
//! task drops the RunningSession.
use super::constants::limits::{WEDGE_FALLBACK_KILL_SILENCE_SECS, WEDGE_FAST_KILL_SILENCE_SECS, WEDGE_WATCHDOG_TICK_SECS};
use super::lifecycle::{SessionDiag, TcpState, probe_tcp_state};
use socket2::Socket;
use std::net::Shutdown;
#[cfg(unix)]
use std::os::fd::AsFd;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
/// Reason a watchdog cancelled its session. Surfaced in the warn log
/// the watchdog emits at cancel time so operators can correlate the
/// cancel with the upstream client behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WedgeReason {
/// Two consecutive ticks observed silence past the fast threshold
/// AND a TCP state of CLOSE_WAIT (peer FIN'd, application has not
/// drained the SSH stream) on the second tick. The CLOSE_WAIT
/// observation on the cancelling tick is the load-bearing claim
/// in the operator log line.
TcpStateCloseWaitConfirmed,
/// Two consecutive ticks observed silence past the fast threshold
/// AND the TCP-state probe failed to return a known state on the
/// second tick (closed dup, missing /proc, kernel without procfs
/// entries). Session is not coming back and the kernel state was
/// not decisively observable when the cancel fired.
ProbeFailedConfirmed,
/// Silence past WEDGE_FALLBACK_KILL_SILENCE_SECS regardless of
/// the TCP_STATE probe result. Backstop for the case where the
/// wedge surfaces in a state other than CLOSE_WAIT and probes
/// kept returning healthy or non-decisive.
FallbackSilence,
}
impl WedgeReason {
fn as_str(self) -> &'static str {
match self {
Self::TcpStateCloseWaitConfirmed => "tcp_state_close_wait_confirmed",
Self::ProbeFailedConfirmed => "probe_failed_confirmed",
Self::FallbackSilence => "fallback_silence",
}
}
}
/// Duplicate the TcpStream's underlying socket via the safe AsFd path
/// and wrap the result in a socket2::Socket. The dup exists solely so
/// the watchdog can call shutdown(Both) on the wedged session without
/// racing russh for the original fd. Returns None when the dup fails.
/// Callers should treat None as "no watchdog this session, accept-loop
/// continues".
#[cfg(unix)]
pub(super) fn dup_socket(stream: &TcpStream) -> Option<Socket> {
let cloned = stream.as_fd().try_clone_to_owned().ok()?;
Some(Socket::from(cloned))
}
/// Non-Unix stub: AsFd on TcpStream is Unix-only. Returns None so the
/// caller falls back to WEDGE_FALLBACK_KILL_SILENCE_SECS.
#[cfg(not(unix))]
pub(super) fn dup_socket(_stream: &TcpStream) -> Option<Socket> {
None
}
/// Spawn a per-session watchdog tick task.
///
/// The task owns the duplicated socket (closed on task end via
/// Socket::Drop) and a clone of the session's CancellationToken.
/// The task exits when it cancels the session itself or when the
/// outer session task cancels the token after a clean session end.
pub(super) fn spawn_for_session(session_diag: Arc<SessionDiag>, socket: Socket, cancel_token: CancellationToken) {
tokio::spawn(async move {
let session_id = session_diag.session_id;
let local = session_diag.local;
let peer = session_diag.peer;
let mut tick = tokio::time::interval(Duration::from_secs(WEDGE_WATCHDOG_TICK_SECS));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// First tick fires immediately; skip it so the watchdog never
// makes a decision before one full silence window has elapsed.
tick.tick().await;
let mut wedge_suspected = false;
loop {
tokio::select! {
_ = cancel_token.cancelled() => break,
_ = tick.tick() => {
let silence_secs = silence_secs(&session_diag);
let probe = probe_tcp_state(local, peer);
let outcome = evaluate(silence_secs, probe, wedge_suspected);
match outcome {
Decision::Quiet => {
wedge_suspected = false;
}
Decision::SuspectedFirstTick => {
wedge_suspected = true;
}
Decision::Cancel(reason) => {
tracing::warn!(
target: "rustfs_protocols::sftp::watchdog",
session_id,
peer = %peer,
silence_secs,
reason = reason.as_str(),
"wedge watchdog cancelling session: russh select! parked outside its arms",
);
cancel_token.cancel();
break;
}
}
}
}
}
// Shut down the duplicated socket on every exit path. The
// cancellation could come from this watchdog's own kill
// decision, from the session task after a clean session end,
// or from the listener-wide shutdown cascade. In the wedge
// and shutdown-cascade cases the russh inner task is parked
// at chan.send(...).await on a backpressured mpsc and only
// unblocks when its read or write socket fails. shutdown
// here makes the next I/O on the original fd return EOF,
// which propagates through russh-sftp and drops the mpsc
// receiver. In the clean-end case russh has already returned
// and dropped its half of the fd; this call sends a final
// FIN on the still-open dup, which the peer's stack
// tolerates.
let _ = socket.shutdown(Shutdown::Both);
});
}
#[derive(Debug, PartialEq, Eq)]
enum Decision {
/// No wedge signal this tick; reset any suspected state.
Quiet,
/// First tick to observe silence past the fast threshold AND a
/// non-healthy probe result. Hold suspected state for one more
/// tick before deciding.
SuspectedFirstTick,
/// Cancel the session for the given reason.
Cancel(WedgeReason),
}
fn silence_secs(session_diag: &SessionDiag) -> u64 {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let last_ms = session_diag.last_activity_ms.load(Ordering::Relaxed);
now_ms.saturating_sub(last_ms) / 1000
}
/// Pure decision function. Takes the silence count, the TCP-state
/// probe outcome (Some(state) for a known kernel TCP state, None for
/// probe failure), and the previous tick's suspected flag. Returns
/// the action the watchdog should take.
///
/// CLOSE_WAIT is the unambiguous wedge signature: peer FIN'd and the
/// application has not closed. Other states (ESTABLISHED, FIN_WAIT_*,
/// transient close-handshake states) are treated as not-wedge.
///
/// Probe failures (None) are treated as wedge-suspect rather than
/// healthy: a session whose probe has failed and which has been silent
/// past the fast threshold is at minimum not coming back, and the
/// fallback silence threshold is the absolute backstop.
fn evaluate(silence_secs: u64, probe: Option<TcpState>, wedge_suspected: bool) -> Decision {
if silence_secs >= WEDGE_FALLBACK_KILL_SILENCE_SECS {
return Decision::Cancel(WedgeReason::FallbackSilence);
}
if silence_secs < WEDGE_FAST_KILL_SILENCE_SECS {
return Decision::Quiet;
}
let wedge_signal = match probe {
Some(TcpState::CloseWait) => true,
Some(TcpState::Established) | Some(TcpState::Other(_)) => false,
None => true,
};
if !wedge_signal {
return Decision::Quiet;
}
if wedge_suspected {
let reason = if probe.is_none() {
WedgeReason::ProbeFailedConfirmed
} else {
WedgeReason::TcpStateCloseWaitConfirmed
};
Decision::Cancel(reason)
} else {
Decision::SuspectedFirstTick
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn silence_below_fast_threshold_is_quiet() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS - 1, Some(TcpState::Established), false);
assert_eq!(decision, Decision::Quiet);
}
#[test]
fn silence_above_fast_with_established_is_quiet() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Established), false);
assert_eq!(decision, Decision::Quiet);
}
#[test]
fn silence_above_fast_with_transient_close_state_is_quiet() {
// FIN_WAIT_2 (0x05): the connection is in a clean close
// handshake initiated by the local side. Not a wedge.
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Other(0x05)), false);
assert_eq!(decision, Decision::Quiet);
}
#[test]
fn silence_above_fast_with_close_wait_first_tick_is_suspected() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), false);
assert_eq!(decision, Decision::SuspectedFirstTick);
}
#[test]
fn silence_above_fast_with_close_wait_second_tick_cancels() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true);
assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed));
}
#[test]
fn probe_failed_silence_above_fast_first_tick_is_suspected() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, false);
assert_eq!(decision, Decision::SuspectedFirstTick);
}
#[test]
fn probe_failed_silence_above_fast_second_tick_cancels_with_probe_failed_reason() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true);
assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed));
}
#[test]
fn close_wait_first_tick_then_probe_fail_second_tick_cancels_with_probe_failed_reason() {
// The cancel reason names the second tick's probe outcome
// because that is the kernel state at the moment the cancel
// fires. CLOSE_WAIT was no longer observable when the kill
// happened, so the operator log should not claim it was.
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true);
assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed));
}
#[test]
fn probe_fail_first_tick_then_close_wait_second_tick_cancels_with_close_wait_reason() {
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true);
assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed));
}
#[test]
fn silence_above_fallback_cancels_regardless_of_probe() {
let decision = evaluate(WEDGE_FALLBACK_KILL_SILENCE_SECS, Some(TcpState::Established), false);
assert_eq!(decision, Decision::Cancel(WedgeReason::FallbackSilence));
}
#[test]
fn wedge_reason_as_str_covers_all_variants() {
assert_eq!(WedgeReason::TcpStateCloseWaitConfirmed.as_str(), "tcp_state_close_wait_confirmed");
assert_eq!(WedgeReason::ProbeFailedConfirmed.as_str(), "probe_failed_confirmed");
assert_eq!(WedgeReason::FallbackSilence.as_str(), "fallback_silence");
}
}
File diff suppressed because it is too large Load Diff
+108
View File
@@ -1556,6 +1556,60 @@ mod tests {
) -> Result<DeleteBucketOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn copy_object(
&self,
_input: CopyObjectInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CopyObjectOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn create_multipart_upload(
&self,
_input: CreateMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CreateMultipartUploadOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn upload_part(
&self,
_input: UploadPartInput,
_access_key: &str,
_secret_key: &str,
) -> Result<UploadPartOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn complete_multipart_upload(
&self,
_input: CompleteMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn abort_multipart_upload(
&self,
_input: AbortMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<AbortMultipartUploadOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
async fn upload_part_copy(
&self,
_input: UploadPartCopyInput,
_access_key: &str,
_secret_key: &str,
) -> Result<UploadPartCopyOutput, Self::Error> {
unreachable!("parse_path tests should not hit storage")
}
}
fn driver() -> WebDavDriver<DummyStorage> {
@@ -1725,6 +1779,60 @@ mod tests {
) -> Result<DeleteBucketOutput, Self::Error> {
unreachable!("delete_bucket is not used in rename regression tests")
}
async fn copy_object(
&self,
_input: CopyObjectInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CopyObjectOutput, Self::Error> {
unreachable!("copy_object is not used in rename regression tests")
}
async fn create_multipart_upload(
&self,
_input: CreateMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CreateMultipartUploadOutput, Self::Error> {
unreachable!("create_multipart_upload is not used in rename regression tests")
}
async fn upload_part(
&self,
_input: UploadPartInput,
_access_key: &str,
_secret_key: &str,
) -> Result<UploadPartOutput, Self::Error> {
unreachable!("upload_part is not used in rename regression tests")
}
async fn complete_multipart_upload(
&self,
_input: CompleteMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
unreachable!("complete_multipart_upload is not used in rename regression tests")
}
async fn abort_multipart_upload(
&self,
_input: AbortMultipartUploadInput,
_access_key: &str,
_secret_key: &str,
) -> Result<AbortMultipartUploadOutput, Self::Error> {
unreachable!("abort_multipart_upload is not used in rename regression tests")
}
async fn upload_part_copy(
&self,
_input: UploadPartCopyInput,
_access_key: &str,
_secret_key: &str,
) -> Result<UploadPartCopyOutput, Self::Error> {
unreachable!("upload_part_copy is not used in rename regression tests")
}
}
fn recording_driver(