mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-06 21:33:14 +00:00
efaf07d323
* 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
1459 lines
68 KiB
Rust
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");
|
|
}
|
|
}
|