This commit is contained in:
2026-04-21 19:17:31 +08:00
parent d234d27cc0
commit 89129ef1f4
83 changed files with 13329 additions and 176 deletions

View File

@@ -7,7 +7,12 @@ license.workspace = true
[dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
time = { version = "0.3", features = ["formatting"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@@ -17,16 +17,23 @@
1. `PostObject` 浏览器直传签名
2.`/generated-*` 公开前缀到 OSS `object_key` 的兼容映射
3. `object_key -> publicUrl` 解析
4. `x-oss-meta-*` 元数据归一化与大小限制校验
5. `content-type``content-length-range``success_action_status` policy 条件生成
3. 私有对象短期签名读 URL
4. 私有对象 `HEAD Object` 探测
5. 服务端 `PutObject` 上传 helper
6. `x-oss-meta-*` 元数据归一化与大小限制校验
7. `content-type``content-length-range``success_action_status` policy 条件生成
当前仍未落地的内容:
1. `STS` 临时授权
2. 服务端上传 helper
3. 私有对象签名 URL
4. 对象确认与业务绑定
1. `STS` 真实临时授权下发
2. multipart 分片上传
3. 内容 hash 自动计算与标签写入
补充说明:
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract避免浏览器拿到 OSS 写权限。
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`
## 3. 边界约束

View File

@@ -1,7 +1,9 @@
use std::{collections::BTreeMap, error::Error, fmt};
use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use httpdate::fmt_http_date;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha1::Sha1;
@@ -10,6 +12,7 @@ use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha1 = Hmac<Sha1>;
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
@@ -46,7 +49,7 @@ pub struct OssConfig {
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
@@ -65,6 +68,28 @@ pub struct OssPostObjectRequest {
pub success_action_status: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssSignedGetObjectUrlRequest {
pub object_key: String,
pub expire_seconds: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectRequest {
pub object_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPutObjectRequest {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub metadata: BTreeMap<String, String>,
pub body: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectResponse {
#[serde(rename = "signatureVersion")]
@@ -77,8 +102,6 @@ pub struct OssPostObjectResponse {
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "publicUrl", skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub access: OssObjectAccess,
@@ -94,6 +117,51 @@ pub struct OssPostObjectResponse {
pub form_fields: OssPostObjectFormFields,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssSignedGetObjectUrlResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
#[serde(rename = "signedUrl")]
pub signed_url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectResponse {
pub bucket: String,
pub object_key: String,
pub content_length: u64,
pub content_type: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPutObjectResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(rename = "contentLength")]
pub content_length: u64,
pub access: OssObjectAccess,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectFormFields {
pub key: String,
@@ -119,6 +187,8 @@ pub struct OssClient {
pub enum OssError {
InvalidConfig(String),
InvalidRequest(String),
ObjectNotFound(String),
Request(String),
SerializePolicy(String),
Sign(String),
}
@@ -157,6 +227,12 @@ impl LegacyAssetPrefix {
pub fn as_public_path_prefix(&self) -> String {
format!("/{}", self.as_str())
}
pub fn from_object_key(raw: &str) -> Option<Self> {
let normalized = raw.trim().trim_start_matches('/').trim();
let prefix = normalized.split('/').next()?;
Self::parse(prefix)
}
}
impl OssConfig {
@@ -166,7 +242,7 @@ impl OssConfig {
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
@@ -176,7 +252,12 @@ impl OssConfig {
let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?;
let access_key_secret =
normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?;
let public_base_url = normalize_optional_base_url(public_base_url);
if default_read_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS 私有读签名有效期必须大于 0".to_string(),
));
}
if default_post_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
@@ -201,7 +282,7 @@ impl OssConfig {
endpoint,
access_key_id,
access_key_secret,
public_base_url,
default_read_expire_seconds,
default_post_expire_seconds,
default_post_max_size_bytes,
default_success_action_status,
@@ -219,6 +300,14 @@ impl OssConfig {
pub fn bucket(&self) -> &str {
&self.bucket
}
pub fn access_key_id(&self) -> &str {
&self.access_key_id
}
pub fn access_key_secret(&self) -> &str {
&self.access_key_secret
}
}
impl OssClient {
@@ -226,6 +315,10 @@ impl OssClient {
Self { config }
}
pub fn config_bucket(&self) -> &str {
self.config.bucket()
}
pub fn sign_post_object(
&self,
request: OssPostObjectRequest,
@@ -293,11 +386,6 @@ impl OssClient {
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?;
let public_url = match request.access {
OssObjectAccess::Public => Some(self.public_url_for(&object_key)),
OssObjectAccess::Private => None,
};
Ok(OssPostObjectResponse {
signature_version: "v1",
provider: "aliyun-oss",
@@ -306,7 +394,6 @@ impl OssClient {
host: self.config.upload_host(),
object_key: object_key.clone(),
legacy_public_path,
public_url,
content_type: content_type.clone(),
access: request.access,
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
@@ -325,14 +412,189 @@ impl OssClient {
})
}
fn public_url_for(&self, object_key: &str) -> String {
let base_url = self
.config
.public_base_url
.clone()
.unwrap_or_else(|| self.config.upload_host());
// 私有 bucket 的对象读取统一走短期签名 URL避免把长期主凭证下发给浏览器。
pub fn sign_get_object_url(
&self,
request: OssSignedGetObjectUrlRequest,
) -> Result<OssSignedGetObjectUrlResponse, OssError> {
let expire_seconds = request
.expire_seconds
.unwrap_or(self.config.default_read_expire_seconds);
format!("{}/{}", base_url.trim_end_matches('/'), object_key)
if expire_seconds == 0 {
return Err(OssError::InvalidRequest(
"expireSeconds 必须大于 0".to_string(),
));
}
let object_key = normalize_object_key(&request.object_key)?;
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
)?))
.ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
let expires_at_text = expires_at
.format(&Rfc3339)
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
let expires_epoch_seconds = expires_at.unix_timestamp();
let canonical_resource = build_canonical_object_resource(&self.config.bucket, &object_key);
let string_to_sign = format!("GET\n\n\n{expires_epoch_seconds}\n{canonical_resource}");
let signature = sign_policy(&self.config.access_key_secret, &string_to_sign)?;
let signed_url = format!(
"{}/{}?OSSAccessKeyId={}&Expires={}&Signature={}",
self.config.upload_host(),
encode_url_path(&object_key),
encode_url_query_value(&self.config.access_key_id),
expires_epoch_seconds,
encode_url_query_value(&signature)
);
Ok(OssSignedGetObjectUrlResponse {
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key,
expires_at: expires_at_text,
signed_url,
})
}
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
pub async fn head_object(
&self,
client: &reqwest::Client,
request: OssHeadObjectRequest,
) -> Result<OssHeadObjectResponse, OssError> {
let object_key = normalize_object_key(&request.object_key)?;
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
let response = send_signed_request(
client,
&self.config,
Method::HEAD,
Some(&object_key),
target_url,
)
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(OssError::ObjectNotFound(format!(
"OSS 对象不存在:{}",
request.object_key
)));
}
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS HEAD Object 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let content_length = headers
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssHeadObjectResponse {
bucket: self.config.bucket.clone(),
object_key,
content_length,
content_type,
etag,
last_modified,
})
}
// AI 生成资源默认由服务端上传 OSSWeb 端只拿签名读地址,不直接持有写权限。
pub async fn put_object(
&self,
client: &reqwest::Client,
request: OssPutObjectRequest,
) -> Result<OssPutObjectResponse, OssError> {
if request.body.is_empty() {
return Err(OssError::InvalidRequest(
"服务端上传对象内容不能为空".to_string(),
));
}
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name)?;
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
let content_length = u64::try_from(request.body.len())
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
let builder = signed_request_builder(
client,
&self.config,
Method::PUT,
Some(&object_key),
target_url,
content_type.as_deref(),
&metadata,
)?
.header(reqwest::header::CONTENT_LENGTH, content_length)
.body(request.body);
let response = builder
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS PutObject 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssPutObjectResponse {
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
legacy_public_path: format!("/{object_key}"),
object_key,
content_type,
content_length,
access: request.access,
etag,
last_modified,
})
}
}
@@ -341,6 +603,8 @@ impl fmt::Display for OssError {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::ObjectNotFound(message)
| Self::Request(message)
| Self::SerializePolicy(message)
| Self::Sign(message) => f.write_str(message),
}
@@ -383,6 +647,23 @@ fn build_policy_json(
})
}
fn build_object_url(
bucket: &str,
endpoint: &str,
object_key: &str,
) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))
.map_err(|error| error.to_string())?;
url = url
.join(object_key.trim_start_matches('/'))
.map_err(|error| error.to_string())?;
Ok(url)
}
fn build_canonical_object_resource(bucket: &str, object_key: &str) -> String {
format!("/{bucket}/{object_key}")
}
fn build_object_key(
prefix: LegacyAssetPrefix,
path_segments: &[String],
@@ -395,6 +676,42 @@ fn build_object_key(
parts.join("/")
}
fn normalize_object_key(raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
if normalized.is_empty() {
return Err(OssError::InvalidRequest("objectKey 不能为空".to_string()));
}
if LegacyAssetPrefix::from_object_key(&normalized).is_none() {
return Err(OssError::InvalidRequest(
"objectKey 必须落在受支持的 generated-* 前缀下".to_string(),
));
}
let segments = normalized.split('/').collect::<Vec<_>>();
if segments.len() < 2 {
return Err(OssError::InvalidRequest(
"objectKey 至少需要包含前缀和文件名".to_string(),
));
}
for segment in &segments {
if segment.is_empty() || *segment == "." || *segment == ".." {
return Err(OssError::InvalidRequest(
"objectKey 包含非法路径片段".to_string(),
));
}
if segment.contains('\\') {
return Err(OssError::InvalidRequest(
"objectKey 不能包含反斜杠".to_string(),
));
}
}
Ok(normalized)
}
fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String {
let mut parts = Vec::with_capacity(path_segments.len() + 1);
parts.push(prefix.as_str().to_string());
@@ -541,10 +858,6 @@ fn normalize_optional_value(value: Option<String>) -> Option<String> {
})
}
fn normalize_optional_base_url(value: Option<String>) -> Option<String> {
normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string())
}
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
let endpoint = raw
.trim()
@@ -588,6 +901,105 @@ fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String,
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
}
async fn send_signed_request(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
) -> Result<reqwest::Response, OssError> {
signed_request_builder(
client,
config,
method,
object_key,
target_url,
None,
&BTreeMap::new(),
)?
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))
}
fn signed_request_builder(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
content_type: Option<&str>,
oss_headers: &BTreeMap<String, String>,
) -> Result<reqwest::RequestBuilder, OssError> {
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => {
build_canonical_object_resource(config.bucket(), object_key.trim_start_matches('/'))
}
None => format!("/{}/", config.bucket()),
};
let canonicalized_oss_headers = build_canonicalized_oss_headers(oss_headers);
let string_to_sign = format!(
"{}\n\n{}\n{}\n{}{}",
method.as_str(),
content_type.unwrap_or_default(),
date,
canonicalized_oss_headers,
canonical_resource
);
let signature = sign_policy(config.access_key_secret(), &string_to_sign)?;
let mut builder = client
.request(method, target_url)
.header("Date", date)
.header(
"Authorization",
format!("OSS {}:{}", config.access_key_id(), signature),
);
if let Some(content_type) = content_type {
builder = builder.header(reqwest::header::CONTENT_TYPE, content_type);
}
for (key, value) in oss_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
fn build_canonicalized_oss_headers(headers: &BTreeMap<String, String>) -> String {
headers
.iter()
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
.collect::<String>()
}
fn encode_url_path(path: &str) -> String {
path.split('/')
.map(encode_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_url_query_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
_ => {
use std::fmt::Write as _;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
@@ -599,7 +1011,7 @@ mod tests {
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
Some("https://cdn.genarrative.local".to_string()),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
@@ -618,7 +1030,7 @@ mod tests {
}
#[test]
fn sign_post_object_returns_legacy_compatible_key_and_urls() {
fn sign_post_object_returns_bucket_and_object_key_for_private_storage_truth() {
let client = build_client();
let mut metadata = BTreeMap::new();
metadata.insert("asset-kind".to_string(), "character-visual".to_string());
@@ -650,12 +1062,7 @@ mod tests {
response.legacy_public_path,
"/generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(
response.public_url.as_deref(),
Some(
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
)
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.form_fields.oss_access_key_id,
"test-access-key-id".to_string()
@@ -713,7 +1120,7 @@ mod tests {
policy["conditions"][4],
json!(["eq", "$content-type", "image/png"])
);
assert!(response.public_url.is_none());
assert_eq!(response.bucket, "genarrative-assets".to_string());
}
#[test]
@@ -725,4 +1132,145 @@ mod tests {
OssError::InvalidRequest("fileName 不能为空".to_string())
);
}
#[test]
fn sign_get_object_url_returns_signed_private_read_url() {
let client = build_client();
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "generated-characters/hero_001/visual/asset_01/master.png".to_string(),
expire_seconds: Some(300),
})
.expect("signed get url should build");
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.object_key,
"generated-characters/hero_001/visual/asset_01/master.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/generated-characters/hero_001/visual/asset_01/master.png?"));
assert!(
response
.signed_url
.contains("OSSAccessKeyId=test-access-key-id")
);
assert!(response.signed_url.contains("&Expires="));
assert!(response.signed_url.contains("&Signature="));
}
#[test]
fn sign_get_object_url_rejects_unsupported_prefix() {
let client = build_client();
let error = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "workflow-cache/task-1.json".to_string(),
expire_seconds: Some(300),
})
.expect_err("unsupported prefix should fail");
assert_eq!(
error,
OssError::InvalidRequest("objectKey 必须落在受支持的 generated-* 前缀下".to_string())
);
}
#[test]
fn legacy_prefix_can_be_resolved_from_object_key() {
assert_eq!(
LegacyAssetPrefix::from_object_key(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png"
),
Some(LegacyAssetPrefix::CustomWorldScenes)
);
assert_eq!(
LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"),
None
);
}
#[test]
fn put_object_request_reuses_generated_object_key_contract() {
let request = OssPutObjectRequest {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec!["Profile 001".to_string(), "asset_01".to_string()],
file_name: "Cover.PNG".to_string(),
content_type: Some(" image/png ".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::from([
("asset_kind".to_string(), "custom_world_cover".to_string()),
("source job id".to_string(), "job_001".to_string()),
]),
body: b"cover-bytes".to_vec(),
};
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name).expect("file name should sanitize");
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let metadata = normalize_metadata(request.metadata).expect("metadata should normalize");
assert_eq!(
object_key,
"generated-custom-world-covers/profile-001/asset_01/cover.png"
);
assert_eq!(
metadata.get("x-oss-meta-asset-kind"),
Some(&"custom_world_cover".to_string())
);
assert_eq!(
metadata.get("x-oss-meta-source-job-id"),
Some(&"job_001".to_string())
);
}
#[test]
fn canonicalized_oss_headers_matches_oss_v1_upload_signature_shape() {
let headers = BTreeMap::from([
(
"x-oss-meta-source-job-id".to_string(),
" job_001 ".to_string(),
),
(
"x-oss-meta-asset-kind".to_string(),
"character_visual".to_string(),
),
]);
assert_eq!(
build_canonicalized_oss_headers(&headers),
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}
#[tokio::test]
async fn put_object_rejects_empty_body_before_calling_oss() {
let client = build_client();
let error = client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Characters,
path_segments: vec!["hero".to_string()],
file_name: "master.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
body: Vec::new(),
},
)
.await
.expect_err("empty server upload should fail before network");
assert_eq!(
error,
OssError::InvalidRequest("服务端上传对象内容不能为空".to_string())
);
}
}