Files
Genarrative/server-rs/crates/platform-oss/src/lib.rs

1636 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{collections::BTreeMap, error::Error, fmt};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha256 = Hmac<Sha256>;
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;
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 9] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
"generated-big-fish-assets",
"generated-square-hole-assets",
"generated-puzzle-assets",
"generated-custom-world-scenes",
"generated-custom-world-covers",
"generated-qwen-sprites",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OssObjectAccess {
Public,
Private,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LegacyAssetPrefix {
CharacterDrafts,
Characters,
Animations,
BigFishAssets,
SquareHoleAssets,
PuzzleAssets,
CustomWorldScenes,
CustomWorldCovers,
QwenSprites,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssConfig {
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPostObjectRequest {
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 max_size_bytes: Option<u64>,
pub expire_seconds: Option<u64>,
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")]
pub signature_version: &'static str,
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>,
pub access: OssObjectAccess,
#[serde(rename = "keyPrefix")]
pub key_prefix: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
#[serde(rename = "maxSizeBytes")]
pub max_size_bytes: u64,
#[serde(rename = "successActionStatus")]
pub success_action_status: u16,
#[serde(rename = "formFields")]
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,
pub policy: String,
#[serde(rename = "x-oss-signature-version")]
pub signature_version: String,
#[serde(rename = "x-oss-credential")]
pub credential: String,
#[serde(rename = "x-oss-date")]
pub date: String,
#[serde(rename = "x-oss-signature")]
pub signature: String,
#[serde(rename = "success_action_status")]
pub success_action_status: String,
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct OssClient {
config: OssConfig,
}
#[derive(Debug, PartialEq, Eq)]
pub enum OssError {
InvalidConfig(String),
InvalidRequest(String),
ObjectNotFound(String),
Request(String),
SerializePolicy(String),
Sign(String),
}
// 平台 OSS 错误只先归类,不在 platform 层绑定 HTTP status。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OssErrorKind {
InvalidConfig,
InvalidRequest,
ObjectNotFound,
Request,
SerializePolicy,
Sign,
}
impl LegacyAssetPrefix {
pub fn parse(raw: &str) -> Option<Self> {
let normalized = raw
.trim()
.trim_start_matches('/')
.trim_end_matches('/')
.trim_end_matches('*')
.trim_end_matches('/');
match normalized {
"generated-character-drafts" => Some(Self::CharacterDrafts),
"generated-characters" => Some(Self::Characters),
"generated-animations" => Some(Self::Animations),
"generated-big-fish-assets" => Some(Self::BigFishAssets),
"generated-square-hole-assets" => Some(Self::SquareHoleAssets),
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"generated-qwen-sprites" => Some(Self::QwenSprites),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::CharacterDrafts => "generated-character-drafts",
Self::Characters => "generated-characters",
Self::Animations => "generated-animations",
Self::BigFishAssets => "generated-big-fish-assets",
Self::SquareHoleAssets => "generated-square-hole-assets",
Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
Self::QwenSprites => "generated-qwen-sprites",
}
}
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 {
#[allow(clippy::too_many_arguments)]
pub fn new(
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
) -> Result<Self, OssError> {
let bucket = normalize_required_value(bucket, "OSS bucket 不能为空")?;
let endpoint = normalize_endpoint(&endpoint)?;
let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?;
let access_key_secret =
normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?;
if default_read_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS 私有读签名有效期必须大于 0".to_string(),
));
}
if default_post_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS PostObject 签名有效期必须大于 0".to_string(),
));
}
if default_post_max_size_bytes == 0 {
return Err(OssError::InvalidConfig(
"OSS PostObject 最大上传大小必须大于 0".to_string(),
));
}
if !(100..=999).contains(&default_success_action_status) {
return Err(OssError::InvalidConfig(
"OSS success_action_status 必须是三位 HTTP 状态码".to_string(),
));
}
Ok(Self {
bucket,
endpoint,
access_key_id,
access_key_secret,
default_read_expire_seconds,
default_post_expire_seconds,
default_post_max_size_bytes,
default_success_action_status,
})
}
pub fn upload_host(&self) -> String {
format!("https://{}.{}", self.bucket, self.endpoint)
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
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 {
pub fn new(config: OssConfig) -> Self {
Self { config }
}
pub fn config_bucket(&self) -> &str {
self.config.bucket()
}
pub fn sign_post_object(
&self,
request: OssPostObjectRequest,
) -> Result<OssPostObjectResponse, OssError> {
let max_size_bytes = request
.max_size_bytes
.unwrap_or(self.config.default_post_max_size_bytes);
let expire_seconds = request
.expire_seconds
.unwrap_or(self.config.default_post_expire_seconds);
let success_action_status = request
.success_action_status
.unwrap_or(self.config.default_success_action_status);
if max_size_bytes == 0 {
return Err(OssError::InvalidRequest(
"maxSizeBytes 必须大于 0".to_string(),
));
}
if expire_seconds == 0 {
return Err(OssError::InvalidRequest(
"expireSeconds 必须大于 0".to_string(),
));
}
if !(100..=999).contains(&success_action_status) {
return Err(OssError::InvalidRequest(
"successActionStatus 必须是三位 HTTP 状态码".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 legacy_public_path = format!("/{}", object_key);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
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 = expires_at
.format(&Rfc3339)
.map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?;
let signed_at = OffsetDateTime::now_utc();
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let signature_date = build_v4_signature_date(signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let policy_json = build_policy_json(
&self.config.bucket,
&object_key,
&expires_at,
max_size_bytes,
success_action_status,
content_type.as_deref(),
&metadata,
&credential,
&signature_date,
);
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,
)?;
Ok(OssPostObjectResponse {
signature_version: "v4",
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key: object_key.clone(),
legacy_public_path,
content_type: content_type.clone(),
access: request.access,
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
expires_at,
max_size_bytes,
success_action_status,
form_fields: OssPostObjectFormFields {
key: object_key,
policy: encoded_policy,
signature_version: OSS_V4_ALGORITHM.to_string(),
credential,
date: signature_date,
signature,
success_action_status: success_action_status.to_string(),
content_type,
metadata,
},
})
}
// 私有 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);
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 signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let mut query = BTreeMap::from([
("x-oss-additional-headers".to_string(), "host".to_string()),
(
"x-oss-signature-version".to_string(),
OSS_V4_ALGORITHM.to_string(),
),
("x-oss-credential".to_string(), credential),
("x-oss-date".to_string(), signed_at_text),
("x-oss-expires".to_string(), expire_seconds.to_string()),
]);
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_query = build_canonical_query_string(&query);
let canonical_request = build_v4_canonical_request(
Method::GET.as_str(),
&canonical_uri,
&canonical_query,
&canonical_headers,
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,
)?;
query.insert("x-oss-signature".to_string(), signature);
let signed_url = format!(
"{}{}?{}",
self.config.upload_host(),
object_url_path,
build_canonical_query_string(&query)
);
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,
})
}
}
impl fmt::Display for OssError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::ObjectNotFound(message)
| Self::Request(message)
| Self::SerializePolicy(message)
| Self::Sign(message) => f.write_str(message),
}
}
}
impl Error for OssError {}
impl OssError {
pub fn kind(&self) -> OssErrorKind {
match self {
Self::InvalidConfig(_) => OssErrorKind::InvalidConfig,
Self::InvalidRequest(_) => OssErrorKind::InvalidRequest,
Self::ObjectNotFound(_) => OssErrorKind::ObjectNotFound,
Self::Request(_) => OssErrorKind::Request,
Self::SerializePolicy(_) => OssErrorKind::SerializePolicy,
Self::Sign(_) => OssErrorKind::Sign,
}
}
}
fn build_policy_json(
bucket: &str,
object_key: &str,
expires_at: &str,
max_size_bytes: u64,
success_action_status: u16,
content_type: Option<&str>,
metadata: &BTreeMap<String, String>,
credential: &str,
signature_date: &str,
) -> Value {
let mut conditions = vec![
json!({ "bucket": bucket }),
json!(["eq", "$key", object_key]),
json!(["content-length-range", 1, max_size_bytes]),
json!([
"eq",
"$success_action_status",
success_action_status.to_string()
]),
json!(["eq", "$x-oss-signature-version", OSS_V4_ALGORITHM]),
json!(["eq", "$x-oss-credential", credential]),
json!(["eq", "$x-oss-date", signature_date]),
];
if let Some(content_type) = content_type {
conditions.push(json!(["eq", "$content-type", content_type]));
}
for (key, value) in metadata {
conditions.push(json!(["eq", format!("${key}"), value]));
}
json!({
"expiration": expires_at,
"conditions": conditions,
})
}
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_object_key(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> String {
let mut parts = Vec::with_capacity(path_segments.len() + 2);
parts.push(prefix.as_str().to_string());
parts.extend(path_segments.iter().cloned());
parts.push(file_name.to_string());
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());
parts.extend(path_segments.iter().cloned());
parts.join("/")
}
fn normalize_metadata(
metadata: BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, OssError> {
let mut normalized = BTreeMap::new();
for (key, value) in metadata {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
let key = normalize_metadata_key(key);
normalized.insert(key, value.to_string());
}
let total_bytes = normalized
.iter()
.map(|(key, value)| key.len() + value.len())
.sum::<usize>();
if total_bytes > DEFAULT_METADATA_TOTAL_BYTES_LIMIT {
return Err(OssError::InvalidRequest(format!(
"x-oss-meta-* 总大小不能超过 {} 字节",
DEFAULT_METADATA_TOTAL_BYTES_LIMIT
)));
}
Ok(normalized)
}
fn normalize_metadata_key(raw: &str) -> String {
let stripped = raw
.trim()
.trim_start_matches("x-oss-meta-")
.trim()
.to_ascii_lowercase();
let sanitized = stripped
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' => character,
'_' | ' ' | '/' | '.' => '-',
_ => '-',
})
.collect::<String>();
let sanitized = collapse_dashes(&sanitized);
format!(
"x-oss-meta-{}",
if sanitized.is_empty() {
"metadata".to_string()
} else {
sanitized
}
)
}
fn sanitize_path_segment(raw: &str) -> String {
let normalized = raw
.trim()
.to_ascii_lowercase()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
_ => '-',
})
.collect::<String>();
collapse_dashes(&normalized)
}
fn sanitize_file_name(raw: &str) -> Result<String, OssError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
}
let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed).trim();
if file_name.is_empty() {
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
}
let (raw_stem, raw_extension) = match file_name.rsplit_once('.') {
Some((stem, extension)) if !stem.trim().is_empty() && !extension.trim().is_empty() => {
(stem, Some(extension))
}
_ => (file_name, None),
};
let stem = raw_stem
.trim()
.to_ascii_lowercase()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
_ => '-',
})
.collect::<String>();
let stem = collapse_dashes(&stem);
if stem.is_empty() {
return Err(OssError::InvalidRequest("fileName 主体不合法".to_string()));
}
let extension = raw_extension
.map(|extension| {
extension
.trim()
.to_ascii_lowercase()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
})
.filter(|extension| !extension.is_empty());
Ok(match extension {
Some(extension) => format!("{stem}.{extension}"),
None => stem,
})
}
fn normalize_required_value(value: String, message: &str) -> Result<String, OssError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(OssError::InvalidConfig(message.to_string()));
}
Ok(value)
}
fn normalize_optional_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
let endpoint = raw
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_matches('/')
.to_string();
if endpoint.is_empty() {
return Err(OssError::InvalidConfig("OSS endpoint 不能为空".to_string()));
}
Ok(endpoint)
}
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
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 signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(config.endpoint(), signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_v4_canonical_uri(config.bucket(), object_path);
let body_sha256 = OSS_UNSIGNED_PAYLOAD.to_string();
let mut signed_headers = BTreeMap::from([
(
"host".to_string(),
format!("{}.{}", config.bucket(), config.endpoint()),
),
("x-oss-content-sha256".to_string(), body_sha256.clone()),
("x-oss-date".to_string(), signed_at_text.clone()),
]);
if let Some(content_type) = content_type {
signed_headers.insert("content-type".to_string(), content_type.to_string());
}
for (key, value) in oss_headers {
signed_headers.insert(key.to_ascii_lowercase(), value.trim().to_string());
}
let canonical_headers = build_v4_canonical_headers(&signed_headers);
let additional_headers = "host";
let canonical_request = build_v4_canonical_request(
method.as_str(),
&canonical_uri,
"",
&canonical_headers,
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 mut builder = client
.request(method, target_url)
.header("x-oss-content-sha256", body_sha256)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={},Signature={}",
config.access_key_id(),
signature_scope,
additional_headers,
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_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = format_v4_signature_scope_date(signed_at);
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<String, OssError> {
// 中文注释time::Time 的 Display 在小时小于 10 时不会稳定补零OSS V4 必须使用固定宽度 UTC 时间。
Ok(format!(
"{}T{:02}{:02}{:02}Z",
format_v4_signature_scope_date(signed_at),
signed_at.hour(),
signed_at.minute(),
signed_at.second()
))
}
fn format_v4_signature_scope_date(signed_at: OffsetDateTime) -> String {
format!(
"{:04}{:02}{:02}",
signed_at.year(),
signed_at.month() as u8,
signed_at.day()
)
}
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!(
"/{}/{}",
encode_url_query_value(bucket),
encode_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_url_query_value(bucket)),
}
}
fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
endpoint
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.map(str::to_string)
.filter(|region| !region.is_empty())
.ok_or_else(|| {
OssError::InvalidConfig(format!("OSS endpoint 无法解析 region当前值{endpoint}"))
})
}
fn sign_v4_content(
access_key_secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, OssError> {
let signing_key = build_v4_signing_key(access_key_secret, signature_scope)?;
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
}
fn build_v4_signing_key(
access_key_secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, OssError> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少日期".to_string()))?;
let region = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 region".to_string()))?;
let service = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 service".to_string()))?;
let request = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 request".to_string()))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{access_key_secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, OssError> {
let mut signer = HmacSha256::new_from_slice(key)
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA256 失败:{error}")))?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
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");
signer.update(content);
hex_lower(&signer.finalize().into_bytes())
}
fn build_v4_canonical_request(
method: &str,
canonical_uri: &str,
canonical_query: &str,
canonical_headers: &str,
signed_headers: &str,
payload_hash: &str,
) -> String {
format!(
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
)
}
fn build_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"{OSS_V4_ALGORITHM}\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
headers
.iter()
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
.collect::<String>()
}
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
encode_url_query_value(key),
encode_url_query_value(value)
)
})
.collect::<Vec<_>>()
.join("&")
}
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::*;
#[test]
fn oss_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
OssError::InvalidConfig("bad config".to_string()).kind(),
OssErrorKind::InvalidConfig
);
assert_eq!(
OssError::ObjectNotFound("missing".to_string()).kind(),
OssErrorKind::ObjectNotFound
);
assert_eq!(
OssError::Request("network".to_string()).kind(),
OssErrorKind::Request
);
}
fn build_client() -> OssClient {
OssClient::new(
OssConfig::new(
"genarrative-assets".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"),
)
}
#[test]
fn parse_legacy_prefix_accepts_public_style_path() {
assert_eq!(
LegacyAssetPrefix::parse("/generated-characters/*"),
Some(LegacyAssetPrefix::Characters)
);
assert_eq!(
LegacyAssetPrefix::parse("/generated-puzzle-assets/*"),
Some(LegacyAssetPrefix::PuzzleAssets)
);
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets"));
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
}
#[test]
fn build_v4_signature_date_zero_pads_single_digit_time_parts() {
let signed_at =
OffsetDateTime::from_unix_timestamp(1_771_477_389).expect("timestamp should be valid");
assert_eq!(
build_v4_signature_date(signed_at).expect("date should format"),
"20260219T050309Z"
);
assert_eq!(
build_v4_signature_scope("oss-cn-shanghai.aliyuncs.com", signed_at)
.expect("scope should format"),
"20260219/cn-shanghai/oss/aliyun_v4_request"
);
}
#[test]
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());
metadata.insert("origin".to_string(), "browser-upload".to_string());
let response = client
.sign_post_object(OssPostObjectRequest {
prefix: LegacyAssetPrefix::Characters,
path_segments: vec![
"Hero_001".to_string(),
"Visual".to_string(),
"Asset_01".to_string(),
],
file_name: "Master.PNG".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Public,
metadata,
max_size_bytes: Some(5 * 1024 * 1024),
expire_seconds: Some(300),
success_action_status: Some(200),
})
.expect("post object signature should build");
assert_eq!(
response.object_key,
"generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(
response.legacy_public_path,
"/generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
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_eq!(
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
Some(&"character-visual".to_string())
);
}
#[test]
fn sign_post_object_embeds_policy_constraints() {
let client = build_client();
let response = client
.sign_post_object(OssPostObjectRequest {
prefix: LegacyAssetPrefix::QwenSprites,
path_segments: vec!["_drafts".to_string(), "master".to_string()],
file_name: "candidate-01.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
max_size_bytes: Some(1024),
expire_seconds: Some(60),
success_action_status: Some(200),
})
.expect("post object signature should build");
let decoded_policy = BASE64_STANDARD
.decode(response.form_fields.policy.as_bytes())
.expect("policy should be valid base64");
let policy: Value =
serde_json::from_slice(&decoded_policy).expect("policy should be valid json");
assert_eq!(
policy["conditions"][0]["bucket"],
Value::String("genarrative-assets".to_string())
);
assert_eq!(
policy["conditions"][1],
json!([
"eq",
"$key",
"generated-qwen-sprites/_drafts/master/candidate-01.png"
])
);
assert_eq!(
policy["conditions"][2],
json!(["content-length-range", 1, 1024])
);
assert_eq!(
policy["conditions"][3],
json!(["eq", "$success_action_status", "200"])
);
assert_eq!(
policy["conditions"][4],
json!(["eq", "$x-oss-signature-version", "OSS4-HMAC-SHA256"])
);
assert_eq!(
policy["conditions"][5],
json!(["eq", "$x-oss-credential", response.form_fields.credential])
);
assert_eq!(
policy["conditions"][6],
json!(["eq", "$x-oss-date", response.form_fields.date])
);
assert_eq!(
policy["conditions"][7],
json!(["eq", "$content-type", "image/png"])
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
}
#[test]
fn sanitize_file_name_rejects_empty_input() {
let error = sanitize_file_name(" ").expect_err("empty file name should fail");
assert_eq!(
error,
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("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-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();
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_sorted_v4_header_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_v4_canonical_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())
);
}
}