diff --git a/Cargo.lock b/Cargo.lock index 800e5be14..443c69864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3255,6 +3255,7 @@ dependencies = [ "base64 0.22.1", "bytes", "chrono", + "clap", "flatbuffers", "flate2", "futures", diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml index ae5118148..e47a8e227 100644 --- a/crates/e2e_test/Cargo.toml +++ b/crates/e2e_test/Cargo.toml @@ -73,3 +73,4 @@ rcgen.workspace = true anyhow.workspace = true rustls.workspace = true zip.workspace = true +clap.workspace = true diff --git a/crates/e2e_test/src/bin/tls_gen.rs b/crates/e2e_test/src/bin/tls_gen.rs new file mode 100644 index 000000000..7f9201414 --- /dev/null +++ b/crates/e2e_test/src/bin/tls_gen.rs @@ -0,0 +1,22 @@ +// 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 clap::Parser; +use e2e_test::tls_gen::{Args, run}; + +fn main() -> anyhow::Result<()> { + let out_dir = run(Args::parse())?; + println!("Generated RustFS TLS bundle in {}", out_dir.display()); + Ok(()) +} diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 1a877a1e2..a06f0eb9f 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -125,3 +125,5 @@ mod replication_extension_test; #[cfg(test)] mod snowball_auto_extract_test; + +pub mod tls_gen; diff --git a/crates/e2e_test/src/reliant/head_tls_bodyless_test.rs b/crates/e2e_test/src/reliant/head_tls_bodyless_test.rs new file mode 100644 index 000000000..6ef214a17 --- /dev/null +++ b/crates/e2e_test/src/reliant/head_tls_bodyless_test.rs @@ -0,0 +1,201 @@ +// 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. + +//! Regression test for TLS/HTTP2 `HEAD` responses on missing objects. +//! +//! Before the fix, RustFS returned `404` for a missing object but still wrote +//! the XML error payload on a `HEAD` request. Under HTTP/2 this emitted DATA +//! frames after the response headers, which clients surfaced as a protocol +//! error. This test keeps the request at the raw HTTPS layer so it can validate +//! the final wire-facing behavior rather than SDK-level error mapping. + +#![cfg(test)] + +use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path}; +use http::Version; +use http::header::HOST; +use rcgen::generate_simple_self_signed; +use reqwest::{Certificate, Client, Response, StatusCode}; +use rustfs_signer::constants::UNSIGNED_PAYLOAD; +use rustfs_signer::sign_v4; +use s3s::Body; +use serial_test::serial; +use std::error::Error; +use std::path::Path; +use std::process::Command; +use tokio::fs; +use tokio::time::{Duration, sleep}; +use tracing::info; + +const ACCESS_KEY: &str = "rustfsadmin"; +const SECRET_KEY: &str = "rustfsadmin"; +const BUCKET: &str = "test-head-tls-bodyless-bucket"; + +async fn generate_tls_bundle(tls_dir: &Path) -> Result, Box> { + fs::create_dir_all(tls_dir).await?; + let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?; + let cert_pem = cert.cert.pem(); + let key_pem = cert.signing_key.serialize_pem(); + + fs::write(tls_dir.join("rustfs_cert.pem"), cert_pem.as_bytes()).await?; + fs::write(tls_dir.join("rustfs_key.pem"), key_pem.as_bytes()).await?; + + Ok(cert_pem.into_bytes()) +} + +fn local_https_h2_client(ca_pem: &[u8]) -> Result> { + let _ca_cert = Certificate::from_pem(ca_pem)?; + Ok(Client::builder() + .no_proxy() + .no_gzip() + .no_brotli() + .no_zstd() + .no_deflate() + .danger_accept_invalid_certs(true) + .build()?) +} + +async fn signed_empty_request( + client: &Client, + method: http::Method, + url: &str, +) -> Result> { + let uri = url.parse::()?; + let authority = uri.authority().ok_or("request URL missing authority")?.to_string(); + let request = http::Request::builder() + .method(method.as_str()) + .uri(uri) + .header(HOST, authority) + .header("x-amz-content-sha256", UNSIGNED_PAYLOAD) + .body(Body::empty())?; + + let signed = sign_v4(request, 0, ACCESS_KEY, SECRET_KEY, "", "us-east-1"); + + let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?; + let mut builder = client.request(reqwest_method, url); + for (name, value) in signed.headers() { + builder = builder.header(name, value); + } + + Ok(builder.send().await?) +} + +async fn ensure_bucket_exists(client: &Client, endpoint: &str) -> Result<(), Box> { + let bucket_url = format!("{endpoint}/{BUCKET}/"); + let response = signed_empty_request(client, http::Method::HEAD, &bucket_url).await?; + + if response.status() == StatusCode::OK { + return Ok(()); + } + + let response = signed_empty_request(client, http::Method::PUT, &bucket_url).await?; + match response.status() { + StatusCode::OK => Ok(()), + StatusCode::CONFLICT => Ok(()), + status => Err(format!("unexpected bucket setup status: {status}").into()), + } +} + +async fn wait_for_tls_server_ready(client: &Client, endpoint: &str) -> Result<(), Box> { + let ready_url = format!("{endpoint}/"); + for _attempt in 0..60 { + match signed_empty_request(client, http::Method::GET, &ready_url).await { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(_) | Err(_) => sleep(Duration::from_millis(500)).await, + } + } + + Err("RustFS TLS server failed to become ready within 30 seconds".into()) +} + +async fn start_tls_rustfs_server(env: &mut RustFSTestEnvironment, tls_dir: &Path) -> Result<(), Box> { + let binary_path = rustfs_binary_path(); + let mut command = Command::new(&binary_path); + command + .env("RUST_LOG", "rustfs=info,rustfs_notify=debug") + .env("RUSTFS_TLS_PATH", tls_dir) + .current_dir(&env.temp_dir); + + for key in [ + "RUSTFS_ADDRESS", + "RUSTFS_VOLUMES", + "RUSTFS_ACCESS_KEY", + "RUSTFS_SECRET_KEY", + "RUSTFS_TLS_PATH", + "RUSTFS_OBS_LOG_DIRECTORY", + ] { + command.env_remove(key); + } + + let process = command + .env("RUSTFS_TLS_PATH", tls_dir) + .env("RUSTFS_CONSOLE_ENABLE", "false") + .args([ + "--address", + &env.address, + "--access-key", + &env.access_key, + "--secret-key", + &env.secret_key, + &env.temp_dir, + ]) + .spawn()?; + + env.process = Some(process); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_head_missing_object_over_tls_http2_is_bodyless() -> Result<(), Box> { + init_logging(); + + let mut env = RustFSTestEnvironment::new().await?; + let tls_dir = std::path::PathBuf::from(&env.temp_dir).join("tls"); + let ca_pem = generate_tls_bundle(&tls_dir).await?; + start_tls_rustfs_server(&mut env, &tls_dir).await?; + + let endpoint = format!("https://{}", env.address); + let client = local_https_h2_client(&ca_pem)?; + wait_for_tls_server_ready(&client, &endpoint).await?; + ensure_bucket_exists(&client, &endpoint).await?; + + let missing_key = "head-does-not-exist.txt"; + let object_url = format!("{endpoint}/{BUCKET}/{missing_key}"); + + let get_response = signed_empty_request(&client, http::Method::GET, &object_url).await?; + assert_eq!(get_response.status(), StatusCode::NOT_FOUND); + let get_version = get_response.version(); + let get_body = get_response.bytes().await?; + let get_body_text = String::from_utf8_lossy(&get_body); + assert!( + get_body_text.contains("NoSuchKey") || get_body_text.contains("NoSuchObject"), + "GET missing-object error body should expose NoSuchKey/NoSuchObject, got: {}", + get_body_text + ); + info!("GET missing object over TLS used {:?} and returned {} bytes", get_version, get_body.len()); + + let head_response = signed_empty_request(&client, http::Method::HEAD, &object_url).await?; + assert_eq!(head_response.status(), StatusCode::NOT_FOUND); + assert_eq!(head_response.version(), Version::HTTP_2, "HEAD regression test must exercise HTTP/2"); + let head_body = head_response.bytes().await?; + assert!( + head_body.is_empty(), + "HEAD missing-object response must not send body bytes over TLS/HTTP2, got {} bytes: {:?}", + head_body.len(), + head_body + ); + + Ok(()) +} diff --git a/crates/e2e_test/src/reliant/mod.rs b/crates/e2e_test/src/reliant/mod.rs index d34cb699e..f5c131708 100644 --- a/crates/e2e_test/src/reliant/mod.rs +++ b/crates/e2e_test/src/reliant/mod.rs @@ -17,6 +17,7 @@ mod get_deleted_object_test; mod grpc_lock_client; mod grpc_lock_server; mod head_deleted_object_versioning_test; +mod head_tls_bodyless_test; mod lifecycle; mod lock; mod node_interact_test; diff --git a/crates/e2e_test/src/tls_gen.rs b/crates/e2e_test/src/tls_gen.rs new file mode 100644 index 000000000..16e9aa277 --- /dev/null +++ b/crates/e2e_test/src/tls_gen.rs @@ -0,0 +1,259 @@ +// 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 anyhow::{Context, Result, bail}; +use clap::Parser; +use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, + SanType, +}; +use std::fs; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use time::{Duration, OffsetDateTime}; + +pub const DEFAULT_OUT_DIR: &str = "target/tls"; +pub const OUTPUT_FILES: [&str; 7] = [ + "rustfs_cert.pem", + "rustfs_key.pem", + "ca.crt", + "public.crt", + "client_ca.crt", + "client_cert.pem", + "client_key.pem", +]; + +#[derive(Debug, Parser)] +#[command(name = "tls_gen", about = "Generate a full RustFS TLS bundle for local TLS and mTLS tests.")] +pub struct Args { + #[arg(long, default_value = DEFAULT_OUT_DIR)] + pub out_dir: PathBuf, + #[arg(long, default_value_t = 365)] + pub days: i64, + #[arg(long)] + pub force: bool, +} + +pub fn run(args: Args) -> Result { + if args.days <= 0 { + bail!("--days must be a positive integer"); + } + + write_bundle(&args.out_dir, args.force, args.days)?; + Ok(args.out_dir) +} + +pub fn ensure_writable(out_dir: &Path, force: bool) -> Result<()> { + if force { + return Ok(()); + } + + let existing: Vec<_> = OUTPUT_FILES + .iter() + .map(|name| out_dir.join(name)) + .filter(|path| path.exists()) + .collect(); + + if existing.is_empty() { + return Ok(()); + } + + let existing_list = existing + .iter() + .map(|path| path.file_name().and_then(|name| name.to_str()).unwrap_or("")) + .collect::>() + .join(", "); + + bail!( + "Refusing to overwrite existing files in {}: {}. Re-run with --force to replace them.", + out_dir.display(), + existing_list + ) +} + +fn write_bundle(out_dir: &Path, force: bool, days: i64) -> Result<()> { + fs::create_dir_all(out_dir).with_context(|| format!("failed to create output directory {}", out_dir.display()))?; + ensure_writable(out_dir, force)?; + + let ca_key = generate_private_key()?; + let ca = build_ca_certificate(ca_key, days)?; + + let server_key = generate_private_key()?; + let server_cert = build_leaf_certificate( + &server_key, + "localhost", + &[SanType::DnsName("localhost".try_into()?)], + &[ + SanType::IpAddress(IpAddr::V4("127.0.0.1".parse()?)), + SanType::IpAddress(IpAddr::V6("::1".parse()?)), + ], + ExtendedKeyUsagePurpose::ServerAuth, + &ca, + days, + )?; + + let client_key = generate_private_key()?; + let client_cert = build_leaf_certificate( + &client_key, + "rustfs-test-client", + &[SanType::DnsName("rustfs-test-client".try_into()?)], + &[], + ExtendedKeyUsagePurpose::ClientAuth, + &ca, + days, + )?; + + let ca_pem = ca.pem(); + let bundle = [ + ("rustfs_cert.pem", server_cert.pem()), + ("rustfs_key.pem", server_key.serialize_pem()), + ("ca.crt", ca_pem.clone()), + ("public.crt", ca_pem.clone()), + ("client_ca.crt", ca_pem), + ("client_cert.pem", client_cert.pem()), + ("client_key.pem", client_key.serialize_pem()), + ]; + + for (name, content) in bundle { + fs::write(out_dir.join(name), content).with_context(|| format!("failed to write {}", out_dir.join(name).display()))?; + } + + Ok(()) +} + +fn build_ca_certificate(signing_key: KeyPair, days: i64) -> Result> { + let mut params = base_params("RustFS Test CA", days)?; + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + + CertifiedIssuer::self_signed(params, signing_key).context("failed to create CA certificate") +} + +fn build_leaf_certificate( + signing_key: &KeyPair, + common_name: &str, + dns_names: &[SanType], + ip_addresses: &[SanType], + usage: ExtendedKeyUsagePurpose, + issuer: &CertifiedIssuer<'_, KeyPair>, + days: i64, +) -> Result { + let mut params = base_params(common_name, days)?; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment]; + params.extended_key_usages = vec![usage]; + params.use_authority_key_identifier_extension = true; + params.subject_alt_names.extend_from_slice(dns_names); + params.subject_alt_names.extend_from_slice(ip_addresses); + + params + .signed_by(signing_key, issuer) + .with_context(|| format!("failed to create leaf certificate for {common_name}")) +} + +fn base_params(common_name: &str, days: i64) -> Result { + let mut params = CertificateParams::default(); + let issued_at = OffsetDateTime::now_utc() - Duration::minutes(5); + params.not_before = issued_at; + params.not_after = issued_at + Duration::days(days); + params.distinguished_name.push(DnType::CountryName, "US"); + params.distinguished_name.push(DnType::OrganizationName, "RustFS"); + params.distinguished_name.push(DnType::CommonName, common_name); + Ok(params) +} + +fn generate_private_key() -> Result { + KeyPair::generate().context("failed to generate private key") +} + +#[cfg(test)] +mod tests { + use super::{Args, OUTPUT_FILES, ensure_writable, run}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time must be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("rustfs-tls-gen-{suffix}")) + } + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new() -> Self { + let path = unique_temp_dir(); + fs::create_dir_all(&path).expect("temporary directory should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn run_writes_full_bundle() { + let temp_dir = TempDir::new(); + + let out_dir = run(Args { + out_dir: temp_dir.path().join("tls"), + days: 365, + force: false, + }) + .expect("bundle generation should succeed"); + + for name in OUTPUT_FILES { + let content = fs::read(out_dir.join(name)).unwrap_or_else(|error| panic!("{name} should exist: {error}")); + assert!(!content.is_empty(), "{name} should not be empty"); + } + } + + #[test] + fn ensure_writable_rejects_existing_files_without_force() { + let temp_dir = TempDir::new(); + let existing = temp_dir.path().join(OUTPUT_FILES[0]); + fs::write(&existing, "existing").expect("existing file should be created"); + + let error = ensure_writable(temp_dir.path(), false).expect_err("existing files must be rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("Refusing to overwrite existing files")); + assert!(message.contains(OUTPUT_FILES[0])); + } + + #[test] + fn run_rejects_non_positive_days() { + let temp_dir = TempDir::new(); + let error = run(Args { + out_dir: temp_dir.path().join("tls"), + days: 0, + force: false, + }) + .expect_err("non-positive days must fail"); + + assert_eq!(format!("{error:#}"), "--days must be a positive integer"); + } +} diff --git a/rustfs/src/server/http.rs b/rustfs/src/server/http.rs index 3b7bead16..ac638ec28 100644 --- a/rustfs/src/server/http.rs +++ b/rustfs/src/server/http.rs @@ -22,8 +22,8 @@ use crate::server::{ compress::{CompressionConfig, PathAwareCompressionPredicate, PathCategoryInjectionLayer}, hybrid::hybrid, layer::{ - AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, ObjectAttributesEtagFixLayer, - RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer, + AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, HeadRequestBodyFixLayer, + ObjectAttributesEtagFixLayer, RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer, }, tls_material::{TlsAcceptorHolder, TlsHandshakeFailureKind, TlsMaterialSnapshot, spawn_reload_loop}, }; @@ -656,6 +656,7 @@ fn process_connection( // 16. ConditionalCorsLayer — S3 API CORS // 17. RedirectLayer — console redirect (conditional) // 18. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses + // 19. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses // ───────────────────────────────────────────────────────────── let hybrid_service = ServiceBuilder::new() // NOTE: Both extension types are intentionally inserted to maintain compatibility: @@ -807,6 +808,10 @@ fn process_connection( // other response-transforming layers see the already-bodyless // response and so no layer (e.g. CORS) re-adds body headers afterward. .layer(BodylessStatusFixLayer) + // HEAD responses must not send body bytes even when the inner S3 layer + // serializes an XML error payload. Keep this innermost so the final + // HTTP response written to hyper/h2 is bodyless. + .layer(HeadRequestBodyFixLayer) .service(service); let hybrid_service = TowerToHyperService::new(hybrid_service); diff --git a/rustfs/src/server/layer.rs b/rustfs/src/server/layer.rs index 40b8bc890..f1fa4a0b2 100644 --- a/rustfs/src/server/layer.rs +++ b/rustfs/src/server/layer.rs @@ -477,6 +477,74 @@ where } } +/// Tower middleware that strips the actual response body for `HEAD` requests +/// while preserving metadata headers such as `Content-Length`. +/// +/// The inner s3s layer may serialize S3 errors as XML bodies. That is valid for +/// regular requests, but for `HEAD` the HTTP layer must suppress the response +/// body entirely. If we forward the serialized error body over HTTP/2, clients +/// observe DATA frames on a `HEAD` response and fail the exchange with a +/// protocol error. +#[derive(Clone)] +pub struct HeadRequestBodyFixLayer; + +impl Layer for HeadRequestBodyFixLayer { + type Service = HeadRequestBodyFixService; + + fn layer(&self, inner: S) -> Self::Service { + HeadRequestBodyFixService { inner } + } +} + +#[derive(Clone)] +pub struct HeadRequestBodyFixService { + inner: S, +} + +impl Service> for HeadRequestBodyFixService +where + S: Service, Response = Response>> + Clone + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, + RestBody: Body + From + Send + 'static, + GrpcBody: Send + 'static, +{ + type Response = Response>; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: HttpRequest) -> Self::Future { + let is_head = req.method() == Method::HEAD; + let mut inner = self.inner.clone(); + + Box::pin(async move { + let response = inner.call(req).await?; + if !is_head { + return Ok(response); + } + + let (mut parts, body) = response.into_parts(); + parts.headers.remove(http::header::TRANSFER_ENCODING); + + let response = match body { + HybridBody::Rest { .. } => Response::from_parts( + parts, + HybridBody::Rest { + rest_body: RestBody::from(Bytes::new()), + }, + ), + HybridBody::Grpc { grpc_body } => Response::from_parts(parts, HybridBody::Grpc { grpc_body }), + }; + + Ok(response) + }) + } +} + fn is_bodyless_status(status: StatusCode) -> bool { status.is_informational() || status == StatusCode::NO_CONTENT @@ -1269,6 +1337,110 @@ mod tests { } } + mod head_request_body_fix { + use super::*; + use crate::server::hybrid::HybridBody; + use http_body_util::Empty; + + #[derive(Clone)] + struct FixedResponse { + status: StatusCode, + body: Bytes, + content_type: Option<&'static str>, + } + + impl Service> for FixedResponse { + type Response = Response, Empty>>; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + let this = self.clone(); + Box::pin(async move { + let body = this.body.clone(); + let len = body.len(); + let mut builder = Response::builder().status(this.status); + builder = builder.header(http::header::CONTENT_LENGTH, len.to_string()); + builder = builder.header(http::header::TRANSFER_ENCODING, "chunked"); + if let Some(ct) = this.content_type { + builder = builder.header(http::header::CONTENT_TYPE, ct); + } + Ok(builder + .body(HybridBody::Rest { + rest_body: Full::from(body), + }) + .expect("build response")) + }) + } + } + + fn request_with_method(method: Method) -> Request<()> { + Request::builder() + .method(method) + .uri("/bucket/object") + .body(()) + .expect("request") + } + + async fn collect_body>(body: B) -> Bytes + where + B::Error: std::fmt::Debug, + { + BodyExt::collect(body).await.expect("collect body").to_bytes() + } + + #[tokio::test] + async fn strips_body_for_head_errors_but_preserves_metadata_headers() { + let payload = Bytes::from_static(b"NoSuchKey"); + let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse { + status: StatusCode::NOT_FOUND, + body: payload.clone(), + content_type: Some("application/xml"), + }); + + let res = svc.call(request_with_method(Method::HEAD)).await.expect("service call"); + let (parts, body) = res.into_parts(); + + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(http::header::CONTENT_LENGTH).unwrap(), + payload.len().to_string().as_str() + ); + assert_eq!(parts.headers.get(http::header::CONTENT_TYPE).unwrap(), "application/xml"); + assert!(parts.headers.get(http::header::TRANSFER_ENCODING).is_none()); + + let bytes = collect_body(body).await; + assert!(bytes.is_empty(), "HEAD response body must be empty"); + } + + #[tokio::test] + async fn preserves_body_for_get_errors() { + let payload = Bytes::from_static(b"NoSuchKey"); + let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse { + status: StatusCode::NOT_FOUND, + body: payload.clone(), + content_type: Some("application/xml"), + }); + + let res = svc.call(request_with_method(Method::GET)).await.expect("service call"); + let (parts, body) = res.into_parts(); + + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(http::header::CONTENT_LENGTH).unwrap(), + payload.len().to_string().as_str() + ); + assert_eq!(parts.headers.get(http::header::TRANSFER_ENCODING).unwrap(), "chunked"); + + let bytes = collect_body(body).await; + assert_eq!(bytes, payload); + } + } + #[test] fn test_apply_bucket_cors_result_replaces_existing_cors_headers() { let mut response_headers = HeaderMap::new(); diff --git a/scripts/tls_gen.md b/scripts/tls_gen.md new file mode 100644 index 000000000..4987ea6b2 --- /dev/null +++ b/scripts/tls_gen.md @@ -0,0 +1,35 @@ +# TLS Bundle Generator + +Generate a local TLS/mTLS certificate bundle for RustFS tests with: + +```bash +cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls +``` + +Overwrite an existing bundle with: + +```bash +cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls --force +``` + +Change the validity window with: + +```bash +cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls --days 30 +``` + +Generated files: + +- `rustfs_cert.pem` +- `rustfs_key.pem` +- `ca.crt` +- `public.crt` +- `client_ca.crt` +- `client_cert.pem` +- `client_key.pem` + +Notes: + +- The command refuses to overwrite existing bundle files unless `--force` is set. +- `--days` must be a positive integer. +- The default output directory is `target/tls`.