Files
rustfs/crates/protocols/src/swift/object.rs
T

1430 lines
52 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.
//! Swift object operations
//!
//! This module implements Swift object CRUD operations including upload, download,
//! metadata management, server-side copy, and HTTP range requests.
//!
//! ## Server-Side Copy
//!
//! Swift supports two methods for server-side object copying:
//!
//! 1. **COPY method with Destination header**:
//! ```text
//! COPY /v1/AUTH_account/container/source_object
//! Destination: /container/dest_object
//! X-Auth-Token: token
//! ```
//!
//! 2. **PUT method with X-Copy-From header**:
//! ```text
//! PUT /v1/AUTH_account/container/dest_object
//! X-Copy-From: /container/source_object
//! X-Auth-Token: token
//! ```
//!
//! The `copy_object` function implements the core copy logic. Handler integration
//! requires passing request headers to identify copy operations.
//!
//! ## Range Requests
//!
//! HTTP Range requests allow partial object downloads:
//!
//! - `bytes=0-1023` - First 1024 bytes
//! - `bytes=1000-` - From byte 1000 to end
//! - `bytes=-500` - Last 500 bytes
//!
//! The `parse_range_header` function parses Range headers, and `get_object` accepts
//! an optional range parameter. See RANGE_REQUESTS.md for details.
use super::account::validate_account_access;
use super::container::ContainerMapper;
use super::storage_api::{BucketOperations, BucketOptions, HTTPRangeSpec, ObjectIO as _, ObjectOperations as _};
use super::{SwiftError, SwiftResult, resolve_swift_object_store_handle};
use axum::http::HeaderMap;
use rustfs_credentials::Credentials;
use rustfs_rio::HashReader;
use std::collections::HashMap;
use tracing::debug;
use tracing::error;
pub use super::{SwiftGetObjectReader, SwiftObjectInfo, SwiftObjectOptions, SwiftPutObjReader};
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
const LOG_SUBSYSTEM_SWIFT_OBJECT: &str = "swift_object";
const EVENT_SWIFT_OBJECT_STORAGE_STATE: &str = "swift_object_storage_state";
/// Maximum number of metadata headers allowed per object (Swift standard)
const MAX_METADATA_COUNT: usize = 90;
/// Maximum size in bytes for a single metadata value (Swift standard)
const MAX_METADATA_VALUE_SIZE: usize = 256;
/// Maximum object size in bytes (5GB - Swift default)
const MAX_OBJECT_SIZE: i64 = 5 * 1024 * 1024 * 1024;
/// Object key translator for Swift object names
///
/// Handles URL encoding/decoding and path normalization for Swift object keys.
/// Swift object names can contain any UTF-8 characters except null bytes.
#[allow(dead_code)] // Used in: object operations
pub struct ObjectKeyMapper;
impl ObjectKeyMapper {
/// Create a new object key mapper
#[allow(dead_code)] // Used in: object operations
pub fn new() -> Self {
Self
}
/// Validate Swift object name
///
/// Object names must:
/// - Not be empty
/// - Not exceed 1024 bytes (UTF-8 encoded)
/// - Not contain null bytes
/// - Not contain '..' path segments (directory traversal)
/// - Not start with '/' (leading slash handled by routing)
#[allow(dead_code)] // Used in: object operations
pub fn validate_object_name(object: &str) -> SwiftResult<()> {
if object.is_empty() {
return Err(SwiftError::BadRequest("Object name cannot be empty".to_string()));
}
if object.len() > 1024 {
return Err(SwiftError::BadRequest("Object name too long (max 1024 bytes)".to_string()));
}
if object.contains('\0') {
return Err(SwiftError::BadRequest("Object name cannot contain null bytes".to_string()));
}
if object.chars().any(char::is_control) {
return Err(SwiftError::BadRequest("Object name cannot contain control characters".to_string()));
}
if object.starts_with('/') {
return Err(SwiftError::BadRequest("Object name cannot start with '/'".to_string()));
}
// Check for directory traversal attempts
if object.contains("..") {
// Allow ".." as part of a filename, but not as a path segment
for segment in object.split('/') {
if segment == ".." {
return Err(SwiftError::BadRequest("Object name cannot contain '..' path segments".to_string()));
}
}
}
Ok(())
}
/// Convert Swift object name to S3 object key
///
/// Swift object names are URL-decoded when received in the URL path,
/// then stored as-is in S3. Special characters are preserved.
///
/// Example:
/// - Swift: "photos/vacation/beach photo.jpg"
/// - S3: "photos/vacation/beach photo.jpg"
#[allow(dead_code)] // Used in: object operations
pub fn swift_to_s3_key(object: &str) -> SwiftResult<String> {
Self::validate_object_name(object)?;
Ok(object.to_string())
}
/// Convert S3 object key to Swift object name
///
/// This is essentially an identity transformation since we store
/// Swift object names as-is in S3.
#[allow(dead_code)] // Used in: object operations
pub fn s3_to_swift_name(key: &str) -> String {
key.to_string()
}
/// Build full S3 object key from container and object
///
/// Combines container name (with tenant prefix) and object name.
/// The container is already validated and includes tenant prefix.
///
/// Example:
/// - Container: "abc123:photos"
/// - Object: "vacation/beach.jpg"
/// - Bucket: "abc123:photos"
/// - Key: "vacation/beach.jpg"
#[allow(dead_code)] // Used in: object operations
pub fn build_s3_key(object: &str) -> SwiftResult<String> {
Self::swift_to_s3_key(object)
}
/// Extract object name from URL path
///
/// The object name comes from the URL path and may be percent-encoded.
/// This function handles URL decoding while preserving special characters.
///
/// Example URL: /v1/AUTH_abc/container/path%2Fto%2Ffile.txt
/// Decoded: "path/to/file.txt"
#[allow(dead_code)] // Used in: object operations
pub fn decode_object_from_url(encoded: &str) -> SwiftResult<String> {
// Decode percent-encoding
let decoded = urlencoding::decode(encoded).map_err(|e| SwiftError::BadRequest(format!("Invalid URL encoding: {}", e)))?;
Self::validate_object_name(&decoded)?;
Ok(decoded.to_string())
}
/// Encode object name for URL
///
/// When constructing URLs (e.g., for redirect responses), we need to
/// percent-encode object names.
#[allow(dead_code)] // Used in: object operations
pub fn encode_object_for_url(object: &str) -> String {
urlencoding::encode(object).to_string()
}
/// Check if object name represents a directory (pseudo-directory)
///
/// In Swift, objects ending with '/' are treated as directory markers.
#[allow(dead_code)] // Used in: object operations
pub fn is_directory_marker(object: &str) -> bool {
object.ends_with('/')
}
/// Normalize object path
///
/// Removes redundant slashes and normalizes the path while preserving
/// trailing slashes for directory markers.
#[allow(dead_code)] // Used in: object operations
pub fn normalize_path(object: &str) -> String {
// Split by '/', filter out empty segments (except if it's the end)
let segments: Vec<&str> = object.split('/').collect();
let has_trailing_slash = object.ends_with('/');
let normalized_segments: Vec<&str> = segments.into_iter().filter(|s| !s.is_empty()).collect();
let mut result = normalized_segments.join("/");
// Preserve trailing slash for directory markers
if has_trailing_slash && !result.is_empty() {
result.push('/');
}
result
}
}
impl Default for ObjectKeyMapper {
fn default() -> Self {
Self::new()
}
}
/// Validate metadata against Swift limits
///
/// Checks that:
/// - Total number of metadata entries doesn't exceed MAX_METADATA_COUNT
/// - Individual metadata values don't exceed MAX_METADATA_VALUE_SIZE
///
/// Returns error if limits are exceeded.
fn validate_metadata(metadata: &HashMap<String, String>) -> SwiftResult<()> {
// Check total metadata count
if metadata.len() > MAX_METADATA_COUNT {
return Err(SwiftError::BadRequest(format!(
"Too many metadata headers: {} (max: {})",
metadata.len(),
MAX_METADATA_COUNT
)));
}
// Check individual value sizes
for (key, value) in metadata.iter() {
if value.len() > MAX_METADATA_VALUE_SIZE {
return Err(SwiftError::BadRequest(format!(
"Metadata value for '{}' too large: {} bytes (max: {} bytes)",
key,
value.len(),
MAX_METADATA_VALUE_SIZE
)));
}
}
Ok(())
}
/// Sanitize storage layer errors for client responses
///
/// Logs detailed error server-side while returning generic message to client.
/// This prevents information disclosure vulnerabilities.
fn sanitize_storage_error<E: std::fmt::Display>(operation: &str, error: E) -> SwiftError {
// Log detailed error server-side
error!(
event = EVENT_SWIFT_OBJECT_STORAGE_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SWIFT_OBJECT,
operation = %operation,
error = %error,
result = "failed",
"swift object storage state changed"
);
// Return generic error to client
SwiftError::InternalServerError(format!("{} operation failed", operation))
}
/// Upload an object to Swift storage (PUT)
///
/// Maps Swift container/object to S3 bucket/key and stores the object.
/// Extracts metadata from X-Object-Meta-* headers and returns ETag.
///
/// # Arguments
/// * `account` - Swift account name (AUTH_{project_id})
/// * `container` - Container name
/// * `object` - Object name
/// * `credentials` - User credentials with project_id
/// * `reader` - Object content reader (implements AsyncRead)
/// * `headers` - HTTP headers including metadata
///
/// # Returns
/// * `Ok(etag)` - Object ETag on success
/// * `Err(SwiftError)` - Error if validation fails or upload fails
#[allow(dead_code)] // Handler integration: PUT object
pub async fn put_object<R>(
account: &str,
container: &str,
object: &str,
credentials: &Credentials,
reader: R,
headers: &HeaderMap,
) -> SwiftResult<String>
where
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
{
// 1. Validate account access and get project_id
let project_id = validate_account_access(account, credentials)?;
// 2. Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// 3. Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// 4. Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Extract Swift metadata from X-Object-Meta-* headers
let mut user_metadata = HashMap::new();
for (header_name, header_value) in headers.iter() {
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
&& let Ok(value_str) = header_value.to_str()
{
user_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
// 6. Extract Content-Type if provided
if let Some(content_type) = headers.get("content-type")
&& let Ok(ct_str) = content_type.to_str()
{
user_metadata.insert("content-type".to_string(), ct_str.to_string());
}
// 7. Extract and validate expiration headers (X-Delete-At / X-Delete-After)
if let Some(delete_at) = super::expiration::extract_expiration(headers)? {
super::expiration::validate_expiration(delete_at)?;
user_metadata.insert("x-delete-at".to_string(), delete_at.to_string());
}
// 8. Extract symlink target if creating a symlink
if let Some(symlink_target) = super::symlink::extract_symlink_target(headers)? {
// Store the fully qualified target (container/object)
let target_value = symlink_target.to_header_value(container);
user_metadata.insert("x-object-symlink-target".to_string(), target_value);
debug!(
event = EVENT_SWIFT_OBJECT_STORAGE_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SWIFT_OBJECT,
state = "symlink_target_recorded",
target = %user_metadata.get("x-object-symlink-target").unwrap(),
"swift object storage state changed"
);
}
// 9. Validate metadata limits
validate_metadata(&user_metadata)?;
// 10. Get content length from headers (-1 if not provided)
let content_length = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(-1);
// 11. Validate content length doesn't exceed maximum
if content_length > MAX_OBJECT_SIZE {
return Err(SwiftError::BadRequest(format!(
"Object size {} bytes exceeds maximum of {} bytes",
content_length, MAX_OBJECT_SIZE
)));
}
// 12. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 13. Verify bucket/container exists
store.get_bucket_info(&bucket, &BucketOptions::default()).await.map_err(|e| {
if e.to_string().contains("does not exist") {
SwiftError::NotFound(format!("Container '{}' not found", container))
} else {
sanitize_storage_error("Container verification", e)
}
})?;
// 12. Prepare object options with metadata
let opts = SwiftObjectOptions {
user_defined: user_metadata,
..Default::default()
};
// 13. Wrap reader in buffered reader for streaming hash validation
let buf_reader = tokio::io::BufReader::new(reader);
// 14. Create HashReader (no MD5/SHA256 validation for Swift)
let hash_reader = HashReader::from_stream(buf_reader, content_length, content_length, None, None, false)
.map_err(|e| sanitize_storage_error("Hash reader creation", e))?;
// 15. Wrap in PutObjReader as expected by storage layer
let mut put_reader = SwiftPutObjReader::new(hash_reader);
// 16. Upload object to storage
let obj_info = store
.put_object(&bucket, &s3_key, &mut put_reader, &opts)
.await
.map_err(|e| sanitize_storage_error("Object upload", e))?;
// 17. Return ETag (MD5 hash in hex format)
Ok(obj_info.etag.unwrap_or_default())
}
/// Upload an object with custom metadata (for SLO/DLO internal use)
///
/// Similar to put_object, but allows directly specifying metadata instead of extracting from headers.
/// This is used internally for storing SLO manifests and marker objects.
#[allow(dead_code)] // Used by SLO implementation
pub async fn put_object_with_metadata<R>(
account: &str,
container: &str,
object: &str,
credentials: &Option<Credentials>,
reader: R,
metadata: &HashMap<String, String>,
) -> SwiftResult<String>
where
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
{
// If credentials are provided, validate access
let project_id = if let Some(creds) = credentials {
validate_account_access(account, creds)?
} else {
// For testing/internal use, extract project_id from account
account
.strip_prefix("AUTH_")
.ok_or_else(|| SwiftError::Unauthorized("Invalid account format".to_string()))?
.to_string()
};
// Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// Validate metadata limits
validate_metadata(metadata)?;
// Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// Verify bucket/container exists
store.get_bucket_info(&bucket, &BucketOptions::default()).await.map_err(|e| {
if e.to_string().contains("does not exist") {
SwiftError::NotFound(format!("Container '{}' not found", container))
} else {
sanitize_storage_error("Container verification", e)
}
})?;
// Prepare object options with metadata
let opts = SwiftObjectOptions {
user_defined: metadata.clone(),
..Default::default()
};
// Content length (use -1 for unknown)
let content_length = -1i64;
// Wrap reader in buffered reader for streaming hash validation
let buf_reader = tokio::io::BufReader::new(reader);
// Create HashReader
let hash_reader = HashReader::from_stream(buf_reader, content_length, content_length, None, None, false)
.map_err(|e| sanitize_storage_error("Hash reader creation", e))?;
// Wrap in PutObjReader
let mut put_reader = SwiftPutObjReader::new(hash_reader);
// Upload object to storage
let obj_info = store
.put_object(&bucket, &s3_key, &mut put_reader, &opts)
.await
.map_err(|e| sanitize_storage_error("Object upload", e))?;
// Return ETag
Ok(obj_info.etag.unwrap_or_default())
}
/// Download an object from Swift storage (GET)
///
/// Maps Swift container/object to S3 bucket/key and retrieves the object content.
/// Returns the object stream and metadata. Supports HTTP Range requests.
///
/// # Arguments
/// * `account` - Swift account name (AUTH_{project_id})
/// * `container` - Container name
/// * `object` - Object name
/// * `credentials` - User credentials with project_id
/// * `range` - Optional HTTP Range specification (bytes=start-end)
///
/// # Returns
/// * `Ok((stream, object_info))` - Object content stream and metadata
/// * `Err(SwiftError)` - Error if validation fails or object not found
///
/// # Range Requests
/// Swift supports standard HTTP Range requests:
/// - `bytes=0-999` - First 1000 bytes
/// - `bytes=1000-1999` - Bytes 1000-1999
/// - `bytes=1000-` - From byte 1000 to end
/// - `bytes=-500` - Last 500 bytes
#[allow(dead_code)] // Handler integration: GET object
pub async fn get_object(
account: &str,
container: &str,
object: &str,
credentials: &Credentials,
range: Option<HTTPRangeSpec>,
) -> SwiftResult<SwiftGetObjectReader> {
// 1. Validate account access and get project_id
let project_id = validate_account_access(account, credentials)?;
// 2. Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// 3. Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// 4. Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 6. Prepare object options
let opts = SwiftObjectOptions::default();
// 7. Get object reader from storage with range support
let reader: SwiftGetObjectReader = store
.get_object_reader(&bucket, &s3_key, range, HeaderMap::new(), &opts)
.await
.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("does not exist") || err_str.contains("not found") {
SwiftError::NotFound(format!("Object '{}' not found in container '{}'", object, container))
} else {
sanitize_storage_error("Object read", e)
}
})?;
Ok(reader)
}
/// Get object metadata without content (HEAD)
///
/// Maps Swift container/object to S3 bucket/key and retrieves only the metadata.
/// This is more efficient than GET when only metadata is needed.
///
/// # Arguments
/// * `account` - Swift account name (AUTH_{project_id})
/// * `container` - Container name
/// * `object` - Object name
/// * `credentials` - User credentials with project_id
///
/// # Returns
/// * `Ok(object_info)` - Object metadata (ObjectInfo)
/// * `Err(SwiftError)` - Error if validation fails or object not found
#[allow(dead_code)] // Handler integration: HEAD object
pub async fn head_object(
account: &str,
container: &str,
object: &str,
credentials: &Credentials,
) -> SwiftResult<SwiftObjectInfo> {
// 1. Validate account access and get project_id
let project_id = validate_account_access(account, credentials)?;
// 2. Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// 3. Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// 4. Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 6. Prepare object options
let opts = SwiftObjectOptions::default();
// 7. Get object info (metadata only) from storage
let info: SwiftObjectInfo = store.get_object_info(&bucket, &s3_key, &opts).await.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("does not exist") || err_str.contains("not found") {
SwiftError::NotFound(format!("Object '{}' not found in container '{}'", object, container))
} else {
sanitize_storage_error("Object metadata retrieval", e)
}
})?;
// 8. Check if this is a delete marker
if info.delete_marker {
return Err(SwiftError::NotFound(format!(
"Object '{}' not found in container '{}'",
object, container
)));
}
Ok(info)
}
/// Delete an object from Swift storage (DELETE)
///
/// Maps Swift container/object to S3 bucket/key and deletes the object.
/// Swift DELETE is idempotent - deleting a non-existent object returns success.
///
/// # Arguments
/// * `account` - Swift account name (AUTH_{project_id})
/// * `container` - Container name
/// * `object` - Object name
/// * `credentials` - User credentials with project_id
///
/// # Returns
/// * `Ok(())` - Object deleted successfully (or didn't exist)
/// * `Err(SwiftError)` - Error if validation fails or deletion fails
#[allow(dead_code)] // Handler integration: DELETE object
pub async fn delete_object(account: &str, container: &str, object: &str, credentials: &Credentials) -> SwiftResult<()> {
// 1. Validate account access and get project_id
let project_id = validate_account_access(account, credentials)?;
// 2. Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// 3. Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// 4. Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 6. Prepare object options for deletion
let opts = SwiftObjectOptions::default();
// 7. Delete object from storage
// Swift DELETE is idempotent - returns success even if object doesn't exist
match store.delete_object(&bucket, &s3_key, opts).await {
Ok(_) => Ok(()),
Err(e) => {
let err_str = e.to_string();
// Only fail if the container (bucket) doesn't exist
if err_str.contains("Bucket not found") {
Err(SwiftError::NotFound(format!("Container '{}' not found", container)))
} else if err_str.contains("Object not found") || err_str.contains("does not exist") {
// Object already gone - this is success for idempotent DELETE
Ok(())
} else {
Err(sanitize_storage_error("Object deletion", e))
}
}
}
}
/// Update object metadata (POST)
///
/// Updates user-defined metadata (X-Object-Meta-*) for an existing object
/// without changing the object content. This is a Swift-specific operation.
///
/// # Arguments
/// * `account` - Swift account name (AUTH_{project_id})
/// * `container` - Container name
/// * `object` - Object name
/// * `credentials` - User credentials with project_id
/// * `headers` - HTTP headers containing X-Object-Meta-* headers to update
///
/// # Returns
/// * `Ok(())` - Metadata updated successfully
/// * `Err(SwiftError)` - Error if validation fails, object not found, or update fails
#[allow(dead_code)] // Handler integration: POST object
pub async fn update_object_metadata(
account: &str,
container: &str,
object: &str,
credentials: &Credentials,
headers: &HeaderMap,
) -> SwiftResult<()> {
// 1. Validate account access and get project_id
let project_id = validate_account_access(account, credentials)?;
// 2. Validate object name
ObjectKeyMapper::validate_object_name(object)?;
// 3. Get S3 key from object name
let s3_key = ObjectKeyMapper::swift_to_s3_key(object)?;
// 4. Map container to bucket using tenant prefixing
let mapper = ContainerMapper::default();
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 6. First, get the existing object info to verify it exists
let opts = SwiftObjectOptions::default();
let existing_info = store.get_object_info(&bucket, &s3_key, &opts).await.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("does not exist") || err_str.contains("not found") {
SwiftError::NotFound(format!("Object '{}' not found in container '{}'", object, container))
} else {
sanitize_storage_error("Object info retrieval", e)
}
})?;
// 7. Check if this is a delete marker
if existing_info.delete_marker {
return Err(SwiftError::NotFound(format!(
"Object '{}' not found in container '{}'",
object, container
)));
}
// 8. Extract new metadata from X-Object-Meta-* headers
let mut new_metadata = HashMap::new();
for (header_name, header_value) in headers.iter() {
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
&& let Ok(value_str) = header_value.to_str()
{
new_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
// 9. Also update Content-Type if provided
if let Some(content_type) = headers.get("content-type")
&& let Ok(ct_str) = content_type.to_str()
{
new_metadata.insert("content-type".to_string(), ct_str.to_string());
}
// 10. Validate metadata limits
validate_metadata(&new_metadata)?;
// 11. Prepare options for metadata update
// Swift POST replaces all custom metadata, not merges
let update_opts = SwiftObjectOptions {
user_defined: new_metadata,
mod_time: existing_info.mod_time,
version_id: existing_info.version_id.map(|v| v.to_string()),
..Default::default()
};
// 12. Update object metadata
let _updated_info = store
.put_object_metadata(&bucket, &s3_key, &update_opts)
.await
.map_err(|e| sanitize_storage_error("Metadata update", e))?;
Ok(())
}
/// Server-side copy object (COPY)
///
/// Copies an object from source container/object to destination container/object
/// without transferring data through the client. This is a Swift-specific operation
/// that uses the underlying storage layer's copy capabilities.
///
/// # Arguments
/// * `src_account` - Source Swift account name (AUTH_{project_id})
/// * `src_container` - Source container name
/// * `src_object` - Source object name
/// * `dst_account` - Destination Swift account name (AUTH_{project_id})
/// * `dst_container` - Destination container name
/// * `dst_object` - Destination object name
/// * `credentials` - User credentials with project_id
/// * `headers` - HTTP headers (may contain metadata to set on destination)
///
/// # Returns
/// * `Ok(String)` - ETag of the copied object
/// * `Err(SwiftError)` - Error if validation fails, source not found, or copy fails
///
/// # Swift COPY Behavior
/// - Copies object data and system metadata (content-type, etag, etc.)
/// - Can optionally set new custom metadata via X-Object-Meta-* headers
/// - If no custom metadata provided, copies source custom metadata
/// - Destination container must exist before copy
/// - Atomic operation (either succeeds completely or fails)
///
/// # Handler Integration Note
/// The current handler architecture needs to be updated to pass headers through
/// to support COPY method and X-Copy-From header detection. See handler.rs for details.
#[allow(dead_code)] // Handler integration: COPY object
#[allow(clippy::too_many_arguments)] // Necessary for full copy functionality
pub async fn copy_object(
src_account: &str,
src_container: &str,
src_object: &str,
dst_account: &str,
dst_container: &str,
dst_object: &str,
credentials: &Credentials,
headers: &HeaderMap,
) -> SwiftResult<String> {
// 1. Validate source account access and get project_id
let src_project_id = validate_account_access(src_account, credentials)?;
// 2. Validate destination account access (may be different account)
let dst_project_id = validate_account_access(dst_account, credentials)?;
// 3. Validate object names
ObjectKeyMapper::validate_object_name(src_object)?;
ObjectKeyMapper::validate_object_name(dst_object)?;
// 4. Get S3 keys from object names
let src_s3_key = ObjectKeyMapper::swift_to_s3_key(src_object)?;
let dst_s3_key = ObjectKeyMapper::swift_to_s3_key(dst_object)?;
// 5. Map containers to buckets using tenant prefixing
let mapper = ContainerMapper::default();
let src_bucket = mapper.swift_to_s3_bucket(src_container, &src_project_id);
let dst_bucket = mapper.swift_to_s3_bucket(dst_container, &dst_project_id);
// 6. Get storage layer
let Some(store) = resolve_swift_object_store_handle() else {
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
};
// 7. First, verify source object exists and get its info
let src_opts = SwiftObjectOptions::default();
let mut src_info = store
.get_object_info(&src_bucket, &src_s3_key, &src_opts)
.await
.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("does not exist") || err_str.contains("not found") {
SwiftError::NotFound(format!("Source object '{}' not found in container '{}'", src_object, src_container))
} else {
sanitize_storage_error("Source object info retrieval", e)
}
})?;
// 8. Check if source is a delete marker
if src_info.delete_marker {
return Err(SwiftError::NotFound(format!(
"Source object '{}' not found in container '{}'",
src_object, src_container
)));
}
// 9. Verify destination container exists by trying to get its info
let bucket_opts = BucketOptions::default();
let _dst_bucket_info = store.get_bucket_info(&dst_bucket, &bucket_opts).await.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("does not exist") || err_str.contains("not found") {
SwiftError::NotFound(format!("Destination container '{}' not found", dst_container))
} else {
sanitize_storage_error("Destination container verification", e)
}
})?;
// 10. Prepare metadata for destination object
// Start with source metadata
let mut new_metadata = (*src_info.user_defined).clone();
// 11. If custom metadata headers provided, use those instead (Swift behavior)
let mut has_custom_meta = false;
for (header_name, header_value) in headers.iter() {
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-") {
if !has_custom_meta {
// First custom meta header - clear source metadata
new_metadata.clear();
has_custom_meta = true;
}
if let Ok(value_str) = header_value.to_str() {
new_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
}
// 12. Also check for Content-Type override
let content_type = if let Some(ct) = headers.get("content-type") {
ct.to_str().ok().map(|s| s.to_string())
} else {
src_info.content_type.clone()
};
if let Some(ct) = content_type {
new_metadata.insert("content-type".to_string(), ct);
}
// 13. Validate metadata limits
validate_metadata(&new_metadata)?;
// 14. Prepare destination options
let dst_opts = SwiftObjectOptions {
user_defined: new_metadata,
..Default::default()
};
// 15. Perform server-side copy
let dst_info = store
.copy_object(&src_bucket, &src_s3_key, &dst_bucket, &dst_s3_key, &mut src_info, &src_opts, &dst_opts)
.await
.map_err(|e| sanitize_storage_error("Object copy", e))?;
// 16. Return the ETag of the destination object
Ok(dst_info.etag.unwrap_or_default())
}
/// Parse Swift Destination header
///
/// The Destination header format is: `/container/object`
/// The account segment is not included in the Destination header (it's implicit from the request URL).
/// Object names can contain slashes (e.g., `/container/path/to/file.txt`).
///
/// # Arguments
/// * `destination` - The Destination header value
///
/// # Returns
/// * `Ok((container, object))` - Parsed container and object names
/// * `Err(SwiftError)` - Error if format is invalid
///
/// # Examples
/// ```ignore
/// // Simple object name
/// let (container, object) = parse_destination_header("/my-container/my-object.txt")?;
/// assert_eq!(container, "my-container");
/// assert_eq!(object, "my-object.txt");
///
/// // Object with path (slashes preserved)
/// let (container, object) = parse_destination_header("/my-container/path/to/file.txt")?;
/// assert_eq!(container, "my-container");
/// assert_eq!(object, "path/to/file.txt");
/// ```
#[allow(dead_code)] // Handler integration: COPY method
pub fn parse_destination_header(destination: &str) -> SwiftResult<(String, String)> {
let destination = destination.trim_start_matches('/');
let parts: Vec<&str> = destination.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(SwiftError::BadRequest(
"Invalid Destination header format. Expected: /container/object".to_string(),
));
}
let container = parts[0].to_string();
let object = parts[1].to_string();
if container.is_empty() || object.is_empty() {
return Err(SwiftError::BadRequest(
"Destination container and object names cannot be empty".to_string(),
));
}
Ok((container, object))
}
/// Parse Swift X-Copy-From header
///
/// The X-Copy-From header format is: `/container/object`
/// This function parses it into container and object components.
///
/// # Arguments
/// * `copy_from` - The X-Copy-From header value
///
/// # Returns
/// * `Ok((container, object))` - Parsed container and object names
/// * `Err(SwiftError)` - Error if format is invalid
#[allow(dead_code)] // Handler integration: X-Copy-From
pub fn parse_copy_from_header(copy_from: &str) -> SwiftResult<(String, String)> {
// Same parsing logic as Destination header
parse_destination_header(copy_from)
}
/// Parse HTTP Range header for Swift
///
/// Parses standard HTTP Range header (e.g., "bytes=0-1023") into HTTPRangeSpec.
/// Swift uses the same Range header format as HTTP/S3.
///
/// # Arguments
/// * `range_str` - The Range header value (e.g., "bytes=0-1023")
///
/// # Returns
/// * `Ok(HTTPRangeSpec)` - Parsed range specification
/// * `Err(SwiftError)` - Error if format is invalid
///
/// # Supported Formats
/// - `bytes=0-1023` - Bytes 0 through 1023 (inclusive)
/// - `bytes=1024-` - From byte 1024 to end of file
/// - `bytes=-500` - Last 500 bytes (suffix range)
///
/// # Examples
/// ```ignore
/// let range = parse_range_header("bytes=0-1023")?;
/// assert_eq!(range.start, 0);
/// assert_eq!(range.end, 1023);
/// ```
#[allow(dead_code)] // Handler integration: Range header
pub fn parse_range_header(range_str: &str) -> SwiftResult<HTTPRangeSpec> {
if !range_str.starts_with("bytes=") {
return Err(SwiftError::BadRequest("Range header must start with 'bytes='".to_string()));
}
let range_part = &range_str[6..]; // Remove "bytes=" prefix
if let Some(dash_pos) = range_part.find('-') {
let start_str = &range_part[..dash_pos];
let end_str = &range_part[dash_pos + 1..];
if start_str.is_empty() && end_str.is_empty() {
return Err(SwiftError::BadRequest("Invalid range format: both start and end are empty".to_string()));
}
if start_str.is_empty() {
// Suffix range: bytes=-500 (last 500 bytes)
let length = end_str
.parse::<i64>()
.map_err(|_| SwiftError::BadRequest("Invalid range format: suffix length not a number".to_string()))?;
if length <= 0 {
return Err(SwiftError::BadRequest("Invalid range format: suffix length must be positive".to_string()));
}
Ok(HTTPRangeSpec {
is_suffix_length: true,
start: -length,
end: -1,
})
} else {
// Regular range or open-ended range
let start = start_str
.parse::<i64>()
.map_err(|_| SwiftError::BadRequest("Invalid range format: start not a number".to_string()))?;
let end = if end_str.is_empty() {
-1 // Open-ended range: bytes=500-
} else {
end_str
.parse::<i64>()
.map_err(|_| SwiftError::BadRequest("Invalid range format: end not a number".to_string()))?
};
if start < 0 {
return Err(SwiftError::BadRequest("Invalid range format: start must be non-negative".to_string()));
}
if end != -1 && end < start {
return Err(SwiftError::BadRequest("Invalid range format: end must be >= start".to_string()));
}
Ok(HTTPRangeSpec {
is_suffix_length: false,
start,
end,
})
}
} else {
Err(SwiftError::BadRequest("Invalid range format: missing '-'".to_string()))
}
}
/// Format Content-Range header for Swift responses
///
/// Creates a Content-Range header value for partial content responses.
/// Format: "bytes start-end/total"
///
/// # Arguments
/// * `start` - Start byte position (inclusive)
/// * `end` - End byte position (inclusive)
/// * `total` - Total size of the object
///
/// # Returns
/// * Formatted Content-Range header value
///
/// # Example
/// ```ignore
/// let header = format_content_range(0, 1023, 5000);
/// assert_eq!(header, "bytes 0-1023/5000");
/// ```
#[allow(dead_code)] // Handler integration: Range header
pub fn format_content_range(start: i64, end: i64, total: i64) -> String {
format!("bytes {}-{}/{}", start, end, total)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_validate_object_name_valid() {
assert!(ObjectKeyMapper::validate_object_name("myfile.txt").is_ok());
assert!(ObjectKeyMapper::validate_object_name("path/to/file.jpg").is_ok());
assert!(ObjectKeyMapper::validate_object_name("file with spaces.pdf").is_ok());
assert!(ObjectKeyMapper::validate_object_name("special-chars_@#$.txt").is_ok());
assert!(ObjectKeyMapper::validate_object_name("unicode-文件.txt").is_ok());
}
#[test]
fn test_validate_object_name_empty() {
let result = ObjectKeyMapper::validate_object_name("");
assert!(result.is_err());
match result {
Err(SwiftError::BadRequest(msg)) => {
assert!(msg.contains("empty"));
}
_ => panic!("Expected BadRequest error"),
}
}
#[test]
fn test_validate_object_name_too_long() {
let long_name = "a".repeat(1025);
let result = ObjectKeyMapper::validate_object_name(&long_name);
assert!(result.is_err());
match result {
Err(SwiftError::BadRequest(msg)) => {
assert!(msg.contains("too long"));
}
_ => panic!("Expected BadRequest error"),
}
}
#[test]
fn test_validate_object_name_null_byte() {
let result = ObjectKeyMapper::validate_object_name("file\0name.txt");
assert!(result.is_err());
match result {
Err(SwiftError::BadRequest(msg)) => {
assert!(msg.contains("null"));
}
_ => panic!("Expected BadRequest error"),
}
}
#[test]
fn test_validate_object_name_control_character() {
let result = ObjectKeyMapper::validate_object_name("file\nname.txt");
assert!(result.is_err());
match result {
Err(SwiftError::BadRequest(msg)) => {
assert!(msg.contains("control"));
}
_ => panic!("Expected BadRequest error"),
}
}
#[test]
fn test_validate_object_name_leading_slash() {
let result = ObjectKeyMapper::validate_object_name("/file.txt");
assert!(result.is_err());
match result {
Err(SwiftError::BadRequest(msg)) => {
assert!(msg.contains("start with '/'"));
}
_ => panic!("Expected BadRequest error"),
}
}
#[test]
fn test_validate_object_name_directory_traversal() {
// ".." as a path segment should be rejected
assert!(ObjectKeyMapper::validate_object_name("path/../file.txt").is_err());
assert!(ObjectKeyMapper::validate_object_name("../file.txt").is_err());
assert!(ObjectKeyMapper::validate_object_name("path/..").is_err());
// But ".." in a filename should be allowed
assert!(ObjectKeyMapper::validate_object_name("file..txt").is_ok());
assert!(ObjectKeyMapper::validate_object_name("my..file.txt").is_ok());
}
#[test]
fn test_swift_to_s3_key() {
assert_eq!(ObjectKeyMapper::swift_to_s3_key("file.txt").unwrap(), "file.txt");
assert_eq!(ObjectKeyMapper::swift_to_s3_key("path/to/file.jpg").unwrap(), "path/to/file.jpg");
assert_eq!(ObjectKeyMapper::swift_to_s3_key("file with spaces.pdf").unwrap(), "file with spaces.pdf");
}
#[test]
fn test_s3_to_swift_name() {
assert_eq!(ObjectKeyMapper::s3_to_swift_name("file.txt"), "file.txt");
assert_eq!(ObjectKeyMapper::s3_to_swift_name("path/to/file.jpg"), "path/to/file.jpg");
}
#[test]
fn test_decode_object_from_url() {
// Basic decoding
assert_eq!(ObjectKeyMapper::decode_object_from_url("file.txt").unwrap(), "file.txt");
// Percent-encoded spaces
assert_eq!(
ObjectKeyMapper::decode_object_from_url("file%20with%20spaces.txt").unwrap(),
"file with spaces.txt"
);
// Percent-encoded special characters
assert_eq!(
ObjectKeyMapper::decode_object_from_url("path%2Fto%2Ffile.txt").unwrap(),
"path/to/file.txt"
);
// Unicode characters
assert_eq!(ObjectKeyMapper::decode_object_from_url("%E6%96%87%E4%BB%B6.txt").unwrap(), "文件.txt");
}
#[test]
fn test_encode_object_for_url() {
assert_eq!(ObjectKeyMapper::encode_object_for_url("file.txt"), "file.txt");
assert_eq!(ObjectKeyMapper::encode_object_for_url("file with spaces.txt"), "file%20with%20spaces.txt");
assert_eq!(ObjectKeyMapper::encode_object_for_url("path/to/file.txt"), "path%2Fto%2Ffile.txt");
}
#[test]
fn test_is_directory_marker() {
assert!(ObjectKeyMapper::is_directory_marker("folder/"));
assert!(ObjectKeyMapper::is_directory_marker("path/to/dir/"));
assert!(!ObjectKeyMapper::is_directory_marker("file.txt"));
assert!(!ObjectKeyMapper::is_directory_marker("folder"));
}
#[test]
fn test_normalize_path() {
// Remove redundant slashes
assert_eq!(ObjectKeyMapper::normalize_path("path//to///file.txt"), "path/to/file.txt");
// Preserve trailing slash for directories
assert_eq!(ObjectKeyMapper::normalize_path("path/to/dir/"), "path/to/dir/");
// Remove leading/trailing slashes except for directory marker
assert_eq!(ObjectKeyMapper::normalize_path("/path/to/file.txt"), "path/to/file.txt");
// Empty segments
assert_eq!(ObjectKeyMapper::normalize_path("path///to/file.txt"), "path/to/file.txt");
}
proptest! {
#[test]
fn validate_object_name_ok_outputs_are_safe(input in any::<String>()) {
if let Ok(()) = ObjectKeyMapper::validate_object_name(&input) {
prop_assert!(!input.is_empty());
prop_assert!(input.len() <= 1024);
prop_assert!(!input.starts_with('/'));
prop_assert!(!input.chars().any(char::is_control));
prop_assert!(!input.split('/').any(|segment| segment == ".."));
}
}
#[test]
fn decode_object_from_url_round_trips_valid_input(input in any::<String>()) {
let encoded = ObjectKeyMapper::encode_object_for_url(&input);
match ObjectKeyMapper::decode_object_from_url(&encoded) {
Ok(decoded) => {
prop_assert_eq!(decoded.as_str(), input.as_str());
prop_assert!(ObjectKeyMapper::validate_object_name(&decoded).is_ok());
}
Err(_) => {
prop_assert!(ObjectKeyMapper::validate_object_name(&input).is_err());
}
}
}
#[test]
fn normalize_path_is_idempotent(input in any::<String>()) {
let once = ObjectKeyMapper::normalize_path(&input);
let twice = ObjectKeyMapper::normalize_path(&once);
prop_assert_eq!(twice, once);
}
}
#[test]
fn test_parse_destination_header() {
// Valid destination headers
let (container, object) = parse_destination_header("/my-container/my-object.txt").unwrap();
assert_eq!(container, "my-container");
assert_eq!(object, "my-object.txt");
let (container, object) = parse_destination_header("/container/path/to/object.txt").unwrap();
assert_eq!(container, "container");
assert_eq!(object, "path/to/object.txt");
// Without leading slash
let (container, object) = parse_destination_header("container/object.txt").unwrap();
assert_eq!(container, "container");
assert_eq!(object, "object.txt");
// Invalid formats
assert!(parse_destination_header("/container-only").is_err());
assert!(parse_destination_header("/").is_err());
assert!(parse_destination_header("").is_err());
assert!(parse_destination_header("/container/").is_err());
}
#[test]
fn test_parse_copy_from_header() {
// Valid X-Copy-From headers
let (container, object) = parse_copy_from_header("/source-container/source.txt").unwrap();
assert_eq!(container, "source-container");
assert_eq!(object, "source.txt");
let (container, object) = parse_copy_from_header("/my-container/photos/vacation.jpg").unwrap();
assert_eq!(container, "my-container");
assert_eq!(object, "photos/vacation.jpg");
// Invalid formats (same as destination header)
assert!(parse_copy_from_header("/container-only").is_err());
assert!(parse_copy_from_header("").is_err());
}
#[test]
fn test_parse_range_header_regular() {
// Regular range: bytes=0-1023
let range = parse_range_header("bytes=0-1023").unwrap();
assert!(!range.is_suffix_length);
assert_eq!(range.start, 0);
assert_eq!(range.end, 1023);
// Another regular range
let range = parse_range_header("bytes=1000-1999").unwrap();
assert!(!range.is_suffix_length);
assert_eq!(range.start, 1000);
assert_eq!(range.end, 1999);
}
#[test]
fn test_parse_range_header_open_ended() {
// Open-ended range: bytes=1000-
let range = parse_range_header("bytes=1000-").unwrap();
assert!(!range.is_suffix_length);
assert_eq!(range.start, 1000);
assert_eq!(range.end, -1);
// From start to end
let range = parse_range_header("bytes=0-").unwrap();
assert!(!range.is_suffix_length);
assert_eq!(range.start, 0);
assert_eq!(range.end, -1);
}
#[test]
fn test_parse_range_header_suffix() {
// Suffix range: bytes=-500 (last 500 bytes)
let range = parse_range_header("bytes=-500").unwrap();
assert!(range.is_suffix_length);
assert_eq!(range.start, -500);
assert_eq!(range.end, -1);
// Last 1 byte
let range = parse_range_header("bytes=-1").unwrap();
assert!(range.is_suffix_length);
assert_eq!(range.start, -1);
assert_eq!(range.end, -1);
}
#[test]
fn test_parse_range_header_invalid() {
// Missing "bytes=" prefix
assert!(parse_range_header("0-1023").is_err());
assert!(parse_range_header("range=0-1023").is_err());
// Missing dash
assert!(parse_range_header("bytes=01023").is_err());
// Both empty
assert!(parse_range_header("bytes=-").is_err());
// End before start
assert!(parse_range_header("bytes=1000-999").is_err());
// Negative start (invalid for regular range)
assert!(parse_range_header("bytes=-1000-2000").is_err());
// Invalid numbers
assert!(parse_range_header("bytes=abc-def").is_err());
assert!(parse_range_header("bytes=0-xyz").is_err());
// Zero or negative suffix length
assert!(parse_range_header("bytes=-0").is_err());
}
#[test]
fn test_format_content_range() {
assert_eq!(format_content_range(0, 1023, 5000), "bytes 0-1023/5000");
assert_eq!(format_content_range(1000, 1999, 10000), "bytes 1000-1999/10000");
assert_eq!(format_content_range(0, 0, 1), "bytes 0-0/1");
assert_eq!(format_content_range(9999, 9999, 10000), "bytes 9999-9999/10000");
}
#[test]
fn test_build_s3_key() {
assert_eq!(ObjectKeyMapper::build_s3_key("file.txt").unwrap(), "file.txt");
assert_eq!(ObjectKeyMapper::build_s3_key("path/to/file.jpg").unwrap(), "path/to/file.jpg");
}
}