//! S3 HTTP transport layer. //! //! Low-level HTTP primitives for S3 operations (PUT, GET, HEAD). //! Implements AWS Signature Version 4 request signing. //! The sync protocol logic lives in the upcoming `s3_sync` module. use reqwest::StatusCode; use std::time::Duration; use url::Url; use crate::error::AppError; use crate::proxy::http_client; use futures::StreamExt; const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Timeout for large file transfers (PUT / GET of db.sql, skills.zip, etc.). const TRANSFER_TIMEOUT_SECS: u64 = 300; // ─── Credentials ───────────────────────────────────────────── /// S3-compatible storage credentials. pub(crate) struct S3Credentials { pub access_key_id: String, pub secret_access_key: String, pub region: String, pub bucket: String, /// Custom endpoint host (e.g. `minio.example.com:9000`). /// Empty string means AWS official endpoint. pub endpoint: String, } // ─── URL construction ──────────────────────────────────────── /// Returns `true` for AWS official endpoints (empty or contains `amazonaws.com`). fn is_aws_endpoint(endpoint: &str) -> bool { endpoint.is_empty() || endpoint.contains("amazonaws.com") } /// Split an endpoint into its scheme and host-with-port parts. /// /// Preserves the original scheme when one is provided, defaulting to `"https"` /// when the endpoint is bare (no `://` prefix). /// /// ```text /// "http://minio:9000" → ("http", "minio:9000") /// "https://storage.example.com" → ("https", "storage.example.com") /// "minio:9000" → ("https", "minio:9000") /// "storage.example.com" → ("https", "storage.example.com") /// ``` fn split_scheme_host(endpoint: &str) -> (&str, &str) { if let Some(rest) = endpoint.strip_prefix("http://") { ("http", rest.trim_end_matches('/')) } else if let Some(rest) = endpoint.strip_prefix("https://") { ("https", rest.trim_end_matches('/')) } else { ("https", endpoint.trim_end_matches('/')) } } /// Build the full URL for an S3 object. /// /// - AWS endpoints use virtual-hosted style: `https://{bucket}.s3.{region}.amazonaws.com/{key}` /// - Custom endpoints use path style: `https://{endpoint}/{bucket}/{key}` fn build_object_url(creds: &S3Credentials, key: &str) -> String { let key = key.trim_start_matches('/'); if is_aws_endpoint(&creds.endpoint) { format!( "https://{}.s3.{}.amazonaws.com/{}", creds.bucket, creds.region, key ) } else { let (scheme, host) = split_scheme_host(&creds.endpoint); format!("{}://{}/{}/{}", scheme, host, creds.bucket, key) } } /// Build the bucket-level URL (for HEAD bucket / test connection). fn build_bucket_url(creds: &S3Credentials) -> String { if is_aws_endpoint(&creds.endpoint) { format!( "https://{}.s3.{}.amazonaws.com/", creds.bucket, creds.region ) } else { let (scheme, host) = split_scheme_host(&creds.endpoint); format!("{}://{}/{}/", scheme, host, creds.bucket) } } // ─── Cryptographic helpers ─────────────────────────────────── fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { use hmac::{Hmac, Mac}; type HmacSha256 = Hmac; let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(data); mac.finalize().into_bytes().to_vec() } fn sha256_hex(data: &[u8]) -> String { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(data)) } /// Percent-encode following AWS Sig V4 rules (RFC 3986 unreserved characters only). fn uri_encode(input: &str, encode_slash: bool) -> String { let mut out = String::with_capacity(input.len()); for byte in input.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(byte as char); } b'/' if !encode_slash => out.push('/'), _ => { use std::fmt::Write; let _ = write!(out, "%{:02X}", byte); } } } out } // ─── AWS Signature V4 signing ──────────────────────────────── /// Sign an HTTP request using AWS Signature Version 4. /// /// Mutates `headers` by adding `host`, `x-amz-date`, `x-amz-content-sha256`, /// and the final `authorization` header. fn sign_request( method: &str, url: &Url, headers: &mut reqwest::header::HeaderMap, body_hash: &str, creds: &S3Credentials, now: chrono::DateTime, ) { let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string(); let datestamp = now.format("%Y%m%d").to_string(); // ── Step 1: Add required headers ── let host_value = match url.port() { Some(port) => format!("{}:{}", url.host_str().unwrap_or_default(), port), None => url.host_str().unwrap_or_default().to_string(), }; headers.insert("host", host_value.parse().unwrap()); headers.insert("x-amz-date", timestamp.parse().unwrap()); headers.insert("x-amz-content-sha256", body_hash.parse().unwrap()); // ── Step 2: Build canonical request ── // Canonical URI (already percent-encoded by the url crate). let canonical_uri = url.path(); let canonical_uri = if canonical_uri.is_empty() { "/" } else { canonical_uri }; // Canonical query string — sorted, re-encoded per Sig V4 rules. let mut query_pairs: Vec<(String, String)> = url .query_pairs() .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); query_pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); let canonical_query = if query_pairs.is_empty() { String::new() } else { query_pairs .iter() .map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true))) .collect::>() .join("&") }; // Canonical headers — sorted by lowercase name. let mut header_names: Vec = headers.keys().map(|k| k.as_str().to_lowercase()).collect(); header_names.sort(); header_names.dedup(); let canonical_headers: String = header_names .iter() .map(|name| { let value = headers .get(name.as_str()) .map(|v| v.to_str().unwrap_or("").trim().to_string()) .unwrap_or_default(); format!("{}:{}\n", name, value) }) .collect(); let signed_headers = header_names.join(";"); let canonical_request = format!( "{}\n{}\n{}\n{}\n{}\n{}", method, canonical_uri, canonical_query, canonical_headers, signed_headers, body_hash ); // ── Step 3: Build string to sign ── let scope = format!("{}/{}/s3/aws4_request", datestamp, creds.region); let string_to_sign = format!( "AWS4-HMAC-SHA256\n{}\n{}\n{}", timestamp, scope, sha256_hex(canonical_request.as_bytes()) ); // ── Step 4: Derive signing key ── // HMAC(HMAC(HMAC(HMAC("AWS4"+secret, date), region), "s3"), "aws4_request") let k_date = hmac_sha256( format!("AWS4{}", creds.secret_access_key).as_bytes(), datestamp.as_bytes(), ); let k_region = hmac_sha256(&k_date, creds.region.as_bytes()); let k_service = hmac_sha256(&k_region, b"s3"); let k_signing = hmac_sha256(&k_service, b"aws4_request"); // ── Step 5: Compute signature ── let sig_bytes = hmac_sha256(&k_signing, string_to_sign.as_bytes()); let signature: String = sig_bytes.iter().map(|b| format!("{:02x}", b)).collect(); // ── Step 6: Add Authorization header ── let authorization = format!( "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", creds.access_key_id, scope, signed_headers, signature ); headers.insert("authorization", authorization.parse().unwrap()); } // ─── Error helpers ─────────────────────────────────────────── /// Redact a URL for safe inclusion in error messages (strips query parameters). fn redact_url(raw: &str) -> String { match Url::parse(raw) { Ok(parsed) => { let mut out = format!("{}://", parsed.scheme()); if let Some(host) = parsed.host_str() { out.push_str(host); } if let Some(port) = parsed.port() { out.push(':'); out.push_str(&port.to_string()); } out.push_str(parsed.path()); out } Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), } } fn s3_transport_error( key: &'static str, op_zh: &str, op_en: &str, err: &reqwest::Error, ) -> AppError { let (zh_reason, en_reason) = if err.is_timeout() { ("请求超时", "request timed out") } else if err.is_connect() { ("连接失败", "connection failed") } else if err.is_request() { ("请求构造失败", "request build failed") } else { ("网络请求失败", "network request failed") }; AppError::localized( key, format!("S3 {op_zh}失败({zh_reason})"), format!("S3 {op_en} failed ({en_reason})"), ) } fn s3_status_error(op: &str, status: StatusCode, url: &str) -> AppError { let safe_url = redact_url(url); let mut zh = format!("S3 {op} 失败: {status} ({safe_url})"); let mut en = format!("S3 {op} failed: {status} ({safe_url})"); if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { zh.push_str("。请检查 Access Key ID 和 Secret Access Key。"); en.push_str(". Please verify your Access Key ID and Secret Access Key."); } else if status == StatusCode::NOT_FOUND && op == "HEAD bucket" { zh.push_str("。请检查存储桶名称和区域是否正确。"); en.push_str(". Please check the bucket name and region."); } AppError::localized("s3.http.status", zh, en) } fn response_too_large_error(url: &str, max_bytes: usize) -> AppError { let max_mb = max_bytes / 1024 / 1024; AppError::localized( "s3.response_too_large", format!("S3 响应体超过上限({} MB): {}", max_mb, redact_url(url)), format!( "S3 response body exceeds limit ({} MB): {}", max_mb, redact_url(url) ), ) } fn ensure_content_length_within_limit( headers: &reqwest::header::HeaderMap, max_bytes: usize, url: &str, ) -> Result<(), AppError> { let Some(cl) = headers.get(reqwest::header::CONTENT_LENGTH) else { return Ok(()); }; let Ok(raw) = cl.to_str() else { return Ok(()); }; let Ok(value) = raw.parse::() else { return Ok(()); }; if value > max_bytes as u64 { return Err(response_too_large_error(url, max_bytes)); } Ok(()) } // ─── Transport functions ───────────────────────────────────── /// Test S3 connectivity by sending HEAD to the bucket root. pub(crate) async fn test_connection(creds: &S3Credentials) -> Result<(), AppError> { let url_str = build_bucket_url(creds); let url = Url::parse(&url_str).map_err(|e| { AppError::localized( "s3.url.invalid", format!("S3 URL 无效: {e}"), format!("Invalid S3 URL: {e}"), ) })?; let client = http_client::get(); let body_hash = sha256_hex(b""); let mut headers = reqwest::header::HeaderMap::new(); sign_request( "HEAD", &url, &mut headers, &body_hash, creds, chrono::Utc::now(), ); let resp = client .head(url.as_str()) .headers(headers) .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) .send() .await .map_err(|e| s3_transport_error("s3.connection_failed", "连接", "connection", &e))?; if resp.status().is_success() { return Ok(()); } Err(s3_status_error("HEAD bucket", resp.status(), &url_str)) } /// Upload bytes to an S3 object. pub(crate) async fn put_object( creds: &S3Credentials, key: &str, bytes: Vec, content_type: &str, ) -> Result<(), AppError> { let url_str = build_object_url(creds, key); let url = Url::parse(&url_str).map_err(|e| { AppError::localized( "s3.url.invalid", format!("S3 URL 无效: {e}"), format!("Invalid S3 URL: {e}"), ) })?; let client = http_client::get(); let body_hash = sha256_hex(&bytes); let mut headers = reqwest::header::HeaderMap::new(); headers.insert("content-type", content_type.parse().unwrap()); sign_request( "PUT", &url, &mut headers, &body_hash, creds, chrono::Utc::now(), ); let resp = client .put(url.as_str()) .headers(headers) .body(bytes) .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)) .send() .await .map_err(|e| s3_transport_error("s3.put_failed", "PUT 请求", "PUT request", &e))?; if resp.status().is_success() { return Ok(()); } Err(s3_status_error("PUT", resp.status(), &url_str)) } /// Download an S3 object. Returns `None` if the object does not exist (404). /// /// On success returns `(body_bytes, optional_etag)`. pub(crate) async fn get_object( creds: &S3Credentials, key: &str, max_bytes: usize, ) -> Result, Option)>, AppError> { let url_str = build_object_url(creds, key); let url = Url::parse(&url_str).map_err(|e| { AppError::localized( "s3.url.invalid", format!("S3 URL 无效: {e}"), format!("Invalid S3 URL: {e}"), ) })?; let client = http_client::get(); let body_hash = sha256_hex(b""); let mut headers = reqwest::header::HeaderMap::new(); sign_request( "GET", &url, &mut headers, &body_hash, creds, chrono::Utc::now(), ); let resp = client .get(url.as_str()) .headers(headers) .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)) .send() .await .map_err(|e| s3_transport_error("s3.get_failed", "GET 请求", "GET request", &e))?; if resp.status() == StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { return Err(s3_status_error("GET", resp.status(), &url_str)); } ensure_content_length_within_limit(resp.headers(), max_bytes, &url_str)?; let etag = resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let mut bytes = Vec::new(); let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| { AppError::localized( "s3.response_read_failed", format!("读取 S3 响应失败: {e}"), format!("Failed to read S3 response: {e}"), ) })?; if bytes.len().saturating_add(chunk.len()) > max_bytes { return Err(response_too_large_error(&url_str, max_bytes)); } bytes.extend_from_slice(&chunk); } Ok(Some((bytes, etag))) } /// Retrieve the ETag of an S3 object via HEAD. Returns `None` on 404. pub(crate) async fn head_object( creds: &S3Credentials, key: &str, ) -> Result, AppError> { let url_str = build_object_url(creds, key); let url = Url::parse(&url_str).map_err(|e| { AppError::localized( "s3.url.invalid", format!("S3 URL 无效: {e}"), format!("Invalid S3 URL: {e}"), ) })?; let client = http_client::get(); let body_hash = sha256_hex(b""); let mut headers = reqwest::header::HeaderMap::new(); sign_request( "HEAD", &url, &mut headers, &body_hash, creds, chrono::Utc::now(), ); let resp = client .head(url.as_str()) .headers(headers) .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) .send() .await .map_err(|e| s3_transport_error("s3.head_failed", "HEAD 请求", "HEAD request", &e))?; if resp.status() == StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { return Err(s3_status_error("HEAD", resp.status(), &url_str)); } Ok(resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string())) } // ─── Tests ─────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; // ── Crypto helpers ── #[test] fn sha256_hex_empty_body() { assert_eq!( sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ); } #[test] fn sha256_hex_known_value() { // From the AWS S3 documentation PUT Object example body. assert_eq!( sha256_hex(b"Welcome to Amazon S3."), "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072" ); } #[test] fn hmac_sha256_rfc2104_test_vector() { // HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog") let result = hmac_sha256(b"key", b"The quick brown fox jumps over the lazy dog"); let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect(); assert_eq!( hex, "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" ); } // ── URL construction: virtual-hosted vs path-style ── #[test] fn build_object_url_virtual_hosted_style_aws() { let creds = test_creds("", "us-east-1", "mybucket"); assert_eq!( build_object_url(&creds, "path/to/file.json"), "https://mybucket.s3.us-east-1.amazonaws.com/path/to/file.json" ); } #[test] fn build_object_url_virtual_hosted_explicit_aws_endpoint() { let creds = test_creds( "s3.ap-northeast-1.amazonaws.com", "ap-northeast-1", "mybucket", ); assert_eq!( build_object_url(&creds, "key.txt"), "https://mybucket.s3.ap-northeast-1.amazonaws.com/key.txt" ); } #[test] fn build_object_url_path_style_custom_endpoint() { let creds = test_creds("minio.example.com:9000", "us-east-1", "mybucket"); assert_eq!( build_object_url(&creds, "path/to/file.json"), "https://minio.example.com:9000/mybucket/path/to/file.json" ); } #[test] fn build_object_url_strips_leading_slash_from_key() { let creds = test_creds("", "us-east-1", "b"); assert_eq!( build_object_url(&creds, "/leading/slash.txt"), "https://b.s3.us-east-1.amazonaws.com/leading/slash.txt" ); } #[test] fn build_bucket_url_aws() { let creds = test_creds("", "us-west-2", "testbucket"); assert_eq!( build_bucket_url(&creds), "https://testbucket.s3.us-west-2.amazonaws.com/" ); } #[test] fn build_bucket_url_custom_endpoint() { let creds = test_creds("storage.example.com", "us-east-1", "data"); assert_eq!( build_bucket_url(&creds), "https://storage.example.com/data/" ); } #[test] fn build_object_url_endpoint_with_trailing_slash() { let creds = test_creds("minio.local:9000/", "us-east-1", "b"); assert_eq!( build_object_url(&creds, "k"), "https://minio.local:9000/b/k" ); } #[test] fn build_object_url_endpoint_with_scheme_prefix() { let creds = test_creds("https://minio.local:9000", "us-east-1", "b"); assert_eq!( build_object_url(&creds, "k"), "https://minio.local:9000/b/k" ); } // ── HTTP scheme preservation (MinIO support) ── #[test] fn build_object_url_preserves_http_scheme() { let creds = test_creds("http://minio:9000", "us-east-1", "mybucket"); let url = build_object_url(&creds, "path/to/file.json"); assert!( url.starts_with("http://"), "expected http:// scheme, got: {url}" ); assert_eq!(url, "http://minio:9000/mybucket/path/to/file.json"); } #[test] fn build_object_url_preserves_https_scheme() { let creds = test_creds("https://storage.example.com", "us-east-1", "mybucket"); let url = build_object_url(&creds, "path/to/file.json"); assert!( url.starts_with("https://"), "expected https:// scheme, got: {url}" ); assert_eq!( url, "https://storage.example.com/mybucket/path/to/file.json" ); } #[test] fn build_object_url_bare_endpoint_defaults_to_https() { let creds = test_creds("minio:9000", "us-east-1", "mybucket"); let url = build_object_url(&creds, "path/to/file.json"); assert!( url.starts_with("https://"), "bare endpoint should default to https://, got: {url}" ); assert_eq!(url, "https://minio:9000/mybucket/path/to/file.json"); } #[test] fn build_bucket_url_preserves_http_scheme() { let creds = test_creds("http://minio:9000", "us-east-1", "data"); let url = build_bucket_url(&creds); assert!( url.starts_with("http://"), "expected http:// scheme, got: {url}" ); assert_eq!(url, "http://minio:9000/data/"); } // ── Endpoint detection ── #[test] fn is_aws_endpoint_detection() { assert!(is_aws_endpoint(""), "empty string = AWS"); assert!(is_aws_endpoint("s3.us-east-1.amazonaws.com")); assert!(is_aws_endpoint("s3.amazonaws.com")); assert!(!is_aws_endpoint("minio.example.com")); assert!(!is_aws_endpoint("storage.googleapis.com")); assert!(!is_aws_endpoint("r2.cloudflarestorage.com")); } // ── URI encoding ── #[test] fn uri_encode_preserves_unreserved_chars() { assert_eq!( uri_encode("hello-world_test.txt~v2", true), "hello-world_test.txt~v2" ); } #[test] fn uri_encode_encodes_spaces_and_special_chars() { assert_eq!(uri_encode("hello world", true), "hello%20world"); assert_eq!(uri_encode("a+b=c&d", true), "a%2Bb%3Dc%26d"); } #[test] fn uri_encode_slash_handling() { assert_eq!(uri_encode("path/to/file", false), "path/to/file"); assert_eq!(uri_encode("path/to/file", true), "path%2Fto%2Ffile"); } // ── AWS Signature V4 signing ── #[test] fn sig_v4_signing_against_aws_test_vector() { // Based on the AWS documentation example: GET /?lifecycle // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html let creds = S3Credentials { access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), region: "us-east-1".to_string(), bucket: "examplebucket".to_string(), endpoint: String::new(), }; let now = chrono::Utc.with_ymd_and_hms(2013, 5, 24, 0, 0, 0).unwrap(); let url = Url::parse("https://examplebucket.s3.amazonaws.com/?lifecycle").unwrap(); let body_hash = sha256_hex(b""); let mut headers = reqwest::header::HeaderMap::new(); sign_request("GET", &url, &mut headers, &body_hash, &creds, now); let auth = headers.get("authorization").unwrap().to_str().unwrap(); // Verify credential scope assert!( auth.starts_with( "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request" ), "unexpected credential scope in: {auth}" ); // Verify signed headers assert!( auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"), "unexpected signed headers in: {auth}" ); // Verify exact signature from AWS documentation assert!( auth.contains( "Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543" ), "signature mismatch in: {auth}" ); } #[test] fn sig_v4_includes_content_type_when_present() { let creds = S3Credentials { access_key_id: "TESTKEY".to_string(), secret_access_key: "TESTSECRET".to_string(), region: "us-east-1".to_string(), bucket: "b".to_string(), endpoint: String::new(), }; let now = chrono::Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let url = Url::parse("https://b.s3.us-east-1.amazonaws.com/key.json").unwrap(); let body_hash = sha256_hex(b"{}"); let mut headers = reqwest::header::HeaderMap::new(); headers.insert("content-type", "application/json".parse().unwrap()); sign_request("PUT", &url, &mut headers, &body_hash, &creds, now); let auth = headers.get("authorization").unwrap().to_str().unwrap(); // content-type must appear in the signed headers assert!( auth.contains("content-type"), "content-type should be in signed headers: {auth}" ); } #[test] fn sig_v4_signing_key_derivation() { // Verify the signing key derivation chain independently. // Using the same AWS example credentials and date. let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; let datestamp = "20130524"; let region = "us-east-1"; let k_date = hmac_sha256(format!("AWS4{}", secret).as_bytes(), datestamp.as_bytes()); let k_region = hmac_sha256(&k_date, region.as_bytes()); let k_service = hmac_sha256(&k_region, b"s3"); let k_signing = hmac_sha256(&k_service, b"aws4_request"); // The signing key should be a 32-byte value (256 bits). assert_eq!(k_signing.len(), 32); // Verify it is deterministic — computing again yields the same result. let k_date2 = hmac_sha256(format!("AWS4{}", secret).as_bytes(), datestamp.as_bytes()); let k_region2 = hmac_sha256(&k_date2, region.as_bytes()); let k_service2 = hmac_sha256(&k_region2, b"s3"); let k_signing2 = hmac_sha256(&k_service2, b"aws4_request"); assert_eq!(k_signing, k_signing2); } // ── Redact URL ── #[test] fn redact_url_strips_query_params() { let r = redact_url( "https://mybucket.s3.us-east-1.amazonaws.com/file.txt?X-Amz-Credential=AKID&X-Amz-Signature=abc", ); assert!(!r.contains("AKID")); assert!(!r.contains("abc")); assert!(r.contains("mybucket.s3.us-east-1.amazonaws.com")); assert!(r.contains("/file.txt")); } #[test] fn redact_url_preserves_path() { let r = redact_url("https://minio.local:9000/bucket/path/to/file.json"); assert_eq!(r, "https://minio.local:9000/bucket/path/to/file.json"); } // ── Content-length check ── #[test] fn ensure_content_length_within_limit_rejects_oversized() { use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH}; let mut h = HeaderMap::new(); h.insert(CONTENT_LENGTH, HeaderValue::from_static("2048")); assert!(ensure_content_length_within_limit(&h, 1024, "https://example.com").is_err()); } #[test] fn ensure_content_length_within_limit_accepts_within_bounds() { use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH}; let mut h = HeaderMap::new(); h.insert(CONTENT_LENGTH, HeaderValue::from_static("512")); assert!(ensure_content_length_within_limit(&h, 1024, "https://example.com").is_ok()); let empty = HeaderMap::new(); assert!(ensure_content_length_within_limit(&empty, 1024, "https://example.com").is_ok()); } // ── Helper ── fn test_creds(endpoint: &str, region: &str, bucket: &str) -> S3Credentials { S3Credentials { access_key_id: String::new(), secret_access_key: String::new(), region: region.to_string(), bucket: bucket.to_string(), endpoint: endpoint.to_string(), } } } // ─── Live integration tests (run with --ignored) ───────────── #[cfg(test)] mod integration_tests { use super::*; fn test_creds() -> S3Credentials { S3Credentials { access_key_id: std::env::var("S3_TEST_AK").expect("S3_TEST_AK env required"), secret_access_key: std::env::var("S3_TEST_SK").expect("S3_TEST_SK env required"), region: std::env::var("S3_TEST_REGION").unwrap_or_else(|_| "us-east-1".to_string()), bucket: std::env::var("S3_TEST_BUCKET").expect("S3_TEST_BUCKET env required"), endpoint: std::env::var("S3_TEST_ENDPOINT").unwrap_or_default(), } } #[tokio::test] #[ignore] async fn live_s3_connection() { crate::proxy::http_client::init(None).ok(); let creds = test_creds(); let result = test_connection(&creds).await; assert!(result.is_ok(), "Connection failed: {:?}", result.err()); println!("PASS: test_connection OK"); } #[tokio::test] #[ignore] async fn live_s3_put_get_head_roundtrip() { crate::proxy::http_client::init(None).ok(); let creds = test_creds(); let key = "cc-switch-sync/v2/default/_integration_test.json"; let data = br#"{"test":true,"ts":12345}"#; // PUT let r = put_object(&creds, key, data.to_vec(), "application/json").await; assert!(r.is_ok(), "PUT failed: {:?}", r.err()); println!("PASS: put_object {} bytes", data.len()); // GET let r = get_object(&creds, key, 1 << 20).await; assert!(r.is_ok(), "GET failed: {:?}", r.err()); let (body, etag) = r.unwrap().expect("should exist"); assert_eq!(body, data); println!("PASS: get_object {} bytes, etag={:?}", body.len(), etag); // HEAD let r = head_object(&creds, key).await; assert!(r.is_ok(), "HEAD failed: {:?}", r.err()); assert!(r.unwrap().is_some()); println!("PASS: head_object OK"); // 404 let r = get_object(&creds, "cc-switch-sync/_no_such_key", 1024).await; assert!(r.is_ok()); assert!(r.unwrap().is_none()); println!("PASS: get_object(404) returned None"); println!("ALL LIVE S3 TESTS PASSED"); } }