Files
rustfs/crates/protocols/src/sftp/driver.rs
T
houseme efaf07d323 feat: preserve request ids across async recovery logs (#3451)
* feat(obs): promote request ids in structured logs

* refactor(tracing): propagate spans into request tasks

* test(ecstore): baseline recovery monitor log chains

* fix(replication): reduce startup resync log noise

* chore(docs): stop tracking local recovery baseline

* chore(obs): polish request id logging cleanup
2026-06-14 23:16:04 +08:00

1459 lines
68 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-session SFTP driver: the SftpDriver struct, the russh_sftp
//! Handler trait dispatch onto operation modules, and the Drop impl
//! that aborts in-flight multipart uploads on session teardown.
//!
//! Implements SFTPv3 as defined by the SFTP Internet Draft
//! draft-ietf-secsh-filexfer-02. Later draft revisions (versions 4 to
//! 6) change the wire format for attributes and timestamps. Supporting
//! them would require a separate driver type rather than a parameter
//! on this one. The russh_sftp library this driver builds on also
//! implements version 3 only.
use super::attrs;
use super::constants::limits::S3_COPY_OBJECT_MAX_SIZE;
use super::errors::{SftpError, auth_err, auth_err_unreachable, is_no_such_upload_error, ok_status, s3_error_to_sftp};
use super::lifecycle::SessionDiag;
use super::paths::{parse_s3_path, sanitise_control_bytes};
use super::state::{HandleState, WritePhase};
use super::write::{build_write_tombstone, fstat_reported_size, rejects_excl_or_trunc_without_create, should_abort_on_drop};
use crate::common::client::s3::StorageBackend;
use crate::common::gateway::{AuthorizationError, S3Action, authorize_operation};
use crate::common::session::SessionContext;
use russh_sftp::protocol::{Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Packet, Status, StatusCode, Version};
use rustfs_utils::MaskedAccessKey;
use s3s::dto::{AbortMultipartUploadInput, CopyObjectInput, CopySource};
use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
use tracing::Instrument;
use uuid::Uuid;
/// Permits available to the fire-and-forget AbortMultipartUpload tasks
/// the Drop impl spawns when a session ends with live multipart uploads.
/// Bounds the concurrent abort fan-out across the whole process so a
/// burst of session teardowns cannot detach an unbounded number of
/// background tasks. Sized at 2x available_parallelism, clamped to a
/// floor that keeps a single small server productive and a ceiling that
/// keeps memory and S3 connections under control.
///
/// Try-acquire returns immediately. If no permit is available the abort
/// is skipped and the orphaned upload_id is reclaimed by the bucket
/// AbortIncompleteMultipartUpload lifecycle rule documented in
/// docs/operations/sftp.md.
const ABORT_PERMITS_FLOOR: usize = 8;
const ABORT_PERMITS_CEILING: usize = 128;
static ABORT_PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
let parallelism = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(ABORT_PERMITS_FLOOR);
let permits = (parallelism * 2).clamp(ABORT_PERMITS_FLOOR, ABORT_PERMITS_CEILING);
Arc::new(Semaphore::new(permits))
});
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
const LOG_SUBSYSTEM_SFTP_DRIVER: &str = "sftp_driver";
const EVENT_SFTP_DRIVER_STATE: &str = "sftp_driver_state";
const EVENT_SFTP_BACKEND_STATE: &str = "sftp_backend_state";
const EVENT_SFTP_AUTHZ_STATE: &str = "sftp_authz_state";
/// Per-session SFTP operation handler.
pub struct SftpDriver<S: StorageBackend + Send + Sync + 'static> {
pub(super) storage: Arc<S>,
pub(super) session_context: SessionContext,
/// When true, write operations (OPEN with any write flag, WRITE,
/// SETSTAT, FSETSTAT, REMOVE, MKDIR, RMDIR, RENAME) are rejected with
/// PermissionDenied before any backend call runs.
pub(super) read_only: bool,
pub(super) handles: HashMap<String, HandleState>,
/// S3 multipart part size in bytes. Bytes accumulate in the per-handle
/// buffer up to this size before a part flushes. Configured per
/// installation via RUSTFS_SFTP_PART_SIZE.
pub(super) part_size: u64,
/// Maximum number of simultaneously-open handles allowed in this
/// session. allocate_handle returns Failure once the table reaches
/// this size. Configured per installation via
/// RUSTFS_SFTP_HANDLES_PER_SESSION.
pub(super) handles_per_session: usize,
/// Per-call deadline applied to every StorageBackend invocation
/// issued through run_backend / run_backend_with_err. A backend
/// that does not respond within this many seconds returns Failure
/// to the client and emits a warn log naming the backend method.
/// Configured per installation via
/// RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS.
pub(super) backend_op_timeout_secs: u64,
/// Per-handle read cache window size in bytes. read_inner fetches
/// at most this many bytes from the backend on a cache miss and
/// serves the next several FXP_READs from the buffer. Configured
/// per installation via RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES.
pub(super) read_cache_window: u64,
/// Process-wide ceiling on cumulative read cache memory across
/// every live SFTP handle. When the projected total would breach
/// this value, read_inner skips the populate call and returns
/// the requested bytes from the freshly-fetched data without
/// storing the rest. Configured per installation via
/// RUSTFS_SFTP_READ_CACHE_TOTAL_MEM_BYTES.
pub(super) read_cache_total_mem_limit: u64,
/// Process-wide accumulator of live read cache memory in bytes.
/// The Drop impl on ReadCache subtracts the live buf.capacity().
/// The populate method subtracts the old capacity and adds the
/// new. The Arc is cloned into every HandleState::File ReadCache
/// so per-handle memory contributes to one shared total. The
/// total is checked against read_cache_total_mem_limit before
/// each populate call.
pub(super) read_cache_in_use: Arc<AtomicU64>,
/// Per-session activity record. Stamp on every handler entry / exit
/// so the per-session wedge watchdog can detect SFTP-handler silence
/// independently of russh's own keepalive and inactivity layers.
pub(super) session_diag: Arc<SessionDiag>,
}
impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
/// Build a driver bound to the given storage backend, authenticated
/// session, read-only flag, multipart part size, per-session handle
/// cap, and per-call backend timeout. The handle table starts
/// empty. Handles are allocated on OPEN and OPENDIR.
#[allow(clippy::too_many_arguments)]
pub fn new(
storage: Arc<S>,
session_context: SessionContext,
read_only: bool,
part_size: u64,
handles_per_session: usize,
backend_op_timeout_secs: u64,
read_cache_window: u64,
read_cache_total_mem_limit: u64,
read_cache_in_use: Arc<AtomicU64>,
session_diag: Arc<SessionDiag>,
) -> Self {
Self {
storage,
session_context,
read_only,
handles: HashMap::new(),
part_size,
handles_per_session,
backend_op_timeout_secs,
read_cache_window,
read_cache_total_mem_limit,
read_cache_in_use,
session_diag,
}
}
/// Build a fresh empty read cache. An Arc to the process-wide
/// in-use accumulator is held inside the returned ReadCache.
/// Calls to the populate method on the returned cache, and the
/// Drop impl on the returned cache, update the same total that
/// read_inner checks against read_cache_total_mem_limit before
/// each populate.
pub(super) fn new_read_cache(&self) -> super::read_cache::ReadCache {
super::read_cache::ReadCache::new(Arc::clone(&self.read_cache_in_use))
}
/// Borrow the authenticated principal's S3 access key. Each StorageBackend
/// call needs this alongside the secret key for signing.
pub(super) fn access_key(&self) -> &str {
&self.session_context.principal.user_identity.credentials.access_key
}
/// Borrow the authenticated principal's S3 secret key. Used together with
/// access_key for signing every backend call.
pub(super) fn secret_key(&self) -> &str {
&self.session_context.principal.user_identity.credentials.secret_key
}
/// Returns Err(PermissionDenied) when the driver is read-only,
/// Ok(()) otherwise. PermissionDenied is the SFTPv3 status that
/// POSIX maps to EACCES.
pub(super) fn enforce_server_readonly(&self) -> Result<(), SftpError> {
if self.read_only {
tracing::warn!(
event = EVENT_SFTP_DRIVER_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
peer = %self.session_context.source_ip,
user = %MaskedAccessKey(&self.session_context.principal.user_identity.credentials.access_key),
result = "read_only_rejected",
"SFTP write rejected by read-only mode"
);
return Err(SftpError::code(StatusCode::PermissionDenied));
}
Ok(())
}
/// Borrows the HandleState for the given id and runs the closure on it.
/// Returns Failure if the handle is not in the table.
pub(super) fn with_handle_ref<F, R>(&self, handle: &str, f: F) -> Result<R, SftpError>
where
F: FnOnce(&HandleState) -> Result<R, SftpError>,
{
match self.handles.get(handle) {
Some(state) => f(state),
None => Err(SftpError::code(StatusCode::Failure)),
}
}
/// Generate a fresh UUID v4 handle, insert the given state into the
/// per-session handle table, and return the handle string. Enforces
/// self.handles_per_session before any UUID generation. Cap-exceeded
/// returns Failure (SFTPv3 has no dedicated "too many handles" code).
pub(super) fn allocate_handle(&mut self, state: HandleState) -> Result<String, SftpError> {
if self.handles.len() >= self.handles_per_session {
return Err(SftpError::code(StatusCode::Failure));
}
let id = Uuid::new_v4().to_string();
self.handles.insert(id.clone(), state);
Ok(id)
}
/// Run a StorageBackend future under the per-call deadline.
/// Returns Ok(value) on success, Err(SftpError) on backend failure
/// (mapped through s3_error_to_sftp), or Err(SftpError::Failure)
/// after a warn log when the deadline elapses.
///
/// Cancel-safety: tokio::time::timeout drops the in-flight backend
/// future on Elapsed. For idempotent reads (head_object,
/// list_objects_v2, get_object_range) cancellation is benign. For
/// create_multipart_upload a timeout can leave an upload_id that
/// the backend created but the client never received; the bucket's
/// AbortIncompleteMultipartUpload lifecycle rule aborts it. For
/// upload_part / complete_multipart_upload / abort_multipart_upload
/// the upload_id was tombstoned in the handle table before the
/// await, so Drop's abort path runs at session teardown.
pub(super) async fn run_backend<F, T, E>(&self, op: &'static str, fut: F) -> Result<T, SftpError>
where
F: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display + 'static,
{
match tokio::time::timeout(std::time::Duration::from_secs(self.backend_op_timeout_secs), fut).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(s3_error_to_sftp(op, e)),
Err(_elapsed) => {
tracing::warn!(
event = EVENT_SFTP_BACKEND_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
op = op,
timeout_secs = self.backend_op_timeout_secs,
result = "timeout",
"SFTP backend operation timed out"
);
Err(SftpError::code(StatusCode::Failure))
}
}
}
/// Variant of run_backend that exposes the backend Err so the
/// caller can branch on its category (for example to filter
/// is_not_found_error in EXCLUDE create or HeadObject-then-list
/// fallback paths). Timeout still maps to Err(SftpError::Failure)
/// after a warn log; the inner Result carries the original
/// backend success or error.
pub(super) async fn run_backend_with_err<F, T, E>(&self, op: &'static str, fut: F) -> Result<Result<T, E>, SftpError>
where
F: std::future::Future<Output = Result<T, E>>,
{
match tokio::time::timeout(std::time::Duration::from_secs(self.backend_op_timeout_secs), fut).await {
Ok(inner) => Ok(inner),
Err(_elapsed) => {
tracing::warn!(
event = EVENT_SFTP_BACKEND_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
op = op,
timeout_secs = self.backend_op_timeout_secs,
result = "timeout",
"sftp backend state changed"
);
Err(SftpError::code(StatusCode::Failure))
}
}
}
/// Authorise an S3 action against the session principal and map
/// the gateway error into an SftpError. AccessDenied surfaces as
/// PermissionDenied (policy-rejected wire status). IamUnavailable
/// surfaces as Failure together with a warn log naming the action
/// and target. The S3Action's wire-name (S3Action::as_str) is the
/// op label in the warn log.
///
/// The authorize_operation call is bounded by the same per-call
/// deadline as backend calls (RUSTFS_SFTP_BACKEND_OP_TIMEOUT_SECS).
/// A stuck IAM call would otherwise block the SFTP request until
/// the SSH keepalive closed the transport (~45 s). The deadline
/// closes that gap and returns IamUnavailable to the client.
pub(super) async fn authorize(&self, action: &S3Action, bucket: &str, key: Option<&str>) -> Result<(), SftpError> {
let auth_fut = authorize_operation(&self.session_context, action, bucket, key);
let outcome = match tokio::time::timeout(std::time::Duration::from_secs(self.backend_op_timeout_secs), auth_fut).await {
Ok(inner) => inner,
Err(_elapsed) => {
tracing::warn!(
event = EVENT_SFTP_AUTHZ_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
action = action.as_str(),
bucket = %bucket,
key = %key.unwrap_or_default(),
result = "timeout",
"sftp authz state changed"
);
return Err(auth_err_unreachable(action.as_str(), bucket, key));
}
};
match outcome {
Ok(()) => Ok(()),
Err(AuthorizationError::AccessDenied) => {
tracing::warn!(
event = EVENT_SFTP_AUTHZ_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
action = action.as_str(),
bucket = %bucket,
key = %key.unwrap_or_default(),
result = "access_denied",
"sftp authz state changed"
);
Err(auth_err())
}
Err(AuthorizationError::IamUnavailable) => {
tracing::warn!(
event = EVENT_SFTP_AUTHZ_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SFTP_DRIVER,
action = action.as_str(),
bucket = %bucket,
key = %key.unwrap_or_default(),
result = "iam_unavailable",
"sftp authz state changed"
);
Err(auth_err_unreachable(action.as_str(), bucket, key))
}
}
}
}
/// SFTPv3 packet dispatch. Each method on the russh_sftp Handler trait
/// corresponds to one SFTPv3 packet type defined by the SFTP Internet
/// Draft draft-ietf-secsh-filexfer-02. Methods not overridden here fall
/// through to the trait default and return SSH_FX_OP_UNSUPPORTED via the
/// unimplemented hook below.
///
/// The associated Error type is SftpError, a newtype over StatusCode.
/// Every wire response therefore carries one of the defined SFTPv3 status
/// codes and no free-form server text.
impl<S: StorageBackend + Send + Sync + 'static> russh_sftp::server::Handler for SftpDriver<S> {
type Error = SftpError;
/// Catch-all error for unimplemented packet types. Returns
/// OP_UNSUPPORTED so the client reports a clean "this server does
/// not support that operation" message.
fn unimplemented(&self) -> Self::Error {
SftpError::code(StatusCode::OpUnsupported)
}
/// SSH_FXP_INIT / SSH_FXP_VERSION exchange, SFTP Internet Draft
/// section 4. Returns the version advertisement built from
/// SFTP_VERSION and an empty extensions map. russh_sftp also
/// exposes Version::new() which constructs the same struct from
/// its own internal VERSION constant. Building the struct directly
/// here binds the wire version to constants::protocol::SFTP_VERSION
/// instead. Clients advertising a different version receive a
/// warn-level log. The reply still carries SFTP_VERSION, and the
/// client must either continue with v3 semantics or close the
/// connection.
#[tracing::instrument(level = "info", skip(self, _extensions), fields(version = version), err(Debug))]
async fn init(
&mut self,
version: u32,
_extensions: std::collections::HashMap<String, String>,
) -> Result<Version, Self::Error> {
self.session_diag.stamp();
if version != super::constants::protocol::SFTP_VERSION {
tracing::warn!(
client_version = version,
server_version = super::constants::protocol::SFTP_VERSION,
"SFTP client advertised a non-v3 version. The reply carries v3 and the client must continue with v3 semantics or close the connection.",
);
}
let result = Ok(Version {
version: super::constants::protocol::SFTP_VERSION,
extensions: std::collections::HashMap::new(),
});
self.session_diag.stamp();
result
}
/// SSH_FXP_REALPATH, SFTP Internet Draft section 6.9. Returns a single
/// File with the resolved path and dummy attributes. Existence is not
/// checked. REALPATH is documented as path resolution only, and
/// returning an error for a non-existent path would also create an
/// existence oracle for paths the principal cannot list. Input is
/// routed through parse_s3_path, so REALPATH rejects NUL, CR, LF,
/// traversal, and the reserved-marker characters that parse_s3_path
/// filters. The decomposed (bucket, key) is reassembled into the
/// absolute path returned to the client.
#[tracing::instrument(level = "debug", skip(self), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
self.session_diag.stamp();
let result: Result<Name, SftpError> = parse_s3_path(&path).map(|(bucket, key)| {
let resolved = match (bucket.as_str(), key.as_deref()) {
("", _) => "/".to_string(),
(b, None) => format!("/{b}"),
(b, Some(k)) => format!("/{b}/{k}"),
};
Name {
id,
files: vec![File::dummy(resolved)],
}
});
self.session_diag.stamp();
result
}
/// SSH_FXP_STAT, SFTP Internet Draft section 6.8. Resolves the path
/// through do_stat, which issues HeadBucket or HeadObject depending on
/// whether the input addresses a bucket or an object.
#[tracing::instrument(level = "debug", skip(self), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
self.session_diag.stamp();
let result = self.do_stat(&path).await.map(|attrs| Attrs { id, attrs });
self.session_diag.stamp();
result
}
/// SSH_FXP_LSTAT, SFTP Internet Draft section 6.8. Under POSIX lstat
/// differs from stat by not following symlinks. S3 has no symlinks so
/// the two collapse to one operation. Both call do_stat so the
/// authorisation and path resolution rules cannot diverge.
#[tracing::instrument(level = "debug", skip(self), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
self.session_diag.stamp();
let result = self.do_stat(&path).await.map(|attrs| Attrs { id, attrs });
self.session_diag.stamp();
result
}
/// SSH_FXP_FSTAT, SFTP Internet Draft section 6.8. Returns the
/// attributes captured at OPEN time from the handle's cache. No
/// network call. A directory handle returns default directory
/// attrs. FSTAT on a write handle reports a size that depends on
/// the WritePhase. Buffering returns the current buffer length.
/// Streaming returns (next_part_number - 1) * part_size + buffer
/// length. Failed returns the size recorded at the most recent
/// successful write. An unknown handle returns Failure.
#[tracing::instrument(level = "debug", skip(self), fields(id, handle = %handle), err(Debug))]
async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> {
self.session_diag.stamp();
let part_size = self.part_size;
let result = self.with_handle_ref(&handle, |state| match state {
HandleState::File { attrs, .. } => Ok(Attrs {
id,
attrs: attrs.clone(),
}),
HandleState::Write { attrs, phase, .. } => {
let mut reported = attrs.clone();
let cached_size = reported.size.unwrap_or(0);
reported.size = Some(fstat_reported_size(phase, part_size, cached_size));
Ok(Attrs { id, attrs: reported })
}
HandleState::Dir(_) => Ok(Attrs {
id,
attrs: attrs::s3_attrs_to_sftp(0, None, true),
}),
});
self.session_diag.stamp();
result
}
/// SSH_FXP_OPENDIR, SFTP Internet Draft section 6.7. Allocates a
/// directory handle. Paths that have an empty bucket construct a
/// Root cursor without a HeadBucket or ListBucket call. The bucket
/// listing and its IAM gate are deferred to the first READDIR.
/// Non-root paths verify ListBucket authorisation and bucket
/// existence (via HeadBucket) before returning the handle.
#[tracing::instrument(level = "debug", skip(self), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
self.session_diag.stamp();
let result = self.opendir_inner(id, &path).await;
self.session_diag.stamp();
result
}
/// SSH_FXP_READDIR, SFTP Internet Draft section 6.7. Returns one batch
/// of entries per call. The cursor on the handle drives the batching.
/// EOF is signalled by returning Err(StatusCode::Eof) when the cursor
/// is exhausted, never by an empty Name response.
///
/// Eof is the spec-mandated sentinel a client sees on every cursor
/// exhaustion, so it is normal control flow on this handler. The
/// instrument attribute therefore omits err(Debug) and non-Eof
/// failures are surfaced via the explicit error log below.
#[tracing::instrument(level = "debug", skip(self), fields(id, handle = %handle))]
async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
self.session_diag.stamp();
let result = self.readdir_inner(id, handle.clone()).await;
self.session_diag.stamp();
if let Err(ref err) = result
&& !matches!(err.0, StatusCode::Eof)
{
tracing::error!(
handle = %handle,
status = ?err.0,
"SFTP READDIR failed"
);
}
result
}
/// SSH_FXP_OPEN, SFTP Internet Draft section 6.3. Splits the request
/// by pflags into the read or write code path.
///
/// APPEND is rejected with OpUnsupported because S3 has no append
/// primitive: every PutObject overwrites the key in full, and there
/// is no way to extend an existing object without re-uploading the
/// prior bytes. A client requesting append-mode is buggy or running
/// on a path the operator did not intend, so refusing the open is
/// safer than silently substituting overwrite semantics.
///
/// READ combined with WRITE is also OpUnsupported. The S3 single-shot
/// PutObject path used by the write handler does not support an
/// in-place edit cycle (download, modify, upload). Clients that need
/// that pattern (rare for SFTP) get a clear protocol error rather
/// than a data loss path.
#[tracing::instrument(level = "info", skip(self, attrs), fields(id, path = %sanitise_control_bytes(&filename), pflags = ?pflags), err(Debug))]
async fn open(&mut self, id: u32, filename: String, pflags: OpenFlags, attrs: FileAttributes) -> Result<Handle, Self::Error> {
if pflags.contains(OpenFlags::APPEND) {
return Err(SftpError::code(StatusCode::OpUnsupported));
}
// SFTPv3 draft section 6.3: SSH_FXF_EXCL and SSH_FXF_TRUNC are
// modifiers of SSH_FXF_CREAT. Either flag without CREAT is a
// malformed request at the protocol boundary. Rejecting here
// avoids the ambiguity of a client that set EXCL expecting
// create-only-if-absent semantics against a path that was never
// created in the first place.
if rejects_excl_or_trunc_without_create(pflags) {
return Err(SftpError::code(StatusCode::BadMessage));
}
let is_write = pflags.contains(OpenFlags::WRITE);
let is_read = pflags.contains(OpenFlags::READ);
if is_write && is_read {
return Err(SftpError::code(StatusCode::OpUnsupported));
}
if is_write {
return self.open_write(id, &filename, pflags, attrs).await;
}
if is_read {
return self.open_read(id, &filename).await;
}
// Neither READ nor WRITE was set. SFTPv3 does not define this
// combination as legal so it is rejected at the boundary.
Err(SftpError::code(StatusCode::BadMessage))
}
/// SSH_FXP_READ, SFTP Internet Draft section 6.4. 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.
///
/// Eof is the spec-mandated sentinel a client sees on every
/// read-past-end-of-file, so it is normal control flow on this
/// handler. The instrument attribute therefore omits err(Debug) and
/// non-Eof failures are surfaced via the explicit error log below.
#[tracing::instrument(level = "debug", skip(self), fields(id, handle = %handle, offset, len))]
async fn read(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result<Data, Self::Error> {
self.session_diag.stamp();
let result = self.read_inner(id, handle.clone(), offset, len).await;
self.session_diag.stamp();
if let Err(ref err) = result
&& !matches!(err.0, StatusCode::Eof)
{
tracing::error!(
handle = %handle,
offset,
len,
status = ?err.0,
"SFTP READ failed"
);
}
result
}
/// SSH_FXP_CLOSE, SFTP Internet Draft section 6.3. Releases the
/// handle. Read and directory handles need no action. Write handles
/// dispatch by WritePhase:
///
/// - Buffering: single PutObject with the buffered bytes. Covers
/// empty files and files smaller than part_size.
/// - Streaming: upload any final partial part, then CompleteMultipartUpload.
/// If the final part flush or CompleteMultipartUpload fails, issue
/// AbortMultipartUpload to release storage and return Failure.
/// - Failed: AbortMultipartUpload to release the upload_id. The
/// client already saw the error that poisoned the handle.
///
/// A missing handle is treated as Ok to tolerate clients that
/// double-close on session teardown.
#[tracing::instrument(level = "info", skip(self), fields(id, handle = %handle), err(Debug))]
async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
self.session_diag.stamp();
let removed = self.handles.remove(&handle);
let Some(HandleState::Write {
bucket,
key,
attrs,
open_attrs,
phase,
}) = removed
else {
return Ok(ok_status(id));
};
match phase {
WritePhase::Buffering { part_buffer } => {
// Small-file path. No multipart state exists so nothing
// to abort on failure.
self.commit_write(&bucket, &key, &open_attrs, part_buffer).await?;
}
WritePhase::Streaming {
upload_id,
abort_authorized,
part_buffer,
uploaded_parts,
next_part_number,
} => {
// Insert a tombstone before the close_streaming await so
// that if the future is cancelled, the Drop drain loop
// finds the upload_id and issues AbortMultipartUpload.
//
// On Ok: remove the tombstone. CompleteMultipartUpload
// has finalised the upload. A later AbortMultipartUpload
// from Drop would return NoSuchUpload. This will be
// logged in the Drop at debug but the tokio::spawn would
// still run. Removing the tombstone here avoids that
// spawn.
//
// On Err: keep the tombstone in place so Drop retries
// the abort. close_streaming has already attempted its
// own abort via close_abort_or_skip, but that attempt
// may itself have failed (transient network error,
// mid-call cancellation). The tombstone-before-await
// pattern survives such abort-failure modes; removing
// the tombstone on Err would trust the inline abort
// unconditionally, which the tombstone exists to avoid.
//
// The synchronous window between the await returning Ok
// and the remove call below contains no other await, so
// cancellation cannot fire between them.
self.handles.insert(
handle.clone(),
build_write_tombstone(&bucket, &key, &attrs, upload_id.clone(), abort_authorized),
);
let result = self
.close_streaming(&bucket, &key, upload_id, abort_authorized, part_buffer, uploaded_parts, next_part_number)
.await;
match result {
Ok(()) => {
self.handles.remove(&handle);
}
Err(e) => return Err(e),
}
}
WritePhase::Failed {
upload_id,
abort_authorized,
} => {
// Handle entered WritePhase::Failed via an earlier
// UploadPart failure. Release the upload_id so S3 does
// not hold partial state.
// Error and skip paths are both log-and-continue: the
// client already saw the write error that poisoned the
// handle, so close itself returns Ok. Cancellation of
// close_abort_or_skip leaves the tombstone for Drop.
self.handles.insert(
handle.clone(),
build_write_tombstone(&bucket, &key, &attrs, upload_id.clone(), abort_authorized),
);
self.close_abort_or_skip(&bucket, &key, &upload_id, abort_authorized, "Failed handle")
.await;
self.handles.remove(&handle);
}
}
Ok(ok_status(id))
}
/// SSH_FXP_WRITE, SFTP Internet Draft section 6.3. Appends data to
/// the open write handle's buffer and flushes full parts to S3 as the
/// buffer fills.
///
/// The offset must equal the current byte count: the implementation
/// is sequential-append only, no sparse writes. Mainstream clients
/// (OpenSSH sftp, FileZilla, WinSCP) write strictly sequentially so
/// the restriction does not affect normal transfers. The per-handle
/// buffer is bounded by part_size: any full-part segment flushes to
/// S3 as soon as part_size bytes are available, so the in-memory
/// high water mark is part_size + the incoming chunk.
///
/// On the first full-part flush the handle transitions from Buffering
/// to Streaming by issuing CreateMultipartUpload. A Failed handle
/// rejects every subsequent write with the same status that caused
/// the failure.
#[tracing::instrument(level = "debug", skip(self, data), fields(id, handle = %handle, offset, len = data.len()), err(Debug))]
async fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec<u8>) -> Result<Status, Self::Error> {
self.session_diag.stamp();
self.enforce_server_readonly()?;
// Remove the handle from the table so write_dispatch can mutate
// it across an await without a live &mut into self.handles.
// Reinsert the handle once write_dispatch returns.
let mut state = self
.handles
.remove(&handle)
.ok_or_else(|| SftpError::code(StatusCode::Failure))?;
// If the handle enters with an active or poisoned upload, build
// a tombstone (see build_write_tombstone for the cancellation
// model) and insert it before the write_dispatch await so a
// cancelled or panicking future still leaves Drop an upload_id
// to abort. The happy path overwrites this tombstone with the
// real state at the self.handles.insert below. For a Buffering
// handle there is no upload_id yet;
// write_dispatch_begin_streaming installs the tombstone itself,
// synchronously after CreateMultipartUpload returns.
if let HandleState::Write {
bucket,
key,
attrs,
open_attrs: _,
phase:
WritePhase::Streaming {
upload_id,
abort_authorized,
..
}
| WritePhase::Failed {
upload_id,
abort_authorized,
},
} = &state
{
let tombstone = build_write_tombstone(bucket, key, attrs, upload_id.clone(), *abort_authorized);
self.handles.insert(handle.clone(), tombstone);
}
let result = self.write_dispatch(&handle, &mut state, offset, data).await;
self.handles.insert(handle, state);
let mapped = result.map(|_| ok_status(id));
self.session_diag.stamp();
mapped
}
/// SSH_FXP_REMOVE, SFTP Internet Draft section 6.5. DeleteObject on a
/// resolved object key. REMOVE on a bucket-only path returns Failure
/// because the SFTPv3 draft scopes REMOVE to files only. Bucket
/// deletion belongs to RMDIR.
#[tracing::instrument(level = "info", skip(self), fields(id, path = %sanitise_control_bytes(&filename)), err(Debug))]
async fn remove(&mut self, id: u32, filename: String) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
let (bucket, key) = parse_s3_path(&filename)?;
let Some(object_key) = key else {
tracing::warn!(path = %sanitise_control_bytes(&filename), "SFTP REMOVE refused on a directory path");
return Err(SftpError::code(StatusCode::Failure));
};
if bucket.is_empty() {
return Err(SftpError::code(StatusCode::NoSuchFile));
}
self.authorize(&S3Action::DeleteObject, &bucket, Some(&object_key)).await?;
self.run_backend(
"delete_object",
self.storage
.delete_object(&bucket, &object_key, self.access_key(), self.secret_key()),
)
.await?;
Ok(ok_status(id))
}
/// SSH_FXP_MKDIR, SFTP Internet Draft section 6.6. Bucket-level path
/// (only the bucket component is set) issues CreateBucket. Sub-bucket
/// path issues PutObject of a zero-byte object at the encoded
/// directory marker key. MKDIR at the SFTP root returns Failure
/// because there is no parent into which a new top-level entity could
/// be added.
///
/// The directory-marker key is built with rustfs_utils::path::
/// encode_dir_object so the key format matches the convention used
/// by the rest of RustFS (S3, Swift, WebDAV).
#[tracing::instrument(level = "info", skip(self, _attrs), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn mkdir(&mut self, id: u32, path: String, _attrs: FileAttributes) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
let (bucket, key) = parse_s3_path(&path)?;
if bucket.is_empty() {
return Err(SftpError::code(StatusCode::Failure));
}
match key {
None => self.mkdir_bucket(&bucket).await?,
Some(object_key) => self.mkdir_subdir_marker(&bucket, &object_key).await?,
}
Ok(ok_status(id))
}
/// SSH_FXP_RMDIR, SFTP Internet Draft section 6.6. Empty check then
/// delete. Bucket-level path lists the bucket with max_keys=1 and,
/// on an empty result, calls DeleteBucket. Sub-bucket path lists the
/// prefix and, on an empty result, calls DeleteObject on the encoded
/// directory marker.
///
/// validate_directory_empty propagates the list error rather than
/// swallowing it. Without that, a transient backend error during
/// the empty-check would let the destructive call proceed against
/// an unverified target.
#[tracing::instrument(level = "info", skip(self), fields(id, path = %sanitise_control_bytes(&path)), err(Debug))]
async fn rmdir(&mut self, id: u32, path: String) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
let (bucket, key) = parse_s3_path(&path)?;
if bucket.is_empty() {
return Err(SftpError::code(StatusCode::Failure));
}
match key {
None => self.rmdir_bucket(&bucket).await?,
Some(object_key) => self.rmdir_subdir_marker(&bucket, &object_key).await?,
}
Ok(ok_status(id))
}
/// SSH_FXP_RENAME, SFTP Internet Draft section 6.5. File-only:
/// CopyObject from source to destination, then DeleteObject on the
/// source. S3 has no native rename operation. A request whose source
/// or destination resolves to anything other than a bucket+key pair
/// (root, bucket-only) returns OpUnsupported because directory rename
/// would require recursive list+copy+delete.
///
/// Large files (larger than S3_COPY_OBJECT_MAX_SIZE, 5 GiB) cannot
/// use the single-shot CopyObject API. In that case a HEAD on the
/// source determines the size, a multipart upload is created on the
/// destination, and the data is copied part-by-part via
/// UploadPartCopy. If the source exceeds part_size *
/// S3_MAX_MULTIPART_PARTS the effective part size is scaled up so
/// any object up to the S3 maximum (5 TiB) can be renamed.
///
/// Rename is multi-step and not atomic. If CopyObject (or the
/// multipart copy) succeeds and DeleteObject fails, the destination
/// exists and the source remains. The wire reply is Failure so the
/// client receives the error and can retry the deletion.
#[tracing::instrument(level = "info", skip(self), fields(id, oldpath = %sanitise_control_bytes(&oldpath), newpath = %sanitise_control_bytes(&newpath)), err(Debug))]
async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
let (src_bucket, src_key) = parse_s3_path(&oldpath)?;
let (dst_bucket, dst_key) = parse_s3_path(&newpath)?;
let Some(src_object) = src_key else {
return Err(SftpError::code(StatusCode::OpUnsupported));
};
let Some(dst_object) = dst_key else {
return Err(SftpError::code(StatusCode::OpUnsupported));
};
if src_bucket.is_empty() || dst_bucket.is_empty() {
return Err(SftpError::code(StatusCode::OpUnsupported));
}
// POSIX rename on the same path is a no-op. Short-circuit
// before any backend call because the flow below (copy then
// delete source) would otherwise delete the object after
// copying it to itself. For files over 5 GiB this would lose
// data, since S3 accepts self-copy via UploadPartCopy even
// though single-shot CopyObject rejects it.
if src_bucket == dst_bucket && src_object == dst_object {
return Ok(ok_status(id));
}
// HEAD the source to learn its size. The size drives the
// single-shot vs multipart-copy branch below.
self.authorize(&S3Action::HeadObject, &src_bucket, Some(&src_object)).await?;
let head = self
.run_backend(
"head_object",
self.storage
.head_object(&src_bucket, &src_object, self.access_key(), self.secret_key()),
)
.await?;
let content_length = head.content_length.unwrap_or(0).max(0) as u64;
// Copy branch. Single-shot CopyObject for anything up to 5 GiB.
// Multipart UploadPartCopy above that.
if content_length <= S3_COPY_OBJECT_MAX_SIZE {
self.authorize(&S3Action::CopyObject, &dst_bucket, Some(&dst_object)).await?;
let input = CopyObjectInput::builder()
.copy_source(CopySource::Bucket {
bucket: src_bucket.clone().into(),
key: src_object.clone().into(),
version_id: None,
})
.bucket(dst_bucket.clone())
.key(dst_object.clone())
.build()
.map_err(|e| s3_error_to_sftp("build_copy_object", e))?;
self.run_backend("copy_object", self.storage.copy_object(input, self.access_key(), self.secret_key()))
.await?;
} else {
self.multipart_copy(&src_bucket, &src_object, &dst_bucket, &dst_object, content_length)
.await?;
}
// Remove the original. If this fails the copy already landed at
// the destination. The client receives Failure and can retry the
// delete separately.
self.authorize(&S3Action::DeleteObject, &src_bucket, Some(&src_object))
.await?;
self.run_backend(
"delete_object",
self.storage
.delete_object(&src_bucket, &src_object, self.access_key(), self.secret_key()),
)
.await?;
Ok(ok_status(id))
}
/// SSH_FXP_SETSTAT, SFTP Internet Draft section 6.6. Returns Ok
/// without touching the backend. S3 has no POSIX permission, owner,
/// or mtime semantics for objects, so honouring SETSTAT would be a
/// lie. WinSCP and rsync issue SETSTAT after every transfer to
/// stamp mtime. Returning OpUnsupported there causes them to flag
/// every successful upload as a transfer failure. A silent success
/// is the only client-compatible answer.
///
/// Attributes carried in the request, including any size value,
/// are intentionally not applied to the backend. A standalone
/// SETSTAT(size=0) request returns Ok without truncating the
/// object. Whole-object replacement is available via OPEN with
/// CREATE | TRUNCATE, which the rsync truncate-then-fill flow
/// chains immediately after SETSTAT, so the unhonoured size has
/// no client-visible effect for the common cases.
#[tracing::instrument(level = "debug", skip(self, _attrs), fields(id, path = %sanitise_control_bytes(&_path)), err(Debug))]
async fn setstat(&mut self, id: u32, _path: String, _attrs: FileAttributes) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
Ok(ok_status(id))
}
/// SSH_FXP_FSETSTAT, SFTP Internet Draft section 6.6. Same rationale
/// as setstat: S3 cannot honour POSIX attributes, and clients use
/// FSETSTAT during transfers to stamp the in-flight handle.
#[tracing::instrument(level = "debug", skip(self, _attrs), fields(id, handle = %_handle), err(Debug))]
async fn fsetstat(&mut self, id: u32, _handle: String, _attrs: FileAttributes) -> Result<Status, Self::Error> {
self.enforce_server_readonly()?;
Ok(ok_status(id))
}
/// SSH_FXP_SYMLINK, SFTP Internet Draft section 6.10. S3 has no
/// symlink primitive and the convention of encoding a target into
/// object metadata is non-portable across SFTP clients. Returning
/// OpUnsupported prevents clients from creating malformed link
/// objects that no other SFTP client can resolve.
#[tracing::instrument(level = "debug", skip(self), fields(id = _id), err(Debug))]
async fn symlink(&mut self, _id: u32, _linkpath: String, _targetpath: String) -> Result<Status, Self::Error> {
Err(SftpError::code(StatusCode::OpUnsupported))
}
/// SSH_FXP_READLINK, SFTP Internet Draft section 6.10. S3 has no
/// symlink primitive. Returns OpUnsupported.
#[tracing::instrument(level = "debug", skip(self), fields(id = _id), err(Debug))]
async fn readlink(&mut self, _id: u32, _path: String) -> Result<Name, Self::Error> {
Err(SftpError::code(StatusCode::OpUnsupported))
}
/// SSH_FXP_EXTENDED, SFTP Internet Draft section 8. The server offers
/// no extensions, so every extended request is rejected with the
/// status the draft mandates for unknown extension names.
#[tracing::instrument(level = "debug", skip(self, _data), fields(id = _id, request = %sanitise_control_bytes(&_request)), err(Debug))]
async fn extended(&mut self, _id: u32, _request: String, _data: Vec<u8>) -> Result<Packet, Self::Error> {
Err(SftpError::code(StatusCode::OpUnsupported))
}
}
/// Abort in-flight multipart uploads when the driver is dropped.
///
/// The driver is owned by russh_sftp::server::run and dropped when the
/// SSH channel stream ends. Drop runs on every channel termination
/// path: clean client close, TCP drop, idle timeout, channel_close, or
/// panic in a handler. Write handles in the Streaming or Failed phase
/// carry an active upload_id. Without explicit abort the upload_id
/// lingers in S3, consuming storage until the bucket's lifecycle rule
/// aborts it.
///
/// Drop is synchronous. The abort calls run in a tokio task spawned
/// per active upload; the task outlives the driver. If the runtime is
/// shutting down the task may not complete, in which case the bucket's
/// AbortIncompleteMultipartUpload lifecycle rule aborts the upload_id.
///
/// Drop does not call authorize_operation directly because it cannot
/// await. The authorisation decision was cached on the Streaming
/// variant (and forwarded to Failed) at CreateMultipartUpload time;
/// see start_multipart_upload and the abort_authorized field on
/// WritePhase. When the cached flag is false, Drop skips the abort and
/// logs the skip with the bucket, key, upload_id, and principal.
/// Operators running Deny-Abort policies (WORM / append-only patterns)
/// must configure the bucket's AbortIncompleteMultipartUpload
/// lifecycle rule or staged parts accumulate.
///
/// The cached flag reflects the policy at CreateMultipartUpload time;
/// a policy edit between cache and Drop is not honoured within the
/// session. Staleness is bounded by one upload's lifetime.
impl<S: StorageBackend + Send + Sync + 'static> Drop for SftpDriver<S> {
fn drop(&mut self) {
// Snapshot credentials, peer IP, and the per-call backend
// timeout before draining the handle table. self.access_key()
// and self.secret_key() borrow self.session_context immutably,
// which conflicts with the mutable borrow of self.handles
// inside the loop. The timeout is copied into each spawned
// abort task so the deadline applies uniformly to inline calls
// and Drop-time aborts.
let access_key = self.session_context.principal.user_identity.credentials.access_key.clone();
let secret_key = self.session_context.principal.user_identity.credentials.secret_key.clone();
let peer = self.session_context.source_ip;
let backend_op_timeout_secs = self.backend_op_timeout_secs;
for (_handle_id, handle_state) in self.handles.drain() {
let HandleState::Write { bucket, key, phase, .. } = handle_state else {
continue;
};
// should_abort_on_drop returns None for Buffering (no
// upload exists) and for Streaming/Failed when the cached
// abort_authorized is false (policy denies Abort).
let upload_id_owned = match should_abort_on_drop(&phase) {
Some(id) => id.to_owned(),
None => {
if let WritePhase::Streaming { upload_id, .. } | WritePhase::Failed { upload_id, .. } = &phase {
tracing::warn!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
peer = %peer,
access_key = %access_key,
"skipped abort of orphaned multipart upload on session drop, principal lacks s3:AbortMultipartUpload, bucket lifecycle rules must reclaim parts",
);
}
continue;
}
};
let storage = Arc::clone(&self.storage);
let access_key = access_key.clone();
let secret_key = secret_key.clone();
let upload_id = upload_id_owned;
// Cap the global abort fan-out so a burst of session
// teardowns each holding live multipart uploads cannot
// detach an unbounded number of background tasks. The
// permit is held for the lifetime of the spawned task.
let permit = match Arc::clone(&ABORT_PERMITS).try_acquire_owned() {
Ok(p) => p,
Err(_) => {
tracing::warn!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
peer = %peer,
"abort permit pool exhausted on session drop, bucket lifecycle rule must reclaim parts",
);
continue;
}
};
tokio::spawn(
async move {
let _permit = permit;
tracing::warn!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
peer = %peer,
"aborting orphaned multipart upload on session drop"
);
// Build AbortMultipartUploadInput inside the spawned
// task so the builder Result is handled in async
// context. The builder only fails on missing required
// fields. bucket, key, and upload_id are all set, so
// log and return on any unexpected failure.
let input = match AbortMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(key.clone())
.upload_id(upload_id.clone())
.build()
{
Ok(input) => input,
Err(e) => {
tracing::error!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
err = %e,
"failed to build AbortMultipartUploadInput on session drop"
);
return;
}
};
match tokio::time::timeout(
std::time::Duration::from_secs(backend_op_timeout_secs),
storage.abort_multipart_upload(input, &access_key, &secret_key),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => {
// close() removes the tombstone only on Ok, so Drop
// retries any abort whose inline attempt caused an
// error. A retried abort can race a concurrent
// successful CompleteMultipartUpload, returning
// NoSuchUpload. Log at debug to keep error-level
// logs reserved for genuine abort failures.
if is_no_such_upload_error(&e) {
tracing::debug!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
"Drop abort returned NoSuchUpload: upload already completed or aborted",
);
} else {
tracing::error!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
err = %e,
"failed to abort orphaned multipart upload"
);
}
}
Err(_elapsed) => {
// Drop's abort task is bounded by the same
// per-call deadline as inline backend calls.
// A timeout here is rare (the runtime drains
// session tasks for SHUTDOWN_DRAIN_TIMEOUT_SECS
// and Drop runs after that), so log at warn so
// operators can correlate the orphaned upload
// with the bucket AbortIncompleteMultipartUpload
// lifecycle rule that will reclaim it.
tracing::warn!(
bucket = %bucket,
key = %key,
upload_id = %upload_id,
timeout_secs = backend_op_timeout_secs,
"Drop abort of orphaned multipart upload timed out; bucket lifecycle rule must reclaim parts",
);
}
}
}
.instrument(tracing::Span::current()),
);
}
}
}
#[cfg(test)]
mod tests {
use super::super::constants::protocol;
use super::super::state::WritePhase;
use super::super::test_support::{TEST_PART_SIZE, build_driver, build_readonly_driver, file_handle, write_handle};
use super::*;
use crate::common::dummy_storage::DummyBackend;
use crate::common::gateway::{with_test_auth_override, with_test_iam_unavailable};
use russh_sftp::server::Handler;
use rustfs_utils::path;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
#[tokio::test]
async fn init_advertises_sftp_v3_without_extensions() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let extensions = HashMap::from([("posix-rename@openssh.com".to_string(), "1".to_string())]);
let advertised = driver
.init(protocol::SFTP_VERSION, extensions)
.await
.expect("init must succeed");
assert_eq!(advertised.version, protocol::SFTP_VERSION);
assert!(advertised.extensions.is_empty(), "server must not advertise unsupported extensions");
}
#[tokio::test]
async fn init_from_newer_client_still_advertises_sftp_v3() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let advertised = driver
.init(protocol::SFTP_VERSION + 3, HashMap::new())
.await
.expect("version negotiation must still reply");
assert_eq!(advertised.version, protocol::SFTP_VERSION);
assert!(advertised.extensions.is_empty());
}
#[tokio::test]
async fn init_stamps_session_activity() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
driver.session_diag.last_activity_ms.store(1, Ordering::Relaxed);
driver
.init(protocol::SFTP_VERSION, HashMap::new())
.await
.expect("init must succeed");
assert!(
driver.session_diag.last_activity_ms.load(Ordering::Relaxed) > 1,
"init must refresh session activity for watchdog accounting"
);
}
#[test]
fn unimplemented_packet_returns_op_unsupported() {
let backend = Arc::new(DummyBackend::new());
let driver = build_driver(backend, TEST_PART_SIZE);
let err = <SftpDriver<DummyBackend> as Handler>::unimplemented(&driver);
assert!(matches!(StatusCode::from(err), StatusCode::OpUnsupported));
}
#[tokio::test]
async fn fstat_on_file_handle_returns_cached_attrs() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let attrs = FileAttributes {
size: Some(1234),
mtime: Some(1_700_000_000),
..Default::default()
};
let handle_id = driver
.allocate_handle(file_handle("b", "k", 1234, attrs.clone()))
.expect("allocate");
let out = driver.fstat(4, handle_id).await.expect("fstat on File must succeed");
assert_eq!(out.attrs.size, Some(1234));
assert_eq!(out.attrs.mtime, Some(1_700_000_000));
}
#[tokio::test]
async fn fstat_on_write_handle_returns_running_byte_count_from_phase() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let phase = WritePhase::Buffering {
part_buffer: vec![0u8; 4096],
};
let handle_id = driver.allocate_handle(write_handle("b", "k", phase)).expect("allocate");
let out = driver.fstat(5, handle_id).await.expect("fstat on Write must succeed");
assert_eq!(
out.attrs.size,
Some(4096),
"fstat on a Buffering handle must report the part-buffer length"
);
}
#[tokio::test]
async fn fsetstat_returns_ok_for_any_attrs() {
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", 0, FileAttributes::default()))
.expect("allocate");
let status = driver
.fsetstat(6, handle_id, FileAttributes::default())
.await
.expect("fsetstat must succeed on any attrs");
assert!(matches!(status.status_code, StatusCode::Ok));
}
async fn realpath_status(driver: &mut SftpDriver<DummyBackend>, path: &str) -> Result<String, StatusCode> {
match driver.realpath(7, path.to_string()).await {
Ok(out) => Ok(out.files[0].filename.clone()),
Err(err) => Err(err.0),
}
}
#[tokio::test]
async fn realpath_rejects_nul_byte() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let result = realpath_status(&mut driver, "/bucket/\0evil").await;
assert!(matches!(result, Err(StatusCode::BadMessage)));
}
#[tokio::test]
async fn realpath_rejects_carriage_return() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let result = realpath_status(&mut driver, "/bucket/line\r/evil").await;
assert!(matches!(result, Err(StatusCode::BadMessage)));
}
#[tokio::test]
async fn realpath_rejects_line_feed() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let result = realpath_status(&mut driver, "/bucket/line\n/evil").await;
assert!(matches!(result, Err(StatusCode::BadMessage)));
}
#[tokio::test]
async fn realpath_rejects_global_dir_marker() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let marker_path = format!("/bucket/sub{}", path::GLOBAL_DIR_SUFFIX);
let result = realpath_status(&mut driver, &marker_path).await;
assert!(matches!(result, Err(StatusCode::BadMessage)));
}
#[tokio::test]
async fn realpath_resolves_traversal_inside_bucket() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let resolved = realpath_status(&mut driver, "/bucket/sub/../other").await.expect("ok");
assert_eq!(resolved, "/bucket/other");
}
#[tokio::test]
async fn realpath_root_returns_slash() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
assert_eq!(realpath_status(&mut driver, "/").await.expect("ok"), "/");
assert_eq!(realpath_status(&mut driver, "").await.expect("ok"), "/");
assert_eq!(realpath_status(&mut driver, "/..").await.expect("ok"), "/");
}
#[tokio::test]
async fn realpath_bucket_only() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
assert_eq!(realpath_status(&mut driver, "/bucket").await.expect("ok"), "/bucket");
assert_eq!(realpath_status(&mut driver, "/bucket/").await.expect("ok"), "/bucket");
}
#[tokio::test]
async fn realpath_nonexistent_path_resolves_without_backend_call() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend.clone(), TEST_PART_SIZE);
let resolved = realpath_status(&mut driver, "/bucket/does-not-exist").await.expect("ok");
assert_eq!(resolved, "/bucket/does-not-exist");
assert!(backend.head_object_calls().is_empty(), "realpath must not issue HeadObject");
}
#[tokio::test]
async fn setstat_returns_ok_in_read_write_mode() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_driver(backend, TEST_PART_SIZE);
let status = driver
.setstat(8, "/bucket/key".into(), FileAttributes::default())
.await
.expect("setstat must succeed in read-write mode");
assert!(matches!(status.status_code, StatusCode::Ok));
}
#[tokio::test]
async fn setstat_rejected_in_read_only_mode() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_readonly_driver(backend, TEST_PART_SIZE);
let result = driver.setstat(9, "/bucket/key".into(), FileAttributes::default()).await;
match result {
Err(err) => assert!(matches!(err.0, StatusCode::PermissionDenied)),
Ok(_) => panic!("setstat must error in read-only mode"),
}
}
/// list_objects_v2 backend error must propagate as Err. Falling
/// through would convert a transient error into silent data loss.
#[tokio::test]
async fn validate_directory_empty_propagates_list_error() {
// When the empty-check list_objects_v2 fails,
// validate_directory_empty returns Err. The destructive caller
// never runs against an unverified target.
let backend = Arc::new(DummyBackend::new());
backend.queue_list_objects_v2_err(crate::common::dummy_storage::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");
}
#[tokio::test]
async fn fsetstat_rejected_in_read_only_mode() {
let backend = Arc::new(DummyBackend::new());
let mut driver = build_readonly_driver(backend, TEST_PART_SIZE);
let handle_id = driver
.allocate_handle(file_handle("b", "k", 0, FileAttributes::default()))
.expect("allocate");
let result = driver.fsetstat(10, handle_id, FileAttributes::default()).await;
match result {
Err(err) => assert!(matches!(err.0, StatusCode::PermissionDenied)),
Ok(_) => panic!("fsetstat must error in read-only mode"),
}
}
/// IAM-unreachable maps to Failure. Policy deny maps to
/// PermissionDenied. Two error categories must produce two wire
/// statuses so an IAM outage is not reported as a permanent
/// permission rejection.
#[tokio::test]
async fn authorize_maps_iam_unavailable_to_failure() {
let backend = Arc::new(DummyBackend::new());
let driver = build_driver(backend, TEST_PART_SIZE);
let result = with_test_iam_unavailable(driver.authorize(&S3Action::PutObject, "b", Some("k"))).await;
let err = result.expect_err("IAM unavailable must surface as Err");
assert!(
matches!(err.0, StatusCode::Failure),
"IAM unavailable must map to Failure, not PermissionDenied"
);
}
/// AccessDenied still surfaces as PermissionDenied. Pinned alongside
/// the IamUnavailable test so a future refactor of the authorize
/// helper cannot silently collapse the two error categories.
#[tokio::test]
async fn authorize_maps_access_denied_to_permission_denied() {
let backend = Arc::new(DummyBackend::new());
let driver = build_driver(backend, TEST_PART_SIZE);
let result = with_test_auth_override(|_, _, _| false, driver.authorize(&S3Action::PutObject, "b", Some("k"))).await;
let err = result.expect_err("Deny must surface as Err");
assert!(matches!(err.0, StatusCode::PermissionDenied), "AccessDenied must map to PermissionDenied");
}
}