mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-05 12:57:42 +00:00
841f86fe90
* test(property): harden parser invariants for #3522 * test(property): satisfy clippy for #3522
2290 lines
83 KiB
Rust
2290 lines
83 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.
|
|
|
|
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
|
use crate::common::gateway::{S3Action, authorize_operation};
|
|
use crate::common::session::SessionContext;
|
|
use bytes::Bytes;
|
|
use dav_server::davpath::DavPath;
|
|
use dav_server::fs::{
|
|
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsResult, FsStream, OpenOptions, ReadDirMeta,
|
|
};
|
|
use futures_util::{FutureExt, StreamExt, stream};
|
|
use percent_encoding::percent_decode_str;
|
|
use rustfs_utils::MaskedAccessKey;
|
|
use rustfs_utils::path;
|
|
use s3s::dto::*;
|
|
use std::fmt::Debug;
|
|
use std::io::SeekFrom;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, error};
|
|
|
|
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
|
|
const LOG_SUBSYSTEM_WEBDAV_DRIVER: &str = "webdav_driver";
|
|
const EVENT_WEBDAV_OBJECT_METADATA_FAILED: &str = "webdav_object_metadata_failed";
|
|
const EVENT_WEBDAV_STREAM_READ_FAILED: &str = "webdav_stream_read_failed";
|
|
const EVENT_WEBDAV_OBJECT_READ_FAILED: &str = "webdav_object_read_failed";
|
|
const EVENT_WEBDAV_OBJECT_WRITE_STATE: &str = "webdav_object_write_state";
|
|
const EVENT_WEBDAV_LIST_FAILED: &str = "webdav_list_failed";
|
|
const EVENT_WEBDAV_COPY_FAILED: &str = "webdav_copy_failed";
|
|
const EVENT_WEBDAV_DELETE_FAILED: &str = "webdav_delete_failed";
|
|
const EVENT_WEBDAV_PROBE_FAILED: &str = "webdav_probe_failed";
|
|
const EVENT_WEBDAV_BUCKET_LIST_FAILED: &str = "webdav_bucket_list_failed";
|
|
const EVENT_WEBDAV_BUCKET_METADATA_STATE: &str = "webdav_bucket_metadata_state";
|
|
const EVENT_WEBDAV_DIRECTORY_STATE: &str = "webdav_directory_state";
|
|
const EVENT_WEBDAV_OBJECT_DELETE_STATE: &str = "webdav_object_delete_state";
|
|
const EVENT_WEBDAV_RENAME_STATE: &str = "webdav_rename_state";
|
|
|
|
/// Convert s3s ETag enum to string
|
|
fn etag_to_string(etag: &ETag) -> String {
|
|
match etag {
|
|
ETag::Strong(s) => s.clone(),
|
|
ETag::Weak(s) => s.clone(),
|
|
}
|
|
}
|
|
|
|
/// WebDAV metadata implementation
|
|
#[derive(Debug, Clone)]
|
|
pub struct WebDavMetaData {
|
|
/// File size in bytes
|
|
pub size: u64,
|
|
/// Modification time
|
|
pub modified: SystemTime,
|
|
/// Creation time
|
|
pub created: SystemTime,
|
|
/// Whether this is a directory
|
|
pub is_dir: bool,
|
|
/// ETag (optional)
|
|
pub etag: Option<String>,
|
|
/// Content type (optional)
|
|
pub content_type: Option<String>,
|
|
}
|
|
|
|
impl DavMetaData for WebDavMetaData {
|
|
fn len(&self) -> u64 {
|
|
self.size
|
|
}
|
|
|
|
fn modified(&self) -> FsResult<SystemTime> {
|
|
Ok(self.modified)
|
|
}
|
|
|
|
fn is_dir(&self) -> bool {
|
|
self.is_dir
|
|
}
|
|
|
|
fn created(&self) -> FsResult<SystemTime> {
|
|
Ok(self.created)
|
|
}
|
|
|
|
fn etag(&self) -> Option<String> {
|
|
self.etag.clone()
|
|
}
|
|
}
|
|
|
|
/// WebDAV directory entry implementation
|
|
#[derive(Debug, Clone)]
|
|
pub struct WebDavDirEntry {
|
|
/// Entry name
|
|
pub name: String,
|
|
/// Entry metadata
|
|
pub metadata: WebDavMetaData,
|
|
}
|
|
|
|
impl DavDirEntry for WebDavDirEntry {
|
|
fn name(&self) -> Vec<u8> {
|
|
self.name.as_bytes().to_vec()
|
|
}
|
|
|
|
fn metadata(&self) -> FsFuture<'_, Box<dyn DavMetaData>> {
|
|
let meta = self.metadata.clone();
|
|
async move { Ok(Box::new(meta) as Box<dyn DavMetaData>) }.boxed()
|
|
}
|
|
}
|
|
|
|
/// WebDAV file implementation for reading/writing
|
|
pub struct WebDavFile<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
/// Storage backend
|
|
storage: S,
|
|
/// Session context for authorization
|
|
session_context: Arc<SessionContext>,
|
|
/// Bucket name
|
|
bucket: String,
|
|
/// Object key
|
|
key: String,
|
|
/// Current position in file (using RwLock for interior mutability in async)
|
|
position: Arc<RwLock<u64>>,
|
|
/// File size (known after metadata fetch)
|
|
size: Option<u64>,
|
|
/// Write buffer for accumulating data before upload
|
|
write_buffer: Arc<RwLock<Vec<u8>>>,
|
|
/// Whether we're in write mode
|
|
is_write: bool,
|
|
/// Maximum body size for chunked transfers
|
|
max_body_size: u64,
|
|
}
|
|
|
|
impl<S> WebDavFile<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
/// Default maximum body size (5GB)
|
|
pub const DEFAULT_MAX_BODY_SIZE: u64 = 5 * 1024 * 1024 * 1024;
|
|
|
|
pub fn new(storage: S, session_context: Arc<SessionContext>, bucket: String, key: String, is_write: bool) -> Self {
|
|
Self::with_max_body_size(storage, session_context, bucket, key, is_write, Self::DEFAULT_MAX_BODY_SIZE)
|
|
}
|
|
|
|
pub fn with_max_body_size(
|
|
storage: S,
|
|
session_context: Arc<SessionContext>,
|
|
bucket: String,
|
|
key: String,
|
|
is_write: bool,
|
|
max_body_size: u64,
|
|
) -> Self {
|
|
Self {
|
|
storage,
|
|
session_context,
|
|
bucket,
|
|
key,
|
|
position: Arc::new(RwLock::new(0)),
|
|
size: None,
|
|
write_buffer: Arc::new(RwLock::new(Vec::new())),
|
|
is_write,
|
|
max_body_size,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<S> Debug for WebDavFile<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("WebDavFile")
|
|
.field("bucket", &self.bucket)
|
|
.field("key", &self.key)
|
|
.field("position", &"<locked>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl<S> DavFile for WebDavFile<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
fn metadata(&mut self) -> FsFuture<'_, Box<dyn DavMetaData>> {
|
|
let storage = self.storage.clone();
|
|
let session_context = self.session_context.clone();
|
|
let bucket = self.bucket.clone();
|
|
let key = self.key.clone();
|
|
|
|
async move {
|
|
match storage
|
|
.head_object(
|
|
&bucket,
|
|
&key,
|
|
&session_context.principal.user_identity.credentials.access_key,
|
|
&session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(output) => {
|
|
let size = output.content_length.unwrap_or(0) as u64;
|
|
let modified = output
|
|
.last_modified
|
|
.map(|dt| {
|
|
let offset_dt: time::OffsetDateTime = dt.into();
|
|
SystemTime::from(offset_dt)
|
|
})
|
|
.unwrap_or_else(SystemTime::now);
|
|
|
|
Ok(Box::new(WebDavMetaData {
|
|
size,
|
|
modified,
|
|
created: modified,
|
|
is_dir: false,
|
|
etag: output.e_tag.as_ref().map(etag_to_string),
|
|
content_type: output.content_type.map(|c| c.to_string()),
|
|
}) as Box<dyn DavMetaData>)
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_OBJECT_METADATA_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
object = %key,
|
|
error = %e,
|
|
"webdav object metadata failed"
|
|
);
|
|
Err(FsError::NotFound)
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn write_buf(&mut self, mut buf: Box<dyn bytes::Buf + Send>) -> FsFuture<'_, ()> {
|
|
let write_buffer = self.write_buffer.clone();
|
|
let max_body_size = self.max_body_size;
|
|
async move {
|
|
let mut buffer = write_buffer.write().await;
|
|
// Consume all chunks from the buffer, not just the first one
|
|
while buf.has_remaining() {
|
|
let chunk = buf.chunk();
|
|
// Check size limit before extending
|
|
if buffer.len() as u64 + chunk.len() as u64 > max_body_size {
|
|
return Err(FsError::TooLarge);
|
|
}
|
|
buffer.extend_from_slice(chunk);
|
|
buf.advance(chunk.len());
|
|
}
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
|
let write_buffer = self.write_buffer.clone();
|
|
let max_body_size = self.max_body_size;
|
|
async move {
|
|
let mut buffer = write_buffer.write().await;
|
|
// Check size limit before extending
|
|
if buffer.len() as u64 + buf.len() as u64 > max_body_size {
|
|
return Err(FsError::TooLarge);
|
|
}
|
|
buffer.extend_from_slice(&buf);
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn read_bytes(&mut self, count: usize) -> FsFuture<'_, Bytes> {
|
|
let storage = self.storage.clone();
|
|
let session_context = self.session_context.clone();
|
|
let bucket = self.bucket.clone();
|
|
let key = self.key.clone();
|
|
let position = self.position.clone();
|
|
|
|
async move {
|
|
let start_pos = *position.read().await;
|
|
match storage
|
|
.get_object_range(
|
|
&bucket,
|
|
&key,
|
|
&session_context.principal.user_identity.credentials.access_key,
|
|
&session_context.principal.user_identity.credentials.secret_key,
|
|
start_pos,
|
|
count as u64,
|
|
)
|
|
.await
|
|
{
|
|
Ok(output) => {
|
|
if let Some(body) = output.body {
|
|
let mut data = Vec::new();
|
|
let mut stream = body;
|
|
while let Some(chunk_result) = stream.next().await {
|
|
match chunk_result {
|
|
Ok(bytes) => data.extend_from_slice(&bytes),
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_STREAM_READ_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
object = %key,
|
|
error = %e,
|
|
"webdav stream read failed"
|
|
);
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
}
|
|
}
|
|
// Update position after successful read
|
|
let bytes_read = data.len() as u64;
|
|
*position.write().await = start_pos + bytes_read;
|
|
Ok(Bytes::from(data))
|
|
} else {
|
|
Ok(Bytes::new())
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_OBJECT_READ_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
object = %key,
|
|
start_pos,
|
|
read_len = count,
|
|
error = %e,
|
|
"webdav object read failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> {
|
|
let position = self.position.clone();
|
|
let size = self.size;
|
|
|
|
async move {
|
|
let current_pos = *position.read().await;
|
|
let new_pos = match pos {
|
|
SeekFrom::Start(offset) => offset,
|
|
SeekFrom::End(offset) => {
|
|
let file_size = size.unwrap_or(0);
|
|
if offset < 0 {
|
|
file_size.saturating_sub((-offset) as u64)
|
|
} else {
|
|
file_size + offset as u64
|
|
}
|
|
}
|
|
SeekFrom::Current(offset) => {
|
|
if offset < 0 {
|
|
current_pos.saturating_sub((-offset) as u64)
|
|
} else {
|
|
current_pos + offset as u64
|
|
}
|
|
}
|
|
};
|
|
// Persist the new position
|
|
*position.write().await = new_pos;
|
|
Ok(new_pos)
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn flush(&mut self) -> FsFuture<'_, ()> {
|
|
let storage = self.storage.clone();
|
|
let session_context = self.session_context.clone();
|
|
let bucket = self.bucket.clone();
|
|
let key = self.key.clone();
|
|
let write_buffer = self.write_buffer.clone();
|
|
let is_write = self.is_write;
|
|
|
|
async move {
|
|
if !is_write {
|
|
return Ok(());
|
|
}
|
|
|
|
// Use write lock and std::mem::take to avoid cloning the buffer
|
|
let mut buffer = write_buffer.write().await;
|
|
let file_size = buffer.len();
|
|
let data_bytes = Bytes::from(std::mem::take(&mut *buffer));
|
|
drop(buffer);
|
|
|
|
let stream = stream::once(async move { Ok::<Bytes, std::io::Error>(data_bytes) });
|
|
let streaming_blob = StreamingBlob::wrap(stream);
|
|
|
|
let put_input = PutObjectInput::builder()
|
|
.bucket(bucket.clone())
|
|
.key(key.clone())
|
|
.content_length(Some(file_size as i64))
|
|
.body(Some(streaming_blob))
|
|
.build()
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
match storage
|
|
.put_object(
|
|
put_input,
|
|
&session_context.principal.user_identity.credentials.access_key,
|
|
&session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
debug!(
|
|
event = EVENT_WEBDAV_OBJECT_WRITE_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "flushed",
|
|
bucket = %bucket,
|
|
object = %key,
|
|
file_size,
|
|
"WebDAV object flush completed"
|
|
);
|
|
// Buffer already cleared by std::mem::take above
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_OBJECT_WRITE_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "flush_failed",
|
|
bucket = %bucket,
|
|
object = %key,
|
|
file_size,
|
|
error = %e,
|
|
"WebDAV object flush failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
}
|
|
|
|
/// WebDAV filesystem driver implementation
|
|
pub struct WebDavDriver<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
/// Storage backend for S3 operations
|
|
storage: S,
|
|
/// Session context for authorization
|
|
session_context: Arc<SessionContext>,
|
|
}
|
|
|
|
enum ResolvedPath {
|
|
File(Box<HeadObjectOutput>),
|
|
Directory {
|
|
prefix: String,
|
|
metadata: Option<Box<HeadObjectOutput>>,
|
|
},
|
|
}
|
|
|
|
enum HeadObjectProbe {
|
|
Forbidden,
|
|
Missing,
|
|
Found(Box<HeadObjectOutput>),
|
|
}
|
|
|
|
impl<S> Debug for WebDavDriver<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("WebDavDriver").field("storage", &"StorageBackend").finish()
|
|
}
|
|
}
|
|
|
|
impl<S> Clone for WebDavDriver<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
storage: self.storage.clone(),
|
|
session_context: self.session_context.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<S> WebDavDriver<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
/// Create a new WebDAV driver with the given storage backend and session context
|
|
pub fn new(storage: S, session_context: Arc<SessionContext>) -> Self {
|
|
Self {
|
|
storage,
|
|
session_context,
|
|
}
|
|
}
|
|
|
|
fn credentials(&self) -> (&str, &str) {
|
|
(
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
}
|
|
|
|
fn is_missing_head_object_error(error: &str) -> bool {
|
|
let lower = error.to_ascii_lowercase();
|
|
lower.contains("nosuchkey")
|
|
|| lower.contains("notfound")
|
|
|| lower.contains("not found")
|
|
|| lower.contains("status code: 404")
|
|
}
|
|
|
|
async fn prefix_has_entries(&self, bucket: &str, prefix: &str) -> FsResult<bool> {
|
|
let (access_key, secret_key) = self.credentials();
|
|
let list_input = ListObjectsV2Input::builder()
|
|
.bucket(bucket.to_string())
|
|
.prefix(Some(prefix.to_string()))
|
|
.max_keys(Some(1))
|
|
.build()
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
let output = self
|
|
.storage
|
|
.list_objects_v2(list_input, access_key, secret_key)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_LIST_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
prefix = %prefix,
|
|
error = %e,
|
|
"webdav list failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
Ok(output.contents.map(|c| !c.is_empty()).unwrap_or(false)
|
|
|| output.common_prefixes.map(|c| !c.is_empty()).unwrap_or(false))
|
|
}
|
|
|
|
async fn copy_object_streaming(&self, src_bucket: &str, src_key: &str, dst_bucket: &str, dst_key: &str) -> FsResult<()> {
|
|
let (access_key, secret_key) = self.credentials();
|
|
let get_output = self
|
|
.storage
|
|
.get_object(src_bucket, src_key, access_key, secret_key, None)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_COPY_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "source_read_failed",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
error = %e,
|
|
"webdav copy failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
let GetObjectOutput {
|
|
body,
|
|
content_length,
|
|
content_type,
|
|
..
|
|
} = get_output;
|
|
let body = body.ok_or_else(|| {
|
|
error!(
|
|
event = EVENT_WEBDAV_COPY_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "source_body_missing",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
"webdav copy failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
let mut put_builder = PutObjectInput::builder()
|
|
.bucket(dst_bucket.to_string())
|
|
.key(dst_key.to_string())
|
|
.body(Some(body));
|
|
|
|
if let Some(content_length) = content_length {
|
|
put_builder = put_builder.content_length(Some(content_length));
|
|
}
|
|
|
|
if let Some(content_type) = content_type {
|
|
put_builder = put_builder.content_type(Some(content_type));
|
|
}
|
|
|
|
let put_input = put_builder.build().map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
self.storage
|
|
.put_object(put_input, access_key, secret_key)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_COPY_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "destination_write_failed",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
error = %e,
|
|
"webdav copy failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn execute_directory_rename_pairs(
|
|
&self,
|
|
src_bucket: &str,
|
|
dst_bucket: &str,
|
|
rename_pairs: &[(String, String)],
|
|
) -> FsResult<()> {
|
|
let (access_key, secret_key) = self.credentials();
|
|
|
|
for (src_obj_key, dst_obj_key) in rename_pairs {
|
|
self.copy_object_streaming(src_bucket, src_obj_key, dst_bucket, dst_obj_key)
|
|
.await?;
|
|
}
|
|
|
|
for (src_obj_key, _) in rename_pairs {
|
|
self.storage
|
|
.delete_object(src_bucket, src_obj_key, access_key, secret_key)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_DELETE_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "rename_cleanup_failed",
|
|
bucket = %src_bucket,
|
|
object = %src_obj_key,
|
|
error = %e,
|
|
"webdav delete failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn probe_head_object(&self, bucket: &str, key: &str) -> FsResult<HeadObjectProbe> {
|
|
let (access_key, secret_key) = self.credentials();
|
|
|
|
if authorize_operation(&self.session_context, &S3Action::HeadObject, bucket, Some(key))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Ok(HeadObjectProbe::Forbidden);
|
|
}
|
|
|
|
match self.storage.head_object(bucket, key, access_key, secret_key).await {
|
|
Ok(output) => Ok(HeadObjectProbe::Found(Box::new(output))),
|
|
Err(e) => {
|
|
let err_msg = e.to_string();
|
|
if Self::is_missing_head_object_error(&err_msg) {
|
|
Ok(HeadObjectProbe::Missing)
|
|
} else {
|
|
error!(
|
|
event = EVENT_WEBDAV_PROBE_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
object = %key,
|
|
error = %err_msg,
|
|
"webdav probe failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn resolve_path(&self, bucket: &str, key: &str) -> FsResult<ResolvedPath> {
|
|
let prefix = format!("{}/", key);
|
|
let mut had_visibility = false;
|
|
|
|
match self.probe_head_object(bucket, key).await? {
|
|
HeadObjectProbe::Found(output) => {
|
|
let size = output.content_length.unwrap_or(0) as u64;
|
|
let is_dir_marker = output.content_type.as_deref() == Some("application/x-directory");
|
|
|
|
if is_dir_marker {
|
|
return Ok(ResolvedPath::Directory {
|
|
prefix,
|
|
metadata: Some(output),
|
|
});
|
|
}
|
|
|
|
if size == 0
|
|
&& authorize_operation(&self.session_context, &S3Action::ListBucket, bucket, Some(&prefix))
|
|
.await
|
|
.is_ok()
|
|
&& self.prefix_has_entries(bucket, &prefix).await?
|
|
{
|
|
return Ok(ResolvedPath::Directory {
|
|
prefix,
|
|
metadata: Some(output),
|
|
});
|
|
}
|
|
|
|
return Ok(ResolvedPath::File(output));
|
|
}
|
|
HeadObjectProbe::Missing => {
|
|
had_visibility = true;
|
|
}
|
|
HeadObjectProbe::Forbidden => {}
|
|
}
|
|
|
|
match self.probe_head_object(bucket, &prefix).await? {
|
|
HeadObjectProbe::Found(output) => {
|
|
return Ok(ResolvedPath::Directory {
|
|
prefix,
|
|
metadata: Some(output),
|
|
});
|
|
}
|
|
HeadObjectProbe::Missing => {
|
|
had_visibility = true;
|
|
}
|
|
HeadObjectProbe::Forbidden => {}
|
|
}
|
|
|
|
if authorize_operation(&self.session_context, &S3Action::ListBucket, bucket, Some(&prefix))
|
|
.await
|
|
.is_ok()
|
|
{
|
|
had_visibility = true;
|
|
if self.prefix_has_entries(bucket, &prefix).await? {
|
|
return Ok(ResolvedPath::Directory { prefix, metadata: None });
|
|
}
|
|
}
|
|
|
|
if had_visibility {
|
|
Err(FsError::NotFound)
|
|
} else {
|
|
Err(FsError::Forbidden)
|
|
}
|
|
}
|
|
|
|
/// Parse WebDAV path to bucket and object key
|
|
fn parse_path(&self, path: &DavPath) -> Result<(String, Option<String>), FsError> {
|
|
let path_str = path.as_url_string();
|
|
let decoded_path = percent_decode_str(&path_str)
|
|
.decode_utf8()
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
if decoded_path.chars().any(char::is_control) {
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
|
|
let cleaned_path = path::clean(&decoded_path);
|
|
let (bucket, object) = path::path_to_bucket_object(&cleaned_path);
|
|
|
|
if bucket.is_empty() {
|
|
return Ok((String::new(), None));
|
|
}
|
|
|
|
if object.contains(path::GLOBAL_DIR_SUFFIX) {
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
|
|
let key = if object.is_empty() { None } else { Some(object) };
|
|
Ok((bucket, key))
|
|
}
|
|
|
|
/// Check if path is root
|
|
fn is_root(&self, path: &DavPath) -> bool {
|
|
let path_str = path.as_url_string();
|
|
path_str == "/" || path_str.is_empty()
|
|
}
|
|
|
|
/// List all buckets (for root path)
|
|
async fn list_buckets(&self) -> FsResult<Vec<WebDavDirEntry>> {
|
|
match authorize_operation(&self.session_context, &S3Action::ListBuckets, "", None).await {
|
|
Ok(_) => {}
|
|
Err(_e) => {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
}
|
|
|
|
match self
|
|
.storage
|
|
.list_buckets(
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(output) => {
|
|
let mut entries = Vec::new();
|
|
if let Some(buckets) = output.buckets {
|
|
for bucket in buckets {
|
|
if let Some(ref bucket_name) = bucket.name {
|
|
let modified = bucket
|
|
.creation_date
|
|
.map(|dt| {
|
|
let offset_dt: time::OffsetDateTime = dt.into();
|
|
SystemTime::from(offset_dt)
|
|
})
|
|
.unwrap_or_else(SystemTime::now);
|
|
|
|
entries.push(WebDavDirEntry {
|
|
name: bucket_name.clone(),
|
|
metadata: WebDavMetaData {
|
|
size: 0,
|
|
modified,
|
|
created: modified,
|
|
is_dir: true,
|
|
etag: None,
|
|
content_type: None,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(entries)
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_BUCKET_LIST_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
error = %e,
|
|
access_key = %MaskedAccessKey(&self.session_context.principal.user_identity.credentials.access_key),
|
|
"webdav bucket list failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List objects in a bucket
|
|
async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> FsResult<Vec<WebDavDirEntry>> {
|
|
// Authorize the operation
|
|
authorize_operation(&self.session_context, &S3Action::ListBucket, bucket, prefix)
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
let prefix_with_slash = prefix.map(|p| if p.ends_with('/') { p.to_string() } else { format!("{}/", p) });
|
|
|
|
let list_input = ListObjectsV2Input::builder()
|
|
.bucket(bucket.to_string())
|
|
.prefix(prefix_with_slash.clone())
|
|
.delimiter(Some("/".to_string()))
|
|
.build()
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
match self
|
|
.storage
|
|
.list_objects_v2(
|
|
list_input,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(output) => {
|
|
let mut entries = Vec::new();
|
|
|
|
// Collect common prefix base names for filtering
|
|
let common_prefix_names: std::collections::HashSet<String> = output
|
|
.common_prefixes
|
|
.as_ref()
|
|
.map(|prefixes| {
|
|
prefixes
|
|
.iter()
|
|
.filter_map(|p| p.prefix.as_ref())
|
|
.map(|p| {
|
|
std::path::PathBuf::from(p.trim_end_matches('/'))
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| p.clone())
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Add files (objects)
|
|
if let Some(objects) = output.contents {
|
|
for obj in objects {
|
|
if let Some(key) = obj.key {
|
|
// Filter: only show files directly in current directory
|
|
let should_show = if prefix.is_none() {
|
|
!key.contains('/')
|
|
} else {
|
|
key.starts_with(&prefix_with_slash.clone().unwrap_or_default())
|
|
};
|
|
|
|
if !should_show {
|
|
continue;
|
|
}
|
|
|
|
let filename = std::path::PathBuf::from(key.as_str())
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| key.clone());
|
|
|
|
let size = obj.size.unwrap_or(0) as u64;
|
|
|
|
// Skip directory markers (keys ending with /)
|
|
if key.ends_with('/') {
|
|
continue;
|
|
}
|
|
|
|
// Skip 0-byte objects that match a directory name (Windows WebDAV duplicates)
|
|
if size == 0 && common_prefix_names.contains(&filename) {
|
|
continue;
|
|
}
|
|
|
|
let modified = obj
|
|
.last_modified
|
|
.map(|dt| {
|
|
let offset_dt: time::OffsetDateTime = dt.into();
|
|
SystemTime::from(offset_dt)
|
|
})
|
|
.unwrap_or_else(SystemTime::now);
|
|
|
|
entries.push(WebDavDirEntry {
|
|
name: filename,
|
|
metadata: WebDavMetaData {
|
|
size,
|
|
modified,
|
|
created: modified,
|
|
is_dir: false,
|
|
etag: obj.e_tag.as_ref().map(etag_to_string),
|
|
content_type: None,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add directories (common prefixes)
|
|
if let Some(common_prefixes) = output.common_prefixes {
|
|
for prefix in common_prefixes {
|
|
if let Some(prefix_str) = prefix.prefix {
|
|
let dir_name = std::path::PathBuf::from(prefix_str.as_str().trim_end_matches('/'))
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| prefix_str.clone());
|
|
|
|
entries.push(WebDavDirEntry {
|
|
name: dir_name,
|
|
metadata: WebDavMetaData {
|
|
size: 0,
|
|
modified: SystemTime::now(),
|
|
created: SystemTime::now(),
|
|
is_dir: true,
|
|
etag: None,
|
|
content_type: None,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_LIST_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
bucket = %bucket,
|
|
prefix = %prefix_with_slash.unwrap_or_default(),
|
|
error = %e,
|
|
"webdav list failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursively delete all objects in a bucket, then delete the bucket itself
|
|
async fn delete_bucket_recursively(&self, bucket: &str) -> FsResult<()> {
|
|
// First, delete all objects in the bucket (with pagination)
|
|
let mut continuation_token = None;
|
|
loop {
|
|
let mut list_input = ListObjectsV2Input::builder().bucket(bucket.to_string());
|
|
|
|
if let Some(token) = continuation_token {
|
|
list_input = list_input.continuation_token(token);
|
|
}
|
|
|
|
let list_input = list_input.build().map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
if let Ok(output) = self
|
|
.storage
|
|
.list_objects_v2(
|
|
list_input,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
// Delete all objects in this page
|
|
if let Some(objects) = output.contents {
|
|
for obj in objects {
|
|
if let Some(obj_key) = obj.key {
|
|
let _ = self
|
|
.storage
|
|
.delete_object(
|
|
bucket,
|
|
&obj_key,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if there are more objects
|
|
if !output.is_truncated.unwrap_or(false) {
|
|
break;
|
|
}
|
|
continuation_token = Some(output.next_continuation_token);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Then delete the bucket
|
|
match self
|
|
.storage
|
|
.delete_bucket(
|
|
bucket,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => Ok(()),
|
|
Err(e) if e.to_string().contains("NoSuchBucket") => Ok(()),
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_DELETE_FAILED,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "bucket_delete_failed",
|
|
bucket = %bucket,
|
|
error = %e,
|
|
"webdav delete failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<S> DavFileSystem for WebDavDriver<S>
|
|
where
|
|
S: S3StorageBackend + Debug + Clone + Send + Sync + 'static,
|
|
{
|
|
fn open<'a>(&'a self, path: &'a DavPath, options: OpenOptions) -> FsFuture<'a, Box<dyn DavFile>> {
|
|
let storage = self.storage.clone();
|
|
let session_context = self.session_context.clone();
|
|
|
|
async move {
|
|
let (bucket, key) = self.parse_path(path)?;
|
|
|
|
if bucket.is_empty() {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let key = key.ok_or(FsError::Forbidden)?; // Cannot open a bucket as a file
|
|
|
|
// Check authorization based on operation type
|
|
if options.write || options.create || options.create_new || options.append {
|
|
authorize_operation(&session_context, &S3Action::PutObject, &bucket, Some(&key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
} else {
|
|
authorize_operation(&session_context, &S3Action::GetObject, &bucket, Some(&key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
}
|
|
|
|
let is_write = options.write || options.create || options.create_new || options.append;
|
|
let file = WebDavFile::new(storage, session_context, bucket, key, is_write);
|
|
|
|
Ok(Box::new(file) as Box<dyn DavFile>)
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn read_dir<'a>(&'a self, path: &'a DavPath, _meta: ReadDirMeta) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
|
|
async move {
|
|
let entries = if self.is_root(path) {
|
|
self.list_buckets().await?
|
|
} else {
|
|
let (bucket, prefix) = self.parse_path(path)?;
|
|
if bucket.is_empty() {
|
|
self.list_buckets().await?
|
|
} else {
|
|
self.list_objects(&bucket, prefix.as_deref()).await?
|
|
}
|
|
};
|
|
|
|
let stream = stream::iter(entries.into_iter().map(|e| Ok(Box::new(e) as Box<dyn DavDirEntry>)));
|
|
Ok(Box::pin(stream) as FsStream<Box<dyn DavDirEntry>>)
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
|
async move {
|
|
if self.is_root(path) {
|
|
return Ok(Box::new(WebDavMetaData {
|
|
size: 0,
|
|
modified: SystemTime::now(),
|
|
created: SystemTime::now(),
|
|
is_dir: true,
|
|
etag: None,
|
|
content_type: None,
|
|
}) as Box<dyn DavMetaData>);
|
|
}
|
|
|
|
let (bucket, key) = self.parse_path(path)?;
|
|
|
|
if bucket.is_empty() {
|
|
return Ok(Box::new(WebDavMetaData {
|
|
size: 0,
|
|
modified: SystemTime::now(),
|
|
created: SystemTime::now(),
|
|
is_dir: true,
|
|
etag: None,
|
|
content_type: None,
|
|
}) as Box<dyn DavMetaData>);
|
|
}
|
|
|
|
if let Some(key) = key {
|
|
return match self.resolve_path(&bucket, &key).await? {
|
|
ResolvedPath::File(output) => {
|
|
let size = output.content_length.unwrap_or(0) as u64;
|
|
let modified = output
|
|
.last_modified
|
|
.map(|dt| {
|
|
let offset_dt: time::OffsetDateTime = dt.into();
|
|
SystemTime::from(offset_dt)
|
|
})
|
|
.unwrap_or_else(SystemTime::now);
|
|
|
|
Ok(Box::new(WebDavMetaData {
|
|
size,
|
|
modified,
|
|
created: modified,
|
|
is_dir: false,
|
|
etag: output.e_tag.as_ref().map(etag_to_string),
|
|
content_type: output.content_type.map(|c| c.to_string()),
|
|
}) as Box<dyn DavMetaData>)
|
|
}
|
|
ResolvedPath::Directory { metadata, .. } => {
|
|
let modified = metadata
|
|
.as_ref()
|
|
.and_then(|output| output.last_modified.as_ref())
|
|
.map(|dt| {
|
|
let offset_dt: time::OffsetDateTime = dt.clone().into();
|
|
SystemTime::from(offset_dt)
|
|
})
|
|
.unwrap_or_else(SystemTime::now);
|
|
|
|
Ok(Box::new(WebDavMetaData {
|
|
size: 0,
|
|
modified,
|
|
created: modified,
|
|
is_dir: true,
|
|
etag: metadata.as_ref().and_then(|output| output.e_tag.as_ref().map(etag_to_string)),
|
|
content_type: metadata.and_then(|output| output.content_type.map(|c| c.to_string())),
|
|
}) as Box<dyn DavMetaData>)
|
|
}
|
|
};
|
|
} else {
|
|
// Get bucket metadata
|
|
authorize_operation(&self.session_context, &S3Action::HeadBucket, &bucket, None)
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
match self
|
|
.storage
|
|
.head_bucket(
|
|
&bucket,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => Ok(Box::new(WebDavMetaData {
|
|
size: 0,
|
|
modified: SystemTime::now(),
|
|
created: SystemTime::now(),
|
|
is_dir: true,
|
|
etag: None,
|
|
content_type: None,
|
|
}) as Box<dyn DavMetaData>),
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_WEBDAV_BUCKET_METADATA_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
result = "not_found",
|
|
bucket = %bucket,
|
|
error = %e,
|
|
"WebDAV bucket metadata listed"
|
|
);
|
|
Err(FsError::NotFound)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
|
async move {
|
|
let (bucket, key) = self.parse_path(path)?;
|
|
|
|
if bucket.is_empty() {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
if let Some(key_str) = key {
|
|
// Creating a "directory" in S3 by creating a zero-byte object with trailing slash
|
|
let dir_key = if key_str.ends_with('/') {
|
|
key_str.to_string()
|
|
} else {
|
|
format!("{}/", key_str)
|
|
};
|
|
|
|
authorize_operation(&self.session_context, &S3Action::PutObject, &bucket, Some(&dir_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
// Create empty streaming blob for directory marker
|
|
let stream = futures_util::stream::once(async { Ok::<Bytes, std::io::Error>(Bytes::new()) });
|
|
let streaming_blob = s3s::dto::StreamingBlob::wrap(stream);
|
|
|
|
let put_input = s3s::dto::PutObjectInput::builder()
|
|
.bucket(bucket.clone())
|
|
.key(dir_key.clone())
|
|
.content_length(Some(0))
|
|
.content_type(Some("application/x-directory".to_string()))
|
|
.body(Some(streaming_blob))
|
|
.build()
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
match self
|
|
.storage
|
|
.put_object(
|
|
put_input,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
debug!(
|
|
event = EVENT_WEBDAV_DIRECTORY_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "created",
|
|
bucket = %bucket,
|
|
object = %dir_key,
|
|
"WebDAV directory marker created"
|
|
);
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_DIRECTORY_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "create_failed",
|
|
bucket = %bucket,
|
|
object = %dir_key,
|
|
error = %e,
|
|
"WebDAV directory marker create failed"
|
|
);
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create bucket
|
|
authorize_operation(&self.session_context, &S3Action::CreateBucket, &bucket, None)
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
match self
|
|
.storage
|
|
.create_bucket(
|
|
&bucket,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
debug!(
|
|
event = EVENT_WEBDAV_DIRECTORY_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "bucket_created",
|
|
bucket = %bucket,
|
|
"WebDAV bucket created"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_DIRECTORY_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "bucket_create_failed",
|
|
bucket = %bucket,
|
|
error = %e,
|
|
"WebDAV bucket create failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
|
async move {
|
|
let (bucket, key) = self.parse_path(path)?;
|
|
|
|
if bucket.is_empty() {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
if let Some(prefix) = key {
|
|
// Delete all objects with this prefix (subdirectory)
|
|
let prefix_with_slash = if prefix.ends_with('/') {
|
|
prefix.to_string()
|
|
} else {
|
|
format!("{}/", prefix)
|
|
};
|
|
|
|
authorize_operation(&self.session_context, &S3Action::DeleteObject, &bucket, Some(&prefix_with_slash))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
// List and delete all objects with this prefix
|
|
let mut continuation_token = None;
|
|
loop {
|
|
let mut list_input = ListObjectsV2Input::builder()
|
|
.bucket(bucket.clone())
|
|
.prefix(Some(prefix_with_slash.clone()));
|
|
|
|
if let Some(token) = continuation_token {
|
|
list_input = list_input.continuation_token(token);
|
|
}
|
|
|
|
let list_input = list_input.build().map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
if let Ok(output) = self
|
|
.storage
|
|
.list_objects_v2(
|
|
list_input,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
if let Some(objects) = output.contents {
|
|
for obj in objects {
|
|
if let Some(obj_key) = obj.key {
|
|
let _ = self
|
|
.storage
|
|
.delete_object(
|
|
&bucket,
|
|
&obj_key,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !output.is_truncated.unwrap_or(false) {
|
|
break;
|
|
}
|
|
continuation_token = Some(output.next_continuation_token);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Also delete the directory marker itself
|
|
let _ = self
|
|
.storage
|
|
.delete_object(
|
|
&bucket,
|
|
&prefix_with_slash,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await;
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
// Delete bucket
|
|
authorize_operation(&self.session_context, &S3Action::DeleteBucket, &bucket, None)
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
self.delete_bucket_recursively(&bucket).await
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
|
async move {
|
|
let (bucket, key) = self.parse_path(path)?;
|
|
|
|
if bucket.is_empty() {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let key = key.ok_or(FsError::Forbidden)?;
|
|
|
|
// Authorize delete object
|
|
authorize_operation(&self.session_context, &S3Action::DeleteObject, &bucket, Some(&key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
match self
|
|
.storage
|
|
.delete_object(
|
|
&bucket,
|
|
&key,
|
|
&self.session_context.principal.user_identity.credentials.access_key,
|
|
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
debug!(
|
|
event = EVENT_WEBDAV_OBJECT_DELETE_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "deleted",
|
|
bucket = %bucket,
|
|
object = %key,
|
|
"WebDAV object deleted"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_WEBDAV_OBJECT_DELETE_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "delete_failed",
|
|
bucket = %bucket,
|
|
object = %key,
|
|
error = %e,
|
|
"WebDAV object delete failed"
|
|
);
|
|
Err(FsError::GeneralFailure)
|
|
}
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
|
async move {
|
|
let (src_bucket, src_key) = self.parse_path(from)?;
|
|
let (dst_bucket, dst_key) = self.parse_path(to)?;
|
|
|
|
if src_bucket.is_empty() || dst_bucket.is_empty() {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let src_key = src_key.ok_or(FsError::Forbidden)?;
|
|
let dst_key = dst_key.ok_or(FsError::Forbidden)?;
|
|
let (access_key, secret_key) = self.credentials();
|
|
let resolved_src = self.resolve_path(&src_bucket, &src_key).await?;
|
|
let (src_prefix, include_src_marker) = match resolved_src {
|
|
ResolvedPath::File(_) => {
|
|
authorize_operation(&self.session_context, &S3Action::GetObject, &src_bucket, Some(&src_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::PutObject, &dst_bucket, Some(&dst_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::DeleteObject, &src_bucket, Some(&src_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
self.copy_object_streaming(&src_bucket, &src_key, &dst_bucket, &dst_key)
|
|
.await?;
|
|
|
|
self.storage
|
|
.delete_object(&src_bucket, &src_key, access_key, secret_key)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_RENAME_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "source_delete_failed",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
error = %e,
|
|
"WebDAV rename source delete failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
debug!(
|
|
event = EVENT_WEBDAV_RENAME_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "file_renamed",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
"WebDAV file renamed"
|
|
);
|
|
return Ok(());
|
|
}
|
|
ResolvedPath::Directory { prefix, .. } => {
|
|
let include_src_marker =
|
|
matches!(self.probe_head_object(&src_bucket, &src_key).await?, HeadObjectProbe::Found(_));
|
|
(prefix, include_src_marker)
|
|
}
|
|
};
|
|
let dst_prefix = format!("{}/", dst_key);
|
|
|
|
authorize_operation(&self.session_context, &S3Action::ListBucket, &src_bucket, Some(&src_prefix))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
let mut continuation_token: Option<String> = None;
|
|
let mut renamed_any = false;
|
|
|
|
if include_src_marker {
|
|
authorize_operation(&self.session_context, &S3Action::GetObject, &src_bucket, Some(&src_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::PutObject, &dst_bucket, Some(&dst_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::DeleteObject, &src_bucket, Some(&src_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
|
|
self.execute_directory_rename_pairs(&src_bucket, &dst_bucket, &[(src_key.clone(), dst_key.clone())])
|
|
.await?;
|
|
renamed_any = true;
|
|
}
|
|
|
|
loop {
|
|
let mut list_builder = ListObjectsV2Input::builder()
|
|
.bucket(src_bucket.clone())
|
|
.prefix(Some(src_prefix.clone()));
|
|
|
|
if let Some(ref token) = continuation_token {
|
|
list_builder = list_builder.continuation_token(Some(token.clone()));
|
|
}
|
|
|
|
let list_input = list_builder.build().map_err(|_| FsError::GeneralFailure)?;
|
|
let output = self
|
|
.storage
|
|
.list_objects_v2(list_input, access_key, secret_key)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(
|
|
event = EVENT_WEBDAV_RENAME_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "directory_list_failed",
|
|
src_bucket = %src_bucket,
|
|
src_prefix = %src_prefix,
|
|
dst_bucket = %dst_bucket,
|
|
dst_prefix = %dst_prefix,
|
|
error = %e,
|
|
"WebDAV rename directory listing failed"
|
|
);
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
let mut page_pairs: Vec<(String, String)> = Vec::new();
|
|
if let Some(objects) = output.contents {
|
|
for obj in objects {
|
|
if let Some(obj_key) = obj.key {
|
|
let new_key = obj_key.replacen(&src_prefix, &dst_prefix, 1);
|
|
page_pairs.push((obj_key, new_key));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !page_pairs.is_empty() {
|
|
for (src_obj_key, dst_obj_key) in &page_pairs {
|
|
authorize_operation(&self.session_context, &S3Action::GetObject, &src_bucket, Some(src_obj_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::PutObject, &dst_bucket, Some(dst_obj_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
authorize_operation(&self.session_context, &S3Action::DeleteObject, &src_bucket, Some(src_obj_key))
|
|
.await
|
|
.map_err(|_| FsError::Forbidden)?;
|
|
}
|
|
|
|
self.execute_directory_rename_pairs(&src_bucket, &dst_bucket, &page_pairs)
|
|
.await?;
|
|
renamed_any = true;
|
|
}
|
|
|
|
if !output.is_truncated.unwrap_or(false) {
|
|
break;
|
|
}
|
|
continuation_token = output.next_continuation_token;
|
|
}
|
|
|
|
if !renamed_any {
|
|
debug!(
|
|
event = EVENT_WEBDAV_RENAME_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
result = "source_not_found",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
"WebDAV rename source not found"
|
|
);
|
|
return Err(FsError::NotFound);
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_WEBDAV_RENAME_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
|
state = "directory_renamed",
|
|
src_bucket = %src_bucket,
|
|
src_object = %src_key,
|
|
dst_bucket = %dst_bucket,
|
|
dst_object = %dst_key,
|
|
"WebDAV directory renamed"
|
|
);
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn copy<'a>(&'a self, _from: &'a DavPath, _to: &'a DavPath) -> FsFuture<'a, ()> {
|
|
// Could implement using S3 CopyObject, but not required for basic WebDAV
|
|
async move { Err(FsError::NotImplemented) }.boxed()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::WebDavDriver;
|
|
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
|
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
|
|
use async_trait::async_trait;
|
|
use bytes::Bytes;
|
|
use dav_server::davpath::DavPath;
|
|
use dav_server::fs::FsError;
|
|
use futures_util::StreamExt;
|
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
|
use rustfs_credentials::Credentials;
|
|
use rustfs_policy::auth::UserIdentity;
|
|
use s3s::dto::*;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt::{Debug, Formatter};
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[derive(Clone)]
|
|
struct DummyStorage;
|
|
|
|
impl Debug for DummyStorage {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("DummyStorage")
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl S3StorageBackend for DummyStorage {
|
|
type Error = std::io::Error;
|
|
|
|
async fn get_object(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
_start_pos: Option<u64>,
|
|
) -> Result<GetObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn get_object_range(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
_start_pos: u64,
|
|
_length: u64,
|
|
) -> Result<GetObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn put_object(
|
|
&self,
|
|
_input: PutObjectInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<PutObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn delete_object(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<DeleteObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn head_object(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<HeadObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn head_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<HeadBucketOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn list_objects_v2(
|
|
&self,
|
|
_input: ListObjectsV2Input,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<ListObjectsV2Output, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn list_buckets(&self, _access_key: &str, _secret_key: &str) -> Result<ListBucketsOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn create_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CreateBucketOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn delete_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<DeleteBucketOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn copy_object(
|
|
&self,
|
|
_input: CopyObjectInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CopyObjectOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn create_multipart_upload(
|
|
&self,
|
|
_input: CreateMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CreateMultipartUploadOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn upload_part(
|
|
&self,
|
|
_input: UploadPartInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<UploadPartOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn complete_multipart_upload(
|
|
&self,
|
|
_input: CompleteMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn abort_multipart_upload(
|
|
&self,
|
|
_input: AbortMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<AbortMultipartUploadOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
|
|
async fn upload_part_copy(
|
|
&self,
|
|
_input: UploadPartCopyInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<UploadPartCopyOutput, Self::Error> {
|
|
unreachable!("parse_path tests should not hit storage")
|
|
}
|
|
}
|
|
|
|
fn driver() -> WebDavDriver<DummyStorage> {
|
|
let identity = UserIdentity::new(Credentials {
|
|
access_key: "ak".to_string(),
|
|
secret_key: "sk".to_string(),
|
|
..Default::default()
|
|
});
|
|
let session_context = SessionContext::new(
|
|
ProtocolPrincipal::new(Arc::new(identity)),
|
|
Protocol::WebDav,
|
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
|
);
|
|
|
|
WebDavDriver::new(DummyStorage, Arc::new(session_context))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingStorageState {
|
|
objects: HashMap<(String, String), Vec<u8>>,
|
|
put_keys: Vec<String>,
|
|
delete_keys: Vec<String>,
|
|
fail_delete_keys: HashSet<String>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct RecordingStorage {
|
|
state: Arc<Mutex<RecordingStorageState>>,
|
|
}
|
|
|
|
impl Debug for RecordingStorage {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("RecordingStorage")
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl S3StorageBackend for RecordingStorage {
|
|
type Error = std::io::Error;
|
|
|
|
async fn get_object(
|
|
&self,
|
|
bucket: &str,
|
|
key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
_start_pos: Option<u64>,
|
|
) -> Result<GetObjectOutput, Self::Error> {
|
|
let data = self
|
|
.state
|
|
.lock()
|
|
.expect("recording storage lock poisoned")
|
|
.objects
|
|
.get(&(bucket.to_string(), key.to_string()))
|
|
.cloned()
|
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing object"))?;
|
|
|
|
let content_length = data.len() as i64;
|
|
let body =
|
|
StreamingBlob::wrap(futures_util::stream::once(async move { Ok::<Bytes, std::io::Error>(Bytes::from(data)) }));
|
|
|
|
Ok(GetObjectOutput {
|
|
body: Some(body),
|
|
content_length: Some(content_length),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
async fn get_object_range(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
_start_pos: u64,
|
|
_length: u64,
|
|
) -> Result<GetObjectOutput, Self::Error> {
|
|
unreachable!("range reads are not used in rename regression tests")
|
|
}
|
|
|
|
async fn put_object(
|
|
&self,
|
|
mut input: PutObjectInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<PutObjectOutput, Self::Error> {
|
|
let bucket = input.bucket.clone();
|
|
let key = input.key.clone();
|
|
let mut bytes = Vec::new();
|
|
|
|
if let Some(mut body) = input.body.take() {
|
|
while let Some(chunk) = body.next().await {
|
|
let chunk = chunk.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
}
|
|
|
|
let mut state = self.state.lock().expect("recording storage lock poisoned");
|
|
state.put_keys.push(key.clone());
|
|
state.objects.insert((bucket, key), bytes);
|
|
|
|
Ok(PutObjectOutput::default())
|
|
}
|
|
|
|
async fn delete_object(
|
|
&self,
|
|
bucket: &str,
|
|
key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<DeleteObjectOutput, Self::Error> {
|
|
let mut state = self.state.lock().expect("recording storage lock poisoned");
|
|
state.delete_keys.push(key.to_string());
|
|
if state.fail_delete_keys.contains(key) {
|
|
return Err(std::io::Error::other("injected delete failure"));
|
|
}
|
|
state.objects.remove(&(bucket.to_string(), key.to_string()));
|
|
Ok(DeleteObjectOutput::default())
|
|
}
|
|
|
|
async fn head_object(
|
|
&self,
|
|
_bucket: &str,
|
|
_key: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<HeadObjectOutput, Self::Error> {
|
|
unreachable!("head_object is not used in rename regression tests")
|
|
}
|
|
|
|
async fn head_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<HeadBucketOutput, Self::Error> {
|
|
unreachable!("head_bucket is not used in rename regression tests")
|
|
}
|
|
|
|
async fn list_objects_v2(
|
|
&self,
|
|
_input: ListObjectsV2Input,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<ListObjectsV2Output, Self::Error> {
|
|
unreachable!("list_objects_v2 is not used in rename regression tests")
|
|
}
|
|
|
|
async fn list_buckets(&self, _access_key: &str, _secret_key: &str) -> Result<ListBucketsOutput, Self::Error> {
|
|
unreachable!("list_buckets is not used in rename regression tests")
|
|
}
|
|
|
|
async fn create_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CreateBucketOutput, Self::Error> {
|
|
unreachable!("create_bucket is not used in rename regression tests")
|
|
}
|
|
|
|
async fn delete_bucket(
|
|
&self,
|
|
_bucket: &str,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<DeleteBucketOutput, Self::Error> {
|
|
unreachable!("delete_bucket is not used in rename regression tests")
|
|
}
|
|
|
|
async fn copy_object(
|
|
&self,
|
|
_input: CopyObjectInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CopyObjectOutput, Self::Error> {
|
|
unreachable!("copy_object is not used in rename regression tests")
|
|
}
|
|
|
|
async fn create_multipart_upload(
|
|
&self,
|
|
_input: CreateMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CreateMultipartUploadOutput, Self::Error> {
|
|
unreachable!("create_multipart_upload is not used in rename regression tests")
|
|
}
|
|
|
|
async fn upload_part(
|
|
&self,
|
|
_input: UploadPartInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<UploadPartOutput, Self::Error> {
|
|
unreachable!("upload_part is not used in rename regression tests")
|
|
}
|
|
|
|
async fn complete_multipart_upload(
|
|
&self,
|
|
_input: CompleteMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<CompleteMultipartUploadOutput, Self::Error> {
|
|
unreachable!("complete_multipart_upload is not used in rename regression tests")
|
|
}
|
|
|
|
async fn abort_multipart_upload(
|
|
&self,
|
|
_input: AbortMultipartUploadInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<AbortMultipartUploadOutput, Self::Error> {
|
|
unreachable!("abort_multipart_upload is not used in rename regression tests")
|
|
}
|
|
|
|
async fn upload_part_copy(
|
|
&self,
|
|
_input: UploadPartCopyInput,
|
|
_access_key: &str,
|
|
_secret_key: &str,
|
|
) -> Result<UploadPartCopyOutput, Self::Error> {
|
|
unreachable!("upload_part_copy is not used in rename regression tests")
|
|
}
|
|
}
|
|
|
|
fn recording_driver(
|
|
initial_objects: &[(&str, &str, &[u8])],
|
|
fail_delete_keys: &[&str],
|
|
) -> (WebDavDriver<RecordingStorage>, RecordingStorage) {
|
|
let storage = RecordingStorage::default();
|
|
let identity = UserIdentity::new(Credentials {
|
|
access_key: "ak".to_string(),
|
|
secret_key: "sk".to_string(),
|
|
..Default::default()
|
|
});
|
|
let session_context = SessionContext::new(
|
|
ProtocolPrincipal::new(Arc::new(identity)),
|
|
Protocol::WebDav,
|
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
|
);
|
|
|
|
let state = RecordingStorageState {
|
|
objects: initial_objects
|
|
.iter()
|
|
.map(|(bucket, key, body)| (((*bucket).to_string(), (*key).to_string()), body.to_vec()))
|
|
.collect(),
|
|
fail_delete_keys: fail_delete_keys.iter().map(|key| (*key).to_string()).collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
*storage.state.lock().expect("recording storage lock poisoned") = state;
|
|
|
|
(WebDavDriver::new(storage.clone(), Arc::new(session_context)), storage)
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_decodes_url_encoded_object_names() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/%E6%96%87%E4%BB%B6%20name.txt").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert_eq!(key.as_deref(), Some("文件 name.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_rejects_invalid_utf8_percent_encoding() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/%FFreport.txt").expect("path should parse");
|
|
|
|
let err = driver.parse_path(&path).expect_err("invalid utf8 should be rejected");
|
|
|
|
assert_eq!(err, FsError::GeneralFailure);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_handles_directory_paths_with_trailing_slash() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/folder/").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert_eq!(key.as_deref(), Some("folder"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_handles_chinese_directory_names() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/%E6%96%B0%E5%BB%BA%E6%96%87%E4%BB%B6%E5%A4%B9%20(4)").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert_eq!(key.as_deref(), Some("新建文件夹 (4)"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_handles_nested_paths() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/dir/subdir/file.txt").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert_eq!(key.as_deref(), Some("dir/subdir/file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_returns_none_key_for_bucket_root() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert!(key.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_handles_url_encoded_spaces_in_object_name() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/file%20with%20spaces.txt").expect("path should parse");
|
|
|
|
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
|
|
|
assert_eq!(bucket, "bucket");
|
|
assert_eq!(key.as_deref(), Some("file with spaces.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_rejects_control_bytes_after_decode() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/report%0Aname.txt").expect("path should parse");
|
|
|
|
let err = driver.parse_path(&path).expect_err("control bytes should be rejected");
|
|
|
|
assert_eq!(err, FsError::GeneralFailure);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_rejects_internal_directory_marker() {
|
|
let driver = driver();
|
|
let path = DavPath::new("/bucket/__XLDIR__").expect("path should parse");
|
|
|
|
let err = driver
|
|
.parse_path(&path)
|
|
.expect_err("internal directory marker should be rejected");
|
|
|
|
assert_eq!(err, FsError::GeneralFailure);
|
|
}
|
|
|
|
proptest::proptest! {
|
|
#[test]
|
|
fn parse_path_never_leaks_control_bytes_or_traversal_in_ok_output(
|
|
input in proptest::prelude::any::<String>(),
|
|
) {
|
|
let driver = driver();
|
|
let encoded = utf8_percent_encode(&input, NON_ALPHANUMERIC).to_string();
|
|
let Ok(path) = DavPath::new(&format!("/{encoded}")) else {
|
|
return Ok(());
|
|
};
|
|
|
|
match driver.parse_path(&path) {
|
|
Err(err) => {
|
|
proptest::prop_assert_eq!(err, FsError::GeneralFailure);
|
|
}
|
|
Ok((bucket, key)) => {
|
|
proptest::prop_assert!(!bucket.contains('/'));
|
|
proptest::prop_assert!(!bucket.chars().any(char::is_control));
|
|
|
|
if let Some(k) = key.as_deref() {
|
|
proptest::prop_assert!(!k.chars().any(char::is_control));
|
|
proptest::prop_assert!(!k.starts_with('/'));
|
|
proptest::prop_assert!(!k.split('/').any(|segment| segment == ".."));
|
|
proptest::prop_assert!(!k.contains(rustfs_utils::path::GLOBAL_DIR_SUFFIX));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn directory_rename_returns_error_when_delete_fails_after_successful_copy() {
|
|
let (driver, storage) = recording_driver(
|
|
&[
|
|
("bucket", "src/file-a.txt", b"file-a"),
|
|
("bucket", "src/file-b.txt", b"file-b"),
|
|
],
|
|
&["src/file-a.txt"],
|
|
);
|
|
|
|
let err = driver
|
|
.execute_directory_rename_pairs(
|
|
"bucket",
|
|
"bucket",
|
|
&[
|
|
("src/file-a.txt".to_string(), "dst/file-a.txt".to_string()),
|
|
("src/file-b.txt".to_string(), "dst/file-b.txt".to_string()),
|
|
],
|
|
)
|
|
.await
|
|
.expect_err("delete failure should be surfaced");
|
|
|
|
assert_eq!(err, FsError::GeneralFailure);
|
|
|
|
let state = storage.state.lock().expect("recording storage lock poisoned");
|
|
assert_eq!(state.put_keys, vec!["dst/file-a.txt".to_string(), "dst/file-b.txt".to_string()]);
|
|
assert_eq!(state.delete_keys, vec!["src/file-a.txt".to_string()]);
|
|
assert!(
|
|
state
|
|
.objects
|
|
.contains_key(&("bucket".to_string(), "dst/file-a.txt".to_string()))
|
|
);
|
|
assert!(
|
|
state
|
|
.objects
|
|
.contains_key(&("bucket".to_string(), "dst/file-b.txt".to_string()))
|
|
);
|
|
}
|
|
}
|