diff --git a/crates/ecstore/src/bucket/utils.rs b/crates/ecstore/src/bucket/utils.rs index 1a40f9dc8..f574408d3 100644 --- a/crates/ecstore/src/bucket/utils.rs +++ b/crates/ecstore/src/bucket/utils.rs @@ -183,6 +183,16 @@ pub fn is_valid_object_name(object: &str) -> bool { is_valid_object_prefix(object) } +/// Client-facing reason attached to rejections of object keys that Win32/NTFS +/// cannot represent as file paths (issue #3299). Deployments on Linux/macOS +/// accept the full S3 key character set. +pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str = + "object key contains characters unsupported on Windows hosts (one of ':', '*', '?', '\"', '|', '<', '>')"; + +/// Client-facing reason for path segments Windows can store but not address +/// afterwards (issue #3449): trailing dot/space or reserved DOS device names. +pub const WINDOWS_RESERVED_SEGMENT_REASON: &str = "object key contains a path segment unsupported on Windows hosts (trailing dot or space, or a reserved device name such as NUL/CON/COM1)"; + /// Reserved DOS device names that shadow regular files on Windows, even when /// an extension is appended (e.g. `NUL.txt` resolves to the `NUL` device). const WINDOWS_RESERVED_NAMES: &[&str] = &[ @@ -226,13 +236,21 @@ pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Res || object.contains('>') // || object.contains('\\') { - return Err(StorageError::ObjectNameInvalid(bucket.to_owned(), object.to_owned())); + return Err(StorageError::InvalidArgument( + bucket.to_owned(), + object.to_owned(), + WINDOWS_RESERVED_CHARACTERS_REASON.to_owned(), + )); } // Reject names that NTFS would happily create but the Win32 path // layer cannot read back (os error 3), e.g. `baddir.` or `NUL.txt`. if object_name_has_windows_incompatible_segment(object) { - return Err(StorageError::ObjectNameInvalid(bucket.to_owned(), object.to_owned())); + return Err(StorageError::InvalidArgument( + bucket.to_owned(), + object.to_owned(), + WINDOWS_RESERVED_SEGMENT_REASON.to_owned(), + )); } } @@ -537,8 +555,8 @@ mod tests { let result = check_object_name_for_length_and_slash("test-bucket", object); if cfg!(target_os = "windows") { assert!( - matches!(result, Err(StorageError::ObjectNameInvalid(..))), - "object name must be rejected on Windows: {object:?}" + matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_SEGMENT_REASON), + "object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}" ); } else { assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}"); @@ -554,6 +572,23 @@ mod tests { } } + #[test] + fn test_check_object_name_windows_reserved_characters() { + // Keys containing Win32-reserved characters are rejected on Windows + // with a descriptive reason (issue #3299); valid elsewhere. + for object in ["path/*sUt*mykey", "a:b", "what?", "pipe|name", "quote\"d", "lt"] { + let result = check_object_name_for_length_and_slash("test-bucket", object); + if cfg!(target_os = "windows") { + assert!( + matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_CHARACTERS_REASON), + "object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}" + ); + } else { + assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}"); + } + } + } + #[test] fn test_check_bucket_and_object_names() { // Valid names diff --git a/rustfs/src/error.rs b/rustfs/src/error.rs index 51a6070ad..494fbd9fd 100644 --- a/rustfs/src/error.rs +++ b/rustfs/src/error.rs @@ -276,6 +276,13 @@ impl From for ApiError { let message = if code == S3ErrorCode::InternalError { err.to_string() + } else if let StorageError::InvalidArgument(_, _, reason) = &err + && !reason.is_empty() + { + // Argument-validation rejections carry a client-actionable reason + // (e.g. object keys unsupported on Windows hosts, issue #3299); + // a bare "Invalid argument" gives the caller nothing to fix. + format!("Invalid argument: {reason}") } else { ApiError::error_code_to_message(&code) }; @@ -397,6 +404,26 @@ mod tests { } } + #[test] + fn test_api_error_surfaces_invalid_argument_reason() { + let err = StorageError::InvalidArgument( + "bucket".into(), + "a:b*c".into(), + "object key contains characters unsupported on Windows hosts".into(), + ); + let api_error: ApiError = err.into(); + assert_eq!(api_error.code, S3ErrorCode::InvalidArgument); + assert_eq!( + api_error.message, + "Invalid argument: object key contains characters unsupported on Windows hosts" + ); + + // An empty reason falls back to the generic message. + let err = StorageError::InvalidArgument("bucket".into(), "object".into(), String::new()); + let api_error: ApiError = err.into(); + assert_eq!(api_error.message, "Invalid argument"); + } + #[test] fn test_api_error_other_function() { let custom_error = "Custom API error";