mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 12:26:37 +00:00
feat(sftp): add SFTPv3 protocol support (#2875)
Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
@@ -71,4 +71,64 @@ pub trait StorageBackend: Send + Sync {
|
||||
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error>;
|
||||
/// Delete a bucket (must be empty)
|
||||
async fn delete_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<DeleteBucketOutput, Self::Error>;
|
||||
/// Server-side copy of an object from one bucket+key to another.
|
||||
/// The input carries the full S3 surface (content type, metadata map,
|
||||
/// metadata directive, storage class, SSE config, conditional-copy
|
||||
/// headers) so protocol drivers can map client-supplied metadata
|
||||
/// onto the destination object.
|
||||
async fn copy_object(
|
||||
&self,
|
||||
input: CopyObjectInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<CopyObjectOutput, Self::Error>;
|
||||
/// Initiate a multipart upload. Returns an upload_id that identifies
|
||||
/// the in-progress upload for subsequent UploadPart, CompleteMultipartUpload,
|
||||
/// and AbortMultipartUpload calls. The input carries the full S3 surface
|
||||
/// (content type, cache control, metadata map, storage class, SSE config,
|
||||
/// object lock settings) so protocol drivers can map client-supplied
|
||||
/// metadata into the upload at creation time.
|
||||
async fn create_multipart_upload(
|
||||
&self,
|
||||
input: CreateMultipartUploadInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<CreateMultipartUploadOutput, Self::Error>;
|
||||
/// Upload one part of a multipart upload. The part_number must be in
|
||||
/// the range 1 to the 10 000-part S3 limit. The returned ETag
|
||||
/// identifies the part in the subsequent CompleteMultipartUpload call.
|
||||
async fn upload_part(
|
||||
&self,
|
||||
input: UploadPartInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<UploadPartOutput, Self::Error>;
|
||||
/// Assemble the parts listed in the input into the final object.
|
||||
/// The parts list must be sorted by part_number with no duplicates.
|
||||
async fn complete_multipart_upload(
|
||||
&self,
|
||||
input: CompleteMultipartUploadInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<CompleteMultipartUploadOutput, Self::Error>;
|
||||
/// Abort an in-progress multipart upload. Releases any storage
|
||||
/// associated with the upload_id. Idempotent: calling abort on an
|
||||
/// already-aborted upload_id returns success. The input carries the
|
||||
/// cross-account and conditional-abort fields (expected_bucket_owner,
|
||||
/// if_match_initiated_time) that non-SFTP consumers may need.
|
||||
async fn abort_multipart_upload(
|
||||
&self,
|
||||
input: AbortMultipartUploadInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<AbortMultipartUploadOutput, Self::Error>;
|
||||
/// Copy a byte range from an existing object into a part of an
|
||||
/// in-progress multipart upload. Used by rename for objects larger
|
||||
/// than the 5 GiB single-shot CopyObject limit.
|
||||
async fn upload_part_copy(
|
||||
&self,
|
||||
input: UploadPartCopyInput,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<UploadPartCopyOutput, Self::Error>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
//! Storage-backend double for protocol driver unit tests.
|
||||
//!
|
||||
//! DummyBackend is a queue-driven StorageBackend implementation with
|
||||
//! per-method response queues and per-call observation logs. Each
|
||||
//! async method pops the next response from its queue; an empty queue
|
||||
//! returns a default not-found or not-implemented error so a test
|
||||
//! that forgets to configure a branch errors at the call site rather
|
||||
//! than passing silently.
|
||||
//!
|
||||
//! Send + Sync behind a single Mutex. Tests share state between the
|
||||
//! driver-held Arc and a cloned Arc kept for observation after the
|
||||
//! driver is dropped. SessionContext fixtures live next to the
|
||||
//! SessionContext type in common::session.
|
||||
|
||||
use crate::common::client::s3::StorageBackend;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use s3s::dto::{
|
||||
AbortMultipartUploadInput, AbortMultipartUploadOutput, CompleteMultipartUploadInput, CompleteMultipartUploadOutput,
|
||||
CopyObjectInput, CopyObjectOutput, CreateBucketOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput,
|
||||
DeleteBucketOutput, DeleteObjectOutput, ETag, GetObjectOutput, HeadBucketOutput, HeadObjectOutput, ListBucketsOutput,
|
||||
ListObjectsV2Input, ListObjectsV2Output, PutObjectInput, PutObjectOutput, StreamingBlob, Timestamp, UploadPartCopyInput,
|
||||
UploadPartCopyOutput, UploadPartInput, UploadPartOutput,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Error type returned by DummyBackend. Display strings include substrings
|
||||
/// the driver's error-mapping helpers match against, so a queued NoSuchKey
|
||||
/// error is reported as a not-found status at the protocol layer and an
|
||||
/// AccessDenied error is reported as a permission-denied status.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DummyError {
|
||||
/// Display includes the NoSuchKey substring. S3-style error mappers
|
||||
/// map this to not-found.
|
||||
#[error("NoSuchKey: {0}")]
|
||||
NoSuchKey(String),
|
||||
/// Display includes the NoSuchBucket substring. S3-style error mappers
|
||||
/// map this to not-found.
|
||||
#[error("NoSuchBucket: {0}")]
|
||||
NoSuchBucket(String),
|
||||
/// Free-form error string pre-seeded by a test. Must contain one of the
|
||||
/// S3 error-code substrings if the test wants a specific status code
|
||||
/// from the driver's error-mapping helper.
|
||||
#[error("{0}")]
|
||||
Injected(String),
|
||||
/// Default response when the per-method queue is empty and the method
|
||||
/// has no NotFound default. Any test reaching this path has forgotten
|
||||
/// to configure the branch.
|
||||
#[error("DummyBackend method not configured: {0}")]
|
||||
Unconfigured(&'static str),
|
||||
}
|
||||
|
||||
/// Recorded invocation of abort_multipart_upload. Tests assert on these to
|
||||
/// observe tombstone-driven abort-on-drop behaviour.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AbortCall {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
/// Recorded invocation of upload_part. Tests assert on these to observe
|
||||
/// the sequence of parts a write path issues.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UploadPartCall {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
pub part_number: i32,
|
||||
pub content_length: Option<i64>,
|
||||
}
|
||||
|
||||
/// Recorded invocation of complete_multipart_upload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompleteCall {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
pub part_count: usize,
|
||||
}
|
||||
|
||||
/// Recorded invocation of head_object.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HeadObjectCall {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
// Response queues. Each method pops from its own queue. Empty queue
|
||||
// plus no default means a configured-miss error.
|
||||
get_object: VecDeque<Result<GetObjectOutput, DummyError>>,
|
||||
get_object_range: VecDeque<Result<GetObjectOutput, DummyError>>,
|
||||
put_object: VecDeque<Result<PutObjectOutput, DummyError>>,
|
||||
delete_object: VecDeque<Result<DeleteObjectOutput, DummyError>>,
|
||||
head_object: VecDeque<Result<HeadObjectOutput, DummyError>>,
|
||||
head_bucket: VecDeque<Result<HeadBucketOutput, DummyError>>,
|
||||
list_objects_v2: VecDeque<Result<ListObjectsV2Output, DummyError>>,
|
||||
list_buckets: VecDeque<Result<ListBucketsOutput, DummyError>>,
|
||||
create_bucket: VecDeque<Result<CreateBucketOutput, DummyError>>,
|
||||
delete_bucket: VecDeque<Result<DeleteBucketOutput, DummyError>>,
|
||||
copy_object: VecDeque<Result<CopyObjectOutput, DummyError>>,
|
||||
create_multipart_upload: VecDeque<Result<CreateMultipartUploadOutput, DummyError>>,
|
||||
upload_part: VecDeque<Result<UploadPartOutput, DummyError>>,
|
||||
complete_multipart_upload: VecDeque<Result<CompleteMultipartUploadOutput, DummyError>>,
|
||||
abort_multipart_upload: VecDeque<Result<AbortMultipartUploadOutput, DummyError>>,
|
||||
upload_part_copy: VecDeque<Result<UploadPartCopyOutput, DummyError>>,
|
||||
|
||||
// Observation logs.
|
||||
abort_multipart_calls: Vec<AbortCall>,
|
||||
upload_part_calls: Vec<UploadPartCall>,
|
||||
complete_multipart_calls: Vec<CompleteCall>,
|
||||
head_object_calls: Vec<HeadObjectCall>,
|
||||
|
||||
// Cancellation-test support. When stall_upload_part is true every
|
||||
// upload_part invocation signals upload_part_entered and then awaits
|
||||
// std::future::pending. The pending future is cancellable: the caller's
|
||||
// select or Drop cancels it without blocking the runtime.
|
||||
stall_upload_part: bool,
|
||||
upload_part_entered: Option<Arc<Notify>>,
|
||||
|
||||
// When stall_put_object is true every put_object invocation signals
|
||||
// put_object_entered and then awaits std::future::pending. Used by
|
||||
// the run_backend timeout integration tests where the driver must
|
||||
// observe an Elapsed deadline rather than a backend Err.
|
||||
stall_put_object: bool,
|
||||
put_object_entered: Option<Arc<Notify>>,
|
||||
|
||||
// When stall_list_objects_v2 is true every list_objects_v2
|
||||
// invocation signals list_objects_v2_entered and then awaits
|
||||
// std::future::pending. Used by the cursor-corruption regression
|
||||
// test that pins the un-advanced cursor after a cancelled READDIR
|
||||
// mid-await.
|
||||
stall_list_objects_v2: bool,
|
||||
list_objects_v2_entered: Option<Arc<Notify>>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
get_object: VecDeque::new(),
|
||||
get_object_range: VecDeque::new(),
|
||||
put_object: VecDeque::new(),
|
||||
delete_object: VecDeque::new(),
|
||||
head_object: VecDeque::new(),
|
||||
head_bucket: VecDeque::new(),
|
||||
list_objects_v2: VecDeque::new(),
|
||||
list_buckets: VecDeque::new(),
|
||||
create_bucket: VecDeque::new(),
|
||||
delete_bucket: VecDeque::new(),
|
||||
copy_object: VecDeque::new(),
|
||||
create_multipart_upload: VecDeque::new(),
|
||||
upload_part: VecDeque::new(),
|
||||
complete_multipart_upload: VecDeque::new(),
|
||||
abort_multipart_upload: VecDeque::new(),
|
||||
upload_part_copy: VecDeque::new(),
|
||||
abort_multipart_calls: Vec::new(),
|
||||
upload_part_calls: Vec::new(),
|
||||
complete_multipart_calls: Vec::new(),
|
||||
head_object_calls: Vec::new(),
|
||||
stall_upload_part: false,
|
||||
upload_part_entered: None,
|
||||
stall_put_object: false,
|
||||
put_object_entered: None,
|
||||
stall_list_objects_v2: false,
|
||||
list_objects_v2_entered: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue-driven StorageBackend test double. Holds internal state behind a
|
||||
/// single Mutex. Tests configure response queues via queue_* methods,
|
||||
/// wrap the backend in Arc, hand one clone to the protocol driver being
|
||||
/// tested, and keep another clone for observation. Method calls are
|
||||
/// fire-and-forget from the driver's perspective and synchronous on the
|
||||
/// test side.
|
||||
pub struct DummyBackend {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl Default for DummyBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DummyBackend {
|
||||
/// Build an empty backend. Every method returns a default not-found or
|
||||
/// configured-miss error until a queue is populated.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// Queue-configuration helpers. Each test stages the responses it
|
||||
// expects in order. The method pops in FIFO order.
|
||||
|
||||
/// Queue a head_object Ok response with the given size and mtime.
|
||||
pub fn queue_head_object_ok(&self, size: u64, mtime: Option<Timestamp>) {
|
||||
let out = HeadObjectOutput {
|
||||
content_length: Some(size as i64),
|
||||
last_modified: mtime,
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").head_object.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Queue a head_object NoSuchKey response for the next call.
|
||||
pub fn queue_head_object_not_found(&self) {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.head_object
|
||||
.push_back(Err(DummyError::NoSuchKey(String::from("head_object"))));
|
||||
}
|
||||
|
||||
/// Queue a put_object Ok response (default PutObjectOutput).
|
||||
pub fn queue_put_object_ok(&self) {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.put_object
|
||||
.push_back(Ok(PutObjectOutput::default()));
|
||||
}
|
||||
|
||||
/// Queue a put_object error. Used by the commit_write retry tests
|
||||
/// to script SlowDown / AccessDenied sequences against the
|
||||
/// rustfs_utils::retry::is_s3code_in_message_retryable predicate.
|
||||
pub fn queue_put_object_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").put_object.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Number of unconsumed put_object responses left in the queue.
|
||||
/// Used to assert that a non-retryable error did not consume more
|
||||
/// than one queued response.
|
||||
pub fn put_object_queue_len(&self) -> usize {
|
||||
self.inner.lock().expect("lock").put_object.len()
|
||||
}
|
||||
|
||||
/// Queue an arbitrary head_object error for the next call. Used by
|
||||
/// the run_backend_with_err pass-through test that verifies the
|
||||
/// backend Err reaches the caller unchanged when no timeout fires.
|
||||
pub fn queue_head_object_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").head_object.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Queue a create_multipart_upload Ok carrying the given upload_id.
|
||||
pub fn queue_create_multipart_upload_ok(&self, upload_id: impl Into<String>) {
|
||||
let out = CreateMultipartUploadOutput {
|
||||
upload_id: Some(upload_id.into()),
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").create_multipart_upload.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Queue an upload_part Ok response carrying the given ETag. The
|
||||
/// string is wrapped in ETag::Strong. Callers that need ETag::Weak
|
||||
/// can queue a custom UploadPartOutput instead of using this helper.
|
||||
pub fn queue_upload_part_ok(&self, e_tag: impl Into<String>) {
|
||||
let out = UploadPartOutput {
|
||||
e_tag: Some(ETag::Strong(e_tag.into())),
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").upload_part.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Queue an upload_part Ok response with no ETag. Exercises the
|
||||
/// missing-ETag branch a driver may guard against.
|
||||
pub fn queue_upload_part_ok_without_etag(&self) {
|
||||
let out = UploadPartOutput {
|
||||
e_tag: None,
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").upload_part.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Queue an upload_part error. The error string flows through the
|
||||
/// driver's error-mapping helper, so Injected("AccessDenied") produces
|
||||
/// a permission-denied status at the driver boundary.
|
||||
pub fn queue_upload_part_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").upload_part.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Queue a complete_multipart_upload Ok response.
|
||||
pub fn queue_complete_multipart_upload_ok(&self) {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.complete_multipart_upload
|
||||
.push_back(Ok(CompleteMultipartUploadOutput::default()));
|
||||
}
|
||||
|
||||
/// Queue a complete_multipart_upload error.
|
||||
pub fn queue_complete_multipart_upload_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").complete_multipart_upload.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Queue a list_objects_v2 Ok response with no contents and no
|
||||
/// common prefixes. The directory-empty validate path treats this
|
||||
/// as "directory is empty".
|
||||
pub fn queue_list_objects_v2_ok_empty(&self) {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.list_objects_v2
|
||||
.push_back(Ok(ListObjectsV2Output::default()));
|
||||
}
|
||||
|
||||
/// Queue a list_objects_v2 error. Used to verify that callers do
|
||||
/// not fall through to a destructive operation when the empty-check
|
||||
/// itself fails.
|
||||
pub fn queue_list_objects_v2_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").list_objects_v2.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Queue a get_object_range error. Used to verify that the SFTP read
|
||||
/// handler surfaces a non-Eof backend failure as an error-level log
|
||||
/// event after the wire response has been mapped through
|
||||
/// s3_error_to_sftp.
|
||||
pub fn queue_get_object_range_err(&self, err: DummyError) {
|
||||
self.inner.lock().expect("lock").get_object_range.push_back(Err(err));
|
||||
}
|
||||
|
||||
/// Queue a get_object_range Ok response carrying the given bytes as
|
||||
/// the streaming body. content_length is set to bytes.len().
|
||||
pub fn queue_get_object_range_bytes(&self, payload: Vec<u8>) {
|
||||
let size = payload.len() as i64;
|
||||
let body = Bytes::from(payload);
|
||||
let blob = StreamingBlob::wrap(stream::once(async move { Ok::<Bytes, std::io::Error>(body) }));
|
||||
let out = GetObjectOutput {
|
||||
body: Some(blob),
|
||||
content_length: Some(size),
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").get_object_range.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Queue a get_object_range Ok response whose body emits one
|
||||
/// initial chunk and then stalls forever on the next .next() poll.
|
||||
/// Used by the chunk-deadline regression test to verify that a
|
||||
/// stalled mid-stream backend is reaped by the per-chunk timeout
|
||||
/// rather than pinning the SFTP session task indefinitely.
|
||||
/// reported_content_length sets the GetObjectOutput.content_length
|
||||
/// field so the read handler is happy to keep iterating past the
|
||||
/// initial chunk.
|
||||
pub fn queue_get_object_range_stalling_after_chunk(&self, initial_chunk: Vec<u8>, reported_content_length: i64) {
|
||||
let head = Bytes::from(initial_chunk);
|
||||
let body_stream = stream::once(async move { Ok::<Bytes, std::io::Error>(head) })
|
||||
.chain(stream::pending::<Result<Bytes, std::io::Error>>());
|
||||
let blob = StreamingBlob::wrap(body_stream);
|
||||
let out = GetObjectOutput {
|
||||
body: Some(blob),
|
||||
content_length: Some(reported_content_length),
|
||||
..Default::default()
|
||||
};
|
||||
self.inner.lock().expect("lock").get_object_range.push_back(Ok(out));
|
||||
}
|
||||
|
||||
/// Configure upload_part to stall indefinitely. Each call notifies the
|
||||
/// supplied Notify once, then awaits std::future::pending, which the
|
||||
/// caller cancels by dropping the future.
|
||||
pub fn stall_upload_part(&self, entered: Arc<Notify>) {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.stall_upload_part = true;
|
||||
inner.upload_part_entered = Some(entered);
|
||||
}
|
||||
|
||||
/// Configure put_object to stall indefinitely. Each call notifies
|
||||
/// the supplied Notify once, then awaits std::future::pending. The
|
||||
/// run_backend timeout integration test uses this to confirm the
|
||||
/// driver's deadline fires when the backend never returns.
|
||||
pub fn stall_put_object(&self, entered: Arc<Notify>) {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.stall_put_object = true;
|
||||
inner.put_object_entered = Some(entered);
|
||||
}
|
||||
|
||||
/// Configure list_objects_v2 to stall indefinitely. Each call
|
||||
/// notifies the supplied Notify once, then awaits
|
||||
/// std::future::pending. The cursor-corruption regression test
|
||||
/// uses this to cancel a READDIR mid-await and assert the
|
||||
/// un-advanced cursor reissues the same first page.
|
||||
pub fn stall_list_objects_v2(&self, entered: Arc<Notify>) {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.stall_list_objects_v2 = true;
|
||||
inner.list_objects_v2_entered = Some(entered);
|
||||
}
|
||||
|
||||
/// Turn the list_objects_v2 stall back off so subsequent calls
|
||||
/// pop from the queue normally. Used by the cursor-corruption
|
||||
/// regression test after the first READDIR has been cancelled
|
||||
/// mid-await, so the re-issued READDIR can complete against a
|
||||
/// queued Ok response.
|
||||
pub fn clear_stall_list_objects_v2(&self) {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.stall_list_objects_v2 = false;
|
||||
inner.list_objects_v2_entered = None;
|
||||
}
|
||||
|
||||
// Observers. Tests call these after the driver has run to verify the
|
||||
// backend received the expected calls.
|
||||
|
||||
/// Snapshot the abort_multipart_upload call log.
|
||||
pub fn abort_multipart_calls(&self) -> Vec<AbortCall> {
|
||||
self.inner.lock().expect("lock").abort_multipart_calls.clone()
|
||||
}
|
||||
|
||||
/// Snapshot the upload_part call log.
|
||||
pub fn upload_part_calls(&self) -> Vec<UploadPartCall> {
|
||||
self.inner.lock().expect("lock").upload_part_calls.clone()
|
||||
}
|
||||
|
||||
/// Snapshot the complete_multipart_upload call log.
|
||||
pub fn complete_multipart_calls(&self) -> Vec<CompleteCall> {
|
||||
self.inner.lock().expect("lock").complete_multipart_calls.clone()
|
||||
}
|
||||
|
||||
/// Snapshot the head_object call log.
|
||||
pub fn head_object_calls(&self) -> Vec<HeadObjectCall> {
|
||||
self.inner.lock().expect("lock").head_object_calls.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageBackend for DummyBackend {
|
||||
type Error = DummyError;
|
||||
|
||||
async fn get_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
_start_pos: Option<u64>,
|
||||
) -> Result<GetObjectOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").get_object.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_object_range(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
_start_pos: u64,
|
||||
_length: u64,
|
||||
) -> Result<GetObjectOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").get_object_range.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_object(&self, _input: PutObjectInput, _ak: &str, _sk: &str) -> Result<PutObjectOutput, Self::Error> {
|
||||
// Decide control flow while holding the lock. Release before
|
||||
// awaiting so the stall path does not hold the Mutex across
|
||||
// an await point.
|
||||
let (stall, entered, popped) = {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
let stall = inner.stall_put_object;
|
||||
let entered = inner.put_object_entered.clone();
|
||||
let popped = if stall { None } else { inner.put_object.pop_front() };
|
||||
(stall, entered, popped)
|
||||
};
|
||||
if stall {
|
||||
if let Some(n) = entered {
|
||||
n.notify_one();
|
||||
}
|
||||
std::future::pending::<Result<PutObjectOutput, Self::Error>>().await
|
||||
} else {
|
||||
match popped {
|
||||
Some(r) => r,
|
||||
None => Ok(PutObjectOutput::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_object(&self, bucket: &str, key: &str, _ak: &str, _sk: &str) -> Result<DeleteObjectOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").delete_object.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn head_object(&self, bucket: &str, key: &str, _ak: &str, _sk: &str) -> Result<HeadObjectOutput, Self::Error> {
|
||||
{
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.head_object_calls.push(HeadObjectCall {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
});
|
||||
}
|
||||
match self.inner.lock().expect("lock").head_object.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchKey(format!("{bucket}/{key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn head_bucket(&self, bucket: &str, _ak: &str, _sk: &str) -> Result<HeadBucketOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").head_bucket.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchBucket(bucket.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_objects_v2(
|
||||
&self,
|
||||
_input: ListObjectsV2Input,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
) -> Result<ListObjectsV2Output, Self::Error> {
|
||||
// Decide control flow while holding the lock. Release before
|
||||
// awaiting so the stall path does not hold the Mutex across
|
||||
// an await point.
|
||||
let (stall, entered, popped) = {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
let stall = inner.stall_list_objects_v2;
|
||||
let entered = inner.list_objects_v2_entered.clone();
|
||||
let popped = if stall { None } else { inner.list_objects_v2.pop_front() };
|
||||
(stall, entered, popped)
|
||||
};
|
||||
if stall {
|
||||
if let Some(n) = entered {
|
||||
n.notify_one();
|
||||
}
|
||||
std::future::pending::<Result<ListObjectsV2Output, Self::Error>>().await
|
||||
} else {
|
||||
match popped {
|
||||
Some(r) => r,
|
||||
None => Ok(ListObjectsV2Output::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_buckets(&self, _ak: &str, _sk: &str) -> Result<ListBucketsOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").list_buckets.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Ok(ListBucketsOutput::default()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_bucket(&self, _bucket: &str, _ak: &str, _sk: &str) -> Result<CreateBucketOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").create_bucket.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("create_bucket")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_bucket(&self, bucket: &str, _ak: &str, _sk: &str) -> Result<DeleteBucketOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").delete_bucket.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::NoSuchBucket(bucket.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn copy_object(&self, _input: CopyObjectInput, _ak: &str, _sk: &str) -> Result<CopyObjectOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").copy_object.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("copy_object")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_multipart_upload(
|
||||
&self,
|
||||
_input: CreateMultipartUploadInput,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
) -> Result<CreateMultipartUploadOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").create_multipart_upload.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("create_multipart_upload")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_part(&self, input: UploadPartInput, _ak: &str, _sk: &str) -> Result<UploadPartOutput, Self::Error> {
|
||||
// Record the call and decide the control flow while holding the
|
||||
// lock. Release the lock before awaiting so the stall path does
|
||||
// not hold the Mutex across an await point.
|
||||
let (stall, entered, popped) = {
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.upload_part_calls.push(UploadPartCall {
|
||||
bucket: input.bucket.to_string(),
|
||||
key: input.key.to_string(),
|
||||
upload_id: input.upload_id.to_string(),
|
||||
part_number: input.part_number,
|
||||
content_length: input.content_length,
|
||||
});
|
||||
let stall = inner.stall_upload_part;
|
||||
let entered = inner.upload_part_entered.clone();
|
||||
let popped = if stall { None } else { inner.upload_part.pop_front() };
|
||||
(stall, entered, popped)
|
||||
};
|
||||
if stall {
|
||||
if let Some(n) = entered {
|
||||
n.notify_one();
|
||||
}
|
||||
std::future::pending::<Result<UploadPartOutput, Self::Error>>().await
|
||||
} else {
|
||||
match popped {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("upload_part")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete_multipart_upload(
|
||||
&self,
|
||||
input: CompleteMultipartUploadInput,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
|
||||
let part_count = input
|
||||
.multipart_upload
|
||||
.as_ref()
|
||||
.and_then(|mpu| mpu.parts.as_ref().map(|p| p.len()))
|
||||
.unwrap_or(0);
|
||||
{
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.complete_multipart_calls.push(CompleteCall {
|
||||
bucket: input.bucket.to_string(),
|
||||
key: input.key.to_string(),
|
||||
upload_id: input.upload_id.to_string(),
|
||||
part_count,
|
||||
});
|
||||
}
|
||||
match self.inner.lock().expect("lock").complete_multipart_upload.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("complete_multipart_upload")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_multipart_upload(
|
||||
&self,
|
||||
input: AbortMultipartUploadInput,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
) -> Result<AbortMultipartUploadOutput, Self::Error> {
|
||||
{
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.abort_multipart_calls.push(AbortCall {
|
||||
bucket: input.bucket.to_string(),
|
||||
key: input.key.to_string(),
|
||||
upload_id: input.upload_id.to_string(),
|
||||
});
|
||||
}
|
||||
match self.inner.lock().expect("lock").abort_multipart_upload.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Ok(AbortMultipartUploadOutput::default()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_part_copy(
|
||||
&self,
|
||||
_input: UploadPartCopyInput,
|
||||
_ak: &str,
|
||||
_sk: &str,
|
||||
) -> Result<UploadPartCopyOutput, Self::Error> {
|
||||
match self.inner.lock().expect("lock").upload_part_copy.pop_front() {
|
||||
Some(r) => r,
|
||||
None => Err(DummyError::Unconfigured("upload_part_copy")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dummy_backend_reports_not_found_by_default() {
|
||||
let backend = DummyBackend::new();
|
||||
let result = backend.head_object("b", "k", "ak", "sk").await;
|
||||
let Err(err) = result else {
|
||||
panic!("default head_object must return an error");
|
||||
};
|
||||
assert!(
|
||||
err.to_string().contains("NoSuchKey"),
|
||||
"default error must carry the NoSuchKey substring so drivers map it to not-found; got: {err}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dummy_backend_returns_queued_head_object_response() {
|
||||
let backend = DummyBackend::new();
|
||||
backend.queue_head_object_ok(42, None);
|
||||
let out = backend.head_object("b", "k", "ak", "sk").await.expect("queued Ok");
|
||||
assert_eq!(out.content_length, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dummy_backend_logs_abort_multipart_calls() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
let input = AbortMultipartUploadInput::builder()
|
||||
.bucket("b".to_string())
|
||||
.key("k".to_string())
|
||||
.upload_id("UP-1".to_string())
|
||||
.build()
|
||||
.expect("build");
|
||||
backend.abort_multipart_upload(input, "ak", "sk").await.expect("Ok");
|
||||
let calls = backend.abort_multipart_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].upload_id, "UP-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dummy_backend_unconfigured_errors_loudly() {
|
||||
let backend = DummyBackend::new();
|
||||
let err = backend
|
||||
.create_multipart_upload(
|
||||
CreateMultipartUploadInput::builder()
|
||||
.bucket("b".to_string())
|
||||
.key("k".to_string())
|
||||
.build()
|
||||
.expect("build"),
|
||||
"ak",
|
||||
"sk",
|
||||
)
|
||||
.await
|
||||
.expect_err("default create_multipart_upload must error");
|
||||
assert!(err.to_string().contains("not configured"));
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,23 @@ use super::session::SessionContext;
|
||||
/// Authorization errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthorizationError {
|
||||
/// Policy denied the principal the requested action. Distinct
|
||||
/// from IamUnavailable so protocol drivers can map a deny to
|
||||
/// PermissionDenied while mapping a transient IAM outage to
|
||||
/// the spec-equivalent Failure (no SFTPv3 service-unavailable
|
||||
/// status exists).
|
||||
#[error("Access denied")]
|
||||
AccessDenied,
|
||||
|
||||
/// The IAM layer was unreachable or returned an error other
|
||||
/// than the expected Allow/Deny verdict. Indistinguishable
|
||||
/// from AccessDenied at the wire boundary in earlier
|
||||
/// implementations; protocol drivers now branch on this
|
||||
/// variant to surface a warn log naming the failing
|
||||
/// operation so operators can correlate session errors with
|
||||
/// IAM degradation.
|
||||
#[error("IAM system unavailable")]
|
||||
IamUnavailable,
|
||||
}
|
||||
|
||||
/// S3 actions that can be performed through the gateway
|
||||
@@ -211,16 +226,56 @@ pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Act
|
||||
S3Action::GetObjectAcl => false,
|
||||
S3Action::PutObjectAcl => false,
|
||||
},
|
||||
super::session::Protocol::Sftp => match action {
|
||||
// Bucket operations: SFTP exposes top-level buckets as directories.
|
||||
S3Action::CreateBucket => true, // MKDIR at the root
|
||||
S3Action::DeleteBucket => true, // RMDIR at the root
|
||||
S3Action::ListBucket => true, // OPENDIR/READDIR within a bucket
|
||||
S3Action::ListBuckets => true, // OPENDIR/READDIR at the root
|
||||
S3Action::HeadBucket => true, // STAT/LSTAT of a bucket entry
|
||||
|
||||
// Object operations
|
||||
S3Action::GetObject => true, // OPEN/READ
|
||||
S3Action::PutObject => true, // OPEN(WRITE)/WRITE/CLOSE
|
||||
S3Action::DeleteObject => true, // REMOVE
|
||||
S3Action::HeadObject => true, // STAT/LSTAT/FSTAT
|
||||
S3Action::CopyObject => true, // RENAME maps to copy + delete
|
||||
|
||||
// Multipart operations: streamed PUT path used by the write driver.
|
||||
S3Action::CreateMultipartUpload => true,
|
||||
S3Action::UploadPart => true,
|
||||
S3Action::CompleteMultipartUpload => true,
|
||||
S3Action::AbortMultipartUpload => true,
|
||||
S3Action::ListMultipartUploads => false,
|
||||
S3Action::ListParts => false,
|
||||
|
||||
// ACL operations: SFTP has no equivalent surface.
|
||||
S3Action::GetBucketAcl => false,
|
||||
S3Action::PutBucketAcl => false,
|
||||
S3Action::GetObjectAcl => false,
|
||||
S3Action::PutObjectAcl => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a principal is allowed to perform an S3 action
|
||||
pub async fn is_authorized(session_context: &SessionContext, action: &S3Action, bucket: &str, object: Option<&str>) -> bool {
|
||||
/// Check if a principal is allowed to perform an S3 action.
|
||||
/// Returns Ok(true) when the policy allows the action, Ok(false) when
|
||||
/// the policy denies it, and Err(AuthorizationError::IamUnavailable)
|
||||
/// when the IAM layer is unreachable (rustfs_iam::get fails). The
|
||||
/// IamUnavailable case is distinct from a Deny so protocol drivers
|
||||
/// can return a transient-failure status with a warn log instead of
|
||||
/// the permanent permission-denied status that a Deny produces.
|
||||
pub async fn is_authorized(
|
||||
session_context: &SessionContext,
|
||||
action: &S3Action,
|
||||
bucket: &str,
|
||||
object: Option<&str>,
|
||||
) -> Result<bool, AuthorizationError> {
|
||||
let iam_sys = match rustfs_iam::get() {
|
||||
Ok(sys) => sys,
|
||||
Err(e) => {
|
||||
error!("IAM system unavailable: {}", e);
|
||||
return false;
|
||||
return Err(AuthorizationError::IamUnavailable);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,25 +307,273 @@ pub async fn is_authorized(session_context: &SessionContext, action: &S3Action,
|
||||
deny_only: false,
|
||||
};
|
||||
|
||||
iam_sys.is_allowed(&args).await
|
||||
Ok(iam_sys.is_allowed(&args).await)
|
||||
}
|
||||
|
||||
/// Authorize an operation and return an error if not authorized
|
||||
/// Authorize an operation and return an error if not authorized.
|
||||
/// AccessDenied covers both the protocol-not-supported case and the
|
||||
/// policy-denies case. IamUnavailable propagates from is_authorized
|
||||
/// when the IAM layer is unreachable; protocol drivers map it to a
|
||||
/// transient-failure status with a warn log rather than the
|
||||
/// permanent permission-denied status that AccessDenied produces.
|
||||
pub async fn authorize_operation(
|
||||
session_context: &SessionContext,
|
||||
action: &S3Action,
|
||||
bucket: &str,
|
||||
object: Option<&str>,
|
||||
) -> Result<(), AuthorizationError> {
|
||||
// SECURITY: the next two lines are cfg(test)-gated. Release builds strip
|
||||
// them and run only the IAM path below. Implementation and verification
|
||||
// recipe are in the test_auth_override submodule at the bottom of this file.
|
||||
#[cfg(test)]
|
||||
if let Some(decision) = test_auth_override::consult(action, bucket, object) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
// check if the operation is supported
|
||||
if !is_operation_supported(session_context.protocol, action) {
|
||||
return Err(AuthorizationError::AccessDenied);
|
||||
}
|
||||
|
||||
// check IAM authorization
|
||||
if is_authorized(session_context, action, bucket, object).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthorizationError::AccessDenied)
|
||||
match is_authorized(session_context, action, bucket, object).await {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(AuthorizationError::AccessDenied),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only authorisation override for driver-level unit tests.
|
||||
///
|
||||
/// Every item in this module is gated on #[cfg(test)], and the single
|
||||
/// call site in authorize_operation is also #[cfg(test)]-gated, so
|
||||
/// release builds contain none of this code and run only the IAM path.
|
||||
///
|
||||
/// A unit test installs a decide closure via with_test_auth_override,
|
||||
/// runs an async body that calls authorize_operation, and the override
|
||||
/// is cleared on scope exit by a Drop guard so a panic inside the body
|
||||
/// cannot leak the decision into later tests on the same thread.
|
||||
#[cfg(test)]
|
||||
pub mod test_auth_override {
|
||||
use super::{AuthorizationError, S3Action};
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
type DecideFn = Box<dyn Fn(&S3Action, &str, Option<&str>) -> bool>;
|
||||
|
||||
thread_local! {
|
||||
/// Current per-thread Allow/Deny override. None means no test
|
||||
/// has installed one and authorize_operation falls through to
|
||||
/// its IAM path.
|
||||
static OVERRIDE: RefCell<Option<DecideFn>> = const { RefCell::new(None) };
|
||||
|
||||
/// Per-thread IAM-unavailable injection. When true, consult
|
||||
/// short-circuits with IamUnavailable so tests can verify the
|
||||
/// IAM-outage branch without standing up a real degraded IAM
|
||||
/// fixture. Takes precedence over the Allow/Deny OVERRIDE.
|
||||
static IAM_UNAVAILABLE: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Consult the per-thread overrides. IamUnavailable takes
|
||||
/// precedence over the Allow/Deny override so a test combining
|
||||
/// both flags can verify that the unavailable branch fires before
|
||||
/// any policy evaluation. Returns Some(decision) when any
|
||||
/// override is active on the current thread, None otherwise.
|
||||
/// Called exclusively from authorize_operation's cfg(test)-gated
|
||||
/// fast path.
|
||||
pub(super) fn consult(action: &S3Action, bucket: &str, object: Option<&str>) -> Option<Result<(), AuthorizationError>> {
|
||||
if IAM_UNAVAILABLE.with(|c| c.get()) {
|
||||
return Some(Err(AuthorizationError::IamUnavailable));
|
||||
}
|
||||
OVERRIDE.with(|cell| {
|
||||
cell.borrow().as_ref().map(|decide| {
|
||||
if decide(action, bucket, object) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthorizationError::AccessDenied)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a test-only authorisation decision for the duration of the
|
||||
/// supplied async body, then clear it. A Drop guard performs the
|
||||
/// clearing so a panic inside the body does not leak the decision
|
||||
/// into later tests on the same thread.
|
||||
///
|
||||
/// Example:
|
||||
/// let result = with_test_auth_override(
|
||||
/// |_action, _bucket, _object| true,
|
||||
/// async { authorize_operation(&ctx, &action, "b", None).await },
|
||||
/// ).await;
|
||||
pub async fn with_test_auth_override<Fut, R>(decide: impl Fn(&S3Action, &str, Option<&str>) -> bool + 'static, body: Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
OVERRIDE.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
}
|
||||
OVERRIDE.with(|cell| *cell.borrow_mut() = Some(Box::new(decide)));
|
||||
let _reset = Reset;
|
||||
body.await
|
||||
}
|
||||
|
||||
/// Inject AuthorizationError::IamUnavailable for every
|
||||
/// authorize_operation call inside the supplied async body, then
|
||||
/// clear the flag on scope exit (Drop guard handles the panic
|
||||
/// case). Used by the IAM-outage tests that verify protocol
|
||||
/// drivers map the unreachable variant to a transient-failure
|
||||
/// status with a warn log rather than to PermissionDenied.
|
||||
pub async fn with_test_iam_unavailable<Fut, R>(body: Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
IAM_UNAVAILABLE.with(|c| c.set(false));
|
||||
}
|
||||
}
|
||||
IAM_UNAVAILABLE.with(|c| c.set(true));
|
||||
let _reset = Reset;
|
||||
body.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergonomic re-export so tests reach the helpers via
|
||||
/// common::gateway::with_test_auth_override rather than nesting
|
||||
/// the submodule path.
|
||||
#[cfg(test)]
|
||||
pub use test_auth_override::{with_test_auth_override, with_test_iam_unavailable};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
|
||||
use rustfs_policy::auth::UserIdentity;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_session() -> SessionContext {
|
||||
let principal = ProtocolPrincipal::new(Arc::new(UserIdentity::default()));
|
||||
SessionContext::new(principal, Protocol::Sftp, IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_test_auth_override_allow_returns_ok() {
|
||||
let session = test_session();
|
||||
let result = with_test_auth_override(|_action, _bucket, _object| true, async {
|
||||
authorize_operation(&session, &S3Action::GetObject, "b", None).await
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok(), "override returning true must make authorize_operation succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_test_auth_override_deny_returns_err() {
|
||||
let session = test_session();
|
||||
let result = with_test_auth_override(|_action, _bucket, _object| false, async {
|
||||
authorize_operation(&session, &S3Action::PutObject, "b", Some("k")).await
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(AuthorizationError::AccessDenied)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_test_auth_override_clears_after_body() {
|
||||
let session = test_session();
|
||||
// Discard the body Result. The test exercises the clear-on-return
|
||||
// side-effect of with_test_auth_override, not the body's outcome.
|
||||
let _ = with_test_auth_override(|_, _, _| true, async { Result::<(), ()>::Ok(()) }).await;
|
||||
// After the helper returns, the IAM path runs. IAM is not
|
||||
// initialised in this test binary, so is_authorized returns
|
||||
// IamUnavailable. A leaked override would have produced Ok.
|
||||
let result = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
|
||||
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_test_auth_override_closure_sees_action_bucket_object() {
|
||||
let session = test_session();
|
||||
let result = with_test_auth_override(
|
||||
|action, bucket, object| {
|
||||
matches!(action, S3Action::UploadPart) && bucket == "only-this-bucket" && object == Some("only-this-key")
|
||||
},
|
||||
async {
|
||||
let allowed =
|
||||
authorize_operation(&session, &S3Action::UploadPart, "only-this-bucket", Some("only-this-key")).await;
|
||||
let denied_by_action =
|
||||
authorize_operation(&session, &S3Action::GetObject, "only-this-bucket", Some("only-this-key")).await;
|
||||
let denied_by_bucket =
|
||||
authorize_operation(&session, &S3Action::UploadPart, "other-bucket", Some("only-this-key")).await;
|
||||
(allowed, denied_by_action, denied_by_bucket)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.0.is_ok());
|
||||
assert!(matches!(result.1, Err(AuthorizationError::AccessDenied)));
|
||||
assert!(matches!(result.2, Err(AuthorizationError::AccessDenied)));
|
||||
}
|
||||
|
||||
/// Regression guard for the SECURITY invariant: the test override
|
||||
/// is reachable only under cfg(test). The body depends on items in
|
||||
/// the test_auth_override module, so if a future edit moves any of
|
||||
/// those items out of a cfg(test) gate the build of THIS test
|
||||
/// binary still succeeds (cfg(test) is active here) but the
|
||||
/// reviewer recipe documented in test_auth_override's module
|
||||
/// comment will start reporting matches in release expansion. Run
|
||||
/// the recipe before shipping.
|
||||
#[tokio::test]
|
||||
async fn override_roundtrip_confirms_consult_path_under_cfg_test() {
|
||||
let session = test_session();
|
||||
|
||||
// Without an installed override, consult returns None and the
|
||||
// IAM path runs. IAM is not initialised in tests so the path
|
||||
// returns IamUnavailable.
|
||||
let without = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
|
||||
assert!(matches!(without, Err(AuthorizationError::IamUnavailable)));
|
||||
|
||||
// With an installed override, consult returns Some and
|
||||
// authorize_operation returns immediately with the override's
|
||||
// decision, bypassing the IAM path.
|
||||
let with = with_test_auth_override(|_, _, _| true, async {
|
||||
authorize_operation(&session, &S3Action::GetObject, "b", None).await
|
||||
})
|
||||
.await;
|
||||
assert!(with.is_ok());
|
||||
|
||||
// After the scope, consult returns None again and the IAM path
|
||||
// reclaims the authorization decision.
|
||||
let after = authorize_operation(&session, &S3Action::GetObject, "b", None).await;
|
||||
assert!(matches!(after, Err(AuthorizationError::IamUnavailable)));
|
||||
}
|
||||
|
||||
/// IamUnavailable is distinct from AccessDenied at the gateway
|
||||
/// boundary, so protocol drivers can branch on it. with_test_iam_unavailable
|
||||
/// short-circuits authorize_operation with the IamUnavailable
|
||||
/// variant regardless of any installed Allow/Deny override, and
|
||||
/// the precedence is documented in test_auth_override::consult.
|
||||
#[tokio::test]
|
||||
async fn with_test_iam_unavailable_returns_iam_unavailable_variant() {
|
||||
let session = test_session();
|
||||
let result = with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))).await;
|
||||
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
|
||||
}
|
||||
|
||||
/// IamUnavailable beats an installed Allow override, so a test
|
||||
/// combining both flags exercises the documented precedence rule
|
||||
/// in test_auth_override::consult: a degraded IAM is observed
|
||||
/// before any policy evaluation.
|
||||
#[tokio::test]
|
||||
async fn with_test_iam_unavailable_takes_precedence_over_allow_override() {
|
||||
let session = test_session();
|
||||
let result = with_test_auth_override(
|
||||
|_, _, _| true,
|
||||
with_test_iam_unavailable(authorize_operation(&session, &S3Action::GetObject, "b", Some("k"))),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(AuthorizationError::IamUnavailable)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ pub mod client;
|
||||
pub mod gateway;
|
||||
pub mod session;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod dummy_storage;
|
||||
|
||||
pub use client::s3::StorageBackend as S3StorageBackend;
|
||||
pub use gateway::{AuthorizationError, S3Action, authorize_operation, is_operation_supported};
|
||||
pub use session::{ProtocolPrincipal, SessionContext};
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
use rustfs_policy::auth::UserIdentity;
|
||||
use std::net::IpAddr;
|
||||
#[cfg(test)]
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Protocol types
|
||||
@@ -22,6 +24,7 @@ pub enum Protocol {
|
||||
Ftps,
|
||||
Swift,
|
||||
WebDav,
|
||||
Sftp,
|
||||
}
|
||||
|
||||
/// Protocol principal representing an authenticated user
|
||||
@@ -66,3 +69,42 @@ impl SessionContext {
|
||||
self.principal.access_key()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SessionContext suitable for driver-level unit tests. The
|
||||
/// principal has an empty access key and an empty secret key. Auth
|
||||
/// decisions in tests come from the gateway test override, not from
|
||||
/// these credentials. The fields are inspected only when a test
|
||||
/// specifically asserts on them. Callers pick the Protocol variant
|
||||
/// that matches the driver under test.
|
||||
#[cfg(test)]
|
||||
pub fn test_session(protocol: Protocol) -> SessionContext {
|
||||
let principal = ProtocolPrincipal::new(Arc::new(UserIdentity::default()));
|
||||
SessionContext::new(principal, protocol, IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod regression_prevention {
|
||||
use super::*;
|
||||
|
||||
// Compile-time check that every Protocol variant is acknowledged here.
|
||||
// This is intentionally an exhaustive match with no wildcard arm: if a
|
||||
// variant is added without being named, or if any variant is removed,
|
||||
// this test file will fail to compile.
|
||||
#[test]
|
||||
fn protocol_variants_are_named() {
|
||||
fn _check(protocol: Protocol) {
|
||||
match protocol {
|
||||
Protocol::Ftps => {}
|
||||
Protocol::Swift => {}
|
||||
Protocol::WebDav => {}
|
||||
Protocol::Sftp => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_carries_supplied_protocol() {
|
||||
assert_eq!(test_session(Protocol::Sftp).protocol, Protocol::Sftp);
|
||||
assert_eq!(test_session(Protocol::Ftps).protocol, Protocol::Ftps);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user