diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index ffe8c85e..266e8e0b 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -14,7 +14,13 @@ - 关联:相关文件、文档、提交或 Issue ``` ---- +## OSS V4 签名时间和 bucket/object_key 兼容 + +- 现象:OSS V4 私有读签名在部分时间点失败,可能出现 `OSS V4 签名时间格式化失败` 或服务端判定签名格式错误;排查用例中 bucket 为 `xushi-dev`,object_key 为 `generated-square-hole-assets/.../image.png`。 +- 原因:旧逻辑依赖 `time::Time::to_string()` 再去掉冒号,小时小于 10 时输出不稳定补零;同时排查时容易把 bucket 名误当成 object_key 的一部分。 +- 处理:OSS V4 `x-oss-date` 使用固定宽度 `yyyyMMdd'T'HHmmss'Z'` 格式化;调用读签名或 `HEAD Object` 时只传 object_key,不要传 `bucket/object_key` 拼接路径。 +- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。 +- 关联:`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`。 ## 中文乱码与编码风险 diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 5e98938d..025481d3 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -34,6 +34,8 @@ 1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。 2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。 3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。 + 4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。 + 5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。 ## 3. 边界约束 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 54c401d6..27cf0c65 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -419,8 +419,11 @@ impl OssClient { let policy = serde_json::to_string(&policy_json) .map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?; let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes()); - let signature = - sign_v4_content(&self.config.access_key_secret, &signature_scope, &encoded_policy)?; + let signature = sign_v4_content( + &self.config.access_key_secret, + &signature_scope, + &encoded_policy, + )?; Ok(OssPostObjectResponse { signature_version: "v4", @@ -492,11 +495,8 @@ impl OssClient { let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key)); let object_url_path = format!("/{}", encode_url_path(&object_key)); let additional_headers = "host"; - let canonical_headers = format!( - "host:{}.{}\n", - self.config.bucket(), - self.config.endpoint() - ); + let canonical_headers = + format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint()); let canonical_query = build_canonical_query_string(&query); let canonical_request = build_v4_canonical_request( Method::GET.as_str(), @@ -506,10 +506,16 @@ impl OssClient { additional_headers, OSS_UNSIGNED_PAYLOAD, ); - let string_to_sign = - build_v4_string_to_sign(query["x-oss-date"].as_str(), &signature_scope, &canonical_request); - let signature = - sign_v4_content(&self.config.access_key_secret, &signature_scope, &string_to_sign)?; + let string_to_sign = build_v4_string_to_sign( + query["x-oss-date"].as_str(), + &signature_scope, + &canonical_request, + ); + let signature = sign_v4_content( + &self.config.access_key_secret, + &signature_scope, + &string_to_sign, + )?; query.insert("x-oss-signature".to_string(), signature); let signed_url = format!( "{}{}?{}", @@ -1036,8 +1042,13 @@ fn signed_request_builder( additional_headers, &body_sha256, ); - let string_to_sign = build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request); - let signature = sign_v4_content(config.access_key_secret(), &signature_scope, &string_to_sign)?; + let string_to_sign = + build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request); + let signature = sign_v4_content( + config.access_key_secret(), + &signature_scope, + &string_to_sign, + )?; let mut builder = client .request(method, target_url) .header("x-oss-content-sha256", body_sha256) @@ -1065,33 +1076,23 @@ fn signed_request_builder( } fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result { - let date = signed_at - .date() - .to_string() - .replace('-', ""); + let date = signed_at.date().to_string().replace('-', ""); let region = extract_oss_region(endpoint)?; Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}")) } fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result { - let date = signed_at - .date() - .to_string() - .replace('-', ""); - let time = signed_at - .time() - .to_string() - .split('.') - .next() - .unwrap_or("00:00:00") - .replace(':', ""); - - if time.len() != 6 { - return Err(OssError::Sign("OSS V4 签名时间格式化失败".to_string())); - } - - Ok(format!("{date}T{time}Z")) + // 中文注释:time::Time 的 Display 在小时小于 10 时不会稳定补零,OSS V4 必须使用固定宽度 UTC 时间。 + Ok(format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + signed_at.year(), + u8::from(signed_at.month()), + signed_at.day(), + signed_at.hour(), + signed_at.minute(), + signed_at.second() + )) } fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String { @@ -1116,9 +1117,7 @@ fn extract_oss_region(endpoint: &str) -> Result { .map(str::to_string) .filter(|region| !region.is_empty()) .ok_or_else(|| { - OssError::InvalidConfig(format!( - "OSS endpoint 无法解析 region,当前值:{endpoint}" - )) + OssError::InvalidConfig(format!("OSS endpoint 无法解析 region,当前值:{endpoint}")) }) } @@ -1131,7 +1130,10 @@ fn sign_v4_content( Ok(hex_sha256_hmac(&signing_key, content.as_bytes())) } -fn build_v4_signing_key(access_key_secret: &str, signature_scope: &str) -> Result, OssError> { +fn build_v4_signing_key( + access_key_secret: &str, + signature_scope: &str, +) -> Result, OssError> { let mut parts = signature_scope.split('/'); let date = parts .next() @@ -1160,8 +1162,7 @@ fn hmac_sha256_raw(key: &[u8], content: &str) -> Result, OssError> { } fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String { - let mut signer = HmacSha256::new_from_slice(key) - .expect("HMAC-SHA256 accepts keys of any size"); + let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any size"); signer.update(content); hex_lower(&signer.finalize().into_bytes()) } @@ -1213,7 +1214,13 @@ fn build_v4_canonical_headers(headers: &BTreeMap) -> String { fn build_canonical_query_string(params: &BTreeMap) -> String { params .iter() - .map(|(key, value)| format!("{}={}", encode_url_query_value(key), encode_url_query_value(value))) + .map(|(key, value)| { + format!( + "{}={}", + encode_url_query_value(key), + encode_url_query_value(value) + ) + }) .collect::>() .join("&") } @@ -1327,18 +1334,19 @@ mod tests { response.form_fields.signature_version, OSS_V4_ALGORITHM.to_string() ); - assert!(response - .form_fields - .credential - .starts_with("test-access-key-id/")); - assert!(response - .form_fields - .credential - .ends_with("/cn-shanghai/oss/aliyun_v4_request")); - assert_eq!( - response.form_fields.date.len(), - "20260507T120000Z".len() + assert!( + response + .form_fields + .credential + .starts_with("test-access-key-id/") ); + assert!( + response + .form_fields + .credential + .ends_with("/cn-shanghai/oss/aliyun_v4_request") + ); + assert_eq!(response.form_fields.date.len(), "20260507T120000Z".len()); assert_eq!( response.form_fields.metadata.get("x-oss-meta-asset-kind"), Some(&"character-visual".to_string()) @@ -1441,13 +1449,48 @@ mod tests { .signed_url .contains("x-oss-signature-version=OSS4-HMAC-SHA256") ); - assert!(response - .signed_url - .contains("x-oss-credential=test-access-key-id%2F")); + assert!( + response + .signed_url + .contains("x-oss-credential=test-access-key-id%2F") + ); assert!(response.signed_url.contains("&x-oss-expires=300")); assert!(response.signed_url.contains("&x-oss-signature=")); } + #[test] + fn sign_get_object_url_uses_square_hole_object_key_without_bucket_prefix() { + let client = OssClient::new( + OssConfig::new( + "xushi-dev".to_string(), + "oss-cn-shanghai.aliyuncs.com".to_string(), + "test-access-key-id".to_string(), + "test-access-key-secret".to_string(), + DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_POST_EXPIRE_SECONDS, + DEFAULT_POST_MAX_SIZE_BYTES, + DEFAULT_SUCCESS_ACTION_STATUS, + ) + .expect("OSS config should be valid"), + ); + + let response = client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(), + expire_seconds: Some(300), + }) + .expect("square hole object key should build signed url"); + + assert_eq!(response.bucket, "xushi-dev".to_string()); + assert_eq!( + response.object_key, + "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string() + ); + assert!(response + .signed_url + .starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?")); + } + #[test] fn sign_get_object_url_rejects_unsupported_prefix() { let client = build_client();