// Copyright 2026 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. //! Truthful storage-class write and discovery contract regressions. #[cfg(test)] mod tests { use crate::common::{RustFSTestEnvironment, init_logging, local_http_client}; use aws_sdk_s3::Client; use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{ObjectAttributes, StorageClass}; use http::header::HOST; use reqwest::StatusCode; use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::sign_v4; use s3s::Body; use serde_json::Value; use std::error::Error; use std::path::Path; const UNSUPPORTED_AWS_CLASSES: [&str; 9] = [ "DEEP_ARCHIVE", "EXPRESS_ONEZONE", "GLACIER", "GLACIER_IR", "INTELLIGENT_TIERING", "ONEZONE_IA", "OUTPOSTS", "SNOW", "STANDARD_IA", ]; async fn assert_object_storage_class( client: &Client, bucket: &str, key: &str, expected: &str, body: &[u8], ) -> Result<(), Box> { let head = client.head_object().bucket(bucket).key(key).send().await?; let expected_head = (expected != "STANDARD").then_some(expected); assert_eq!( head.storage_class().map(StorageClass::as_str), expected_head, "HeadObject must omit implicit STANDARD and report RRS" ); let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?; let object = listed .contents() .iter() .find(|object| object.key() == Some(key)) .ok_or("object missing from ListObjectsV2")?; assert_eq!(object.storage_class().map(|storage_class| storage_class.as_str()), Some(expected)); let get = client.get_object().bucket(bucket).key(key).send().await?; assert_eq!( get.storage_class().map(StorageClass::as_str), expected_head, "GetObject must report the same effective storage class as HeadObject" ); let downloaded = get.body.collect().await?.into_bytes(); assert_eq!(downloaded.as_ref(), body, "storage-class selection must not alter object bytes"); Ok(()) } async fn mutate_xl_meta( root: &str, bucket: &str, key: &str, mutate: impl FnOnce(&mut rustfs_filemeta::MetaObject), ) -> Result<(), Box> { let path = Path::new(root).join(bucket).join(key).join("xl.meta"); let bytes = tokio::fs::read(&path).await?; let mut file_meta = rustfs_filemeta::FileMeta::load(&bytes)?; let (index, mut version) = file_meta.find_version(None)?; let object = version.object.as_mut().ok_or("fixture version is not an object")?; mutate(object); file_meta.versions[index] = rustfs_filemeta::FileMetaShallowVersion::try_from(version)?; tokio::fs::write(path, file_meta.marshal_msg()?).await?; Ok(()) } async fn signed_admin_get( env: &RustFSTestEnvironment, path: &str, ) -> Result> { let url = format!("{}{path}", env.url); let uri = url.parse::()?; let authority = uri.authority().ok_or("admin URL missing authority")?.to_string(); let request = http::Request::builder() .method(http::Method::GET) .uri(uri) .header(HOST, authority) .header("x-amz-content-sha256", UNSIGNED_PAYLOAD) .body(Body::empty())?; let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1"); let mut request = local_http_client().get(&url); for (name, value) in signed.headers() { request = request.header(name, value); } Ok(request.send().await?) } #[tokio::test] async fn standard_and_rrs_are_supported_across_put_copy_and_multipart() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(Vec::new()).await?; let client = env.create_s3_client(); let bucket = "storage-class-supported-contract"; env.create_test_bucket(bucket).await?; client .put_object() .bucket(bucket) .key("copy-source") .body(ByteStream::from_static(b"copy-source-body")) .send() .await?; for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] { let class_name = storage_class.as_str().to_string(); let put_key = format!("put-{class_name}"); let put_body = format!("put-body-{class_name}").into_bytes(); client .put_object() .bucket(bucket) .key(&put_key) .storage_class(storage_class.clone()) .body(ByteStream::from(put_body.clone())) .send() .await?; assert_object_storage_class(&client, bucket, &put_key, &class_name, &put_body).await?; let copy_key = format!("copy-{class_name}"); client .copy_object() .bucket(bucket) .key(©_key) .copy_source(format!("{bucket}/copy-source")) .storage_class(storage_class.clone()) .send() .await?; assert_object_storage_class(&client, bucket, ©_key, &class_name, b"copy-source-body").await?; let multipart_key = format!("multipart-{class_name}"); let created = client .create_multipart_upload() .bucket(bucket) .key(&multipart_key) .storage_class(storage_class) .send() .await?; let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?; let parts = client .list_parts() .bucket(bucket) .key(&multipart_key) .upload_id(upload_id) .send() .await?; assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name.as_str())); client .abort_multipart_upload() .bucket(bucket) .key(&multipart_key) .upload_id(upload_id) .send() .await?; } Ok(()) } #[tokio::test] async fn label_only_aws_classes_fail_before_put_copy_or_multipart_mutation() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(Vec::new()).await?; let client = env.create_s3_client(); let bucket = "storage-class-unsupported-contract"; env.create_test_bucket(bucket).await?; for key in ["put-guard", "copy-source", "copy-guard"] { client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(format!("original-{key}").into_bytes())) .send() .await?; } for unsupported in UNSUPPORTED_AWS_CLASSES { let put_error = client .put_object() .bucket(bucket) .key("put-guard") .storage_class(StorageClass::from(unsupported)) .body(ByteStream::from(format!("rejected-put-{unsupported}").into_bytes())) .send() .await .expect_err("label-only PUT storage class must be rejected"); assert_eq!( put_error.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidStorageClass"), "PUT returned a different error for {unsupported}" ); let copy_error = client .copy_object() .bucket(bucket) .key("copy-guard") .copy_source(format!("{bucket}/copy-source")) .storage_class(StorageClass::from(unsupported)) .send() .await .expect_err("label-only CopyObject storage class must be rejected"); assert_eq!( copy_error.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidStorageClass"), "CopyObject returned a different error for {unsupported}" ); let multipart_key = format!("multipart-{unsupported}"); let multipart_error = client .create_multipart_upload() .bucket(bucket) .key(&multipart_key) .storage_class(StorageClass::from(unsupported)) .send() .await .expect_err("label-only CreateMultipartUpload storage class must be rejected"); assert_eq!( multipart_error.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidStorageClass"), "CreateMultipartUpload returned a different error for {unsupported}" ); } let put_guard = client .get_object() .bucket(bucket) .key("put-guard") .send() .await? .body .collect() .await? .into_bytes(); assert_eq!(put_guard.as_ref(), b"original-put-guard"); let copy_guard = client .get_object() .bucket(bucket) .key("copy-guard") .send() .await? .body .collect() .await? .into_bytes(); assert_eq!(copy_guard.as_ref(), b"original-copy-guard"); let uploads = client.list_multipart_uploads().bucket(bucket).send().await?; assert!(uploads.uploads().is_empty(), "unsupported classes must not create multipart sessions"); Ok(()) } #[tokio::test] async fn historical_label_only_metadata_is_standard_without_hiding_a_real_transition_tier() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(Vec::new()).await?; let client = env.create_s3_client(); let bucket = "storage-class-historical-contract"; let legacy_key = "legacy-label-only"; let transitioned_key = "real-transition-tier"; env.create_test_bucket(bucket).await?; for key in [legacy_key, transitioned_key] { client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"fixture-body")) .send() .await?; } env.stop_server(); mutate_xl_meta(&env.temp_dir, bucket, legacy_key, |object| { object .meta_user .insert("x-amz-storage-class".to_string(), "STANDARD_IA".to_string()); }) .await?; mutate_xl_meta(&env.temp_dir, bucket, transitioned_key, |object| { object.set_transition(&rustfs_filemeta::FileInfo { transition_status: rustfs_filemeta::TRANSITION_COMPLETE.to_string(), transition_tier: "STANDARD_IA".to_string(), ..Default::default() }); }) .await?; env.restart_server_preserving_data(Vec::new(), &[]).await?; assert_object_storage_class(&client, bucket, legacy_key, "STANDARD", b"fixture-body").await?; let legacy_attributes = client .get_object_attributes() .bucket(bucket) .key(legacy_key) .object_attributes(ObjectAttributes::StorageClass) .send() .await?; assert_eq!(legacy_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD")); let versions = client.list_object_versions().bucket(bucket).prefix(legacy_key).send().await?; let legacy_version = versions .versions() .iter() .find(|version| version.key() == Some(legacy_key)) .ok_or("legacy fixture missing from ListObjectVersions")?; assert_eq!(legacy_version.storage_class().map(|class| class.as_str()), Some("STANDARD")); let transitioned_head = client.head_object().bucket(bucket).key(transitioned_key).send().await?; assert_eq!(transitioned_head.storage_class().map(StorageClass::as_str), Some("STANDARD_IA")); let transitioned_attributes = client .get_object_attributes() .bucket(bucket) .key(transitioned_key) .object_attributes(ObjectAttributes::StorageClass) .send() .await?; assert_eq!(transitioned_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD_IA")); let transitioned_list = client .list_objects_v2() .bucket(bucket) .prefix(transitioned_key) .send() .await?; assert_eq!( transitioned_list.contents()[0].storage_class().map(|class| class.as_str()), Some("STANDARD_IA") ); let transitioned_versions = client .list_object_versions() .bucket(bucket) .prefix(transitioned_key) .send() .await?; assert_eq!( transitioned_versions.versions()[0] .storage_class() .map(|class| class.as_str()), Some("STANDARD_IA") ); Ok(()) } #[tokio::test] async fn authenticated_runtime_capabilities_publish_the_versioned_storage_class_contract() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(Vec::new()).await?; let path = "/rustfs/admin/v4/runtime/capabilities"; let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?; assert_eq!(unsigned.status(), StatusCode::FORBIDDEN); let unsigned_body = unsigned.text().await?; assert!( !unsigned_body.contains("supported_write_classes"), "the capability contract must not bypass admin authentication" ); assert!( !unsigned_body.contains("manual_transition_jobs"), "manual transition job capabilities must not bypass admin authentication" ); let response = signed_admin_get(&env, path).await?; assert_eq!(response.status(), StatusCode::OK); let body: Value = response.json().await?; assert_eq!(body["storage_classes"]["contract_version"], 1); assert_eq!( body["storage_classes"]["supported_write_classes"], serde_json::json!(["STANDARD", "REDUCED_REDUNDANCY"]) ); assert_eq!(body["storage_classes"]["unsupported_write_error"], "InvalidStorageClass"); assert_eq!(body["storage_classes"]["legacy_label_behavior"], "normalized_to_effective_class"); assert_eq!(body["summary"]["manual_transition_jobs"]["state"], "supported"); assert_eq!(body["manual_transition_jobs"]["contract_version"], 1); assert_eq!(body["manual_transition_jobs"]["status"]["state"], "supported"); assert_eq!(body["manual_transition_jobs"]["modes"], serde_json::json!(["enqueue_only", "async"])); assert_eq!(body["manual_transition_jobs"]["run_route"], "/rustfs/admin/v3/ilm/transition/run"); assert_eq!( body["manual_transition_jobs"]["status_route"], "/rustfs/admin/v3/ilm/transition/jobs/{job_id}" ); assert_eq!( body["manual_transition_jobs"]["cancel_route"], "/rustfs/admin/v3/ilm/transition/jobs/{job_id}" ); assert_eq!(body["manual_transition_jobs"]["job_id_format"], "uuid"); assert_eq!(body["manual_transition_jobs"]["admission_scope"], "bucket"); assert_eq!( body["manual_transition_jobs"]["mixed_version_policy"], "fail_closed_when_capability_unknown_or_unsupported" ); Ok(()) } }