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; 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; 10] = [ "generated-character-drafts", "generated-characters", "generated-animations", "generated-big-fish-assets", "generated-square-hole-assets", "generated-match3d-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, Match3DAssets, 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, pub file_name: String, pub content_type: Option, pub access: OssObjectAccess, pub metadata: BTreeMap, pub max_size_bytes: Option, pub expire_seconds: Option, pub success_action_status: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssSignedGetObjectUrlRequest { pub object_key: String, pub expire_seconds: Option, } #[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, pub file_name: String, pub content_type: Option, pub access: OssObjectAccess, pub metadata: BTreeMap, pub body: Vec, } #[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, 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, pub etag: Option, pub last_modified: Option, } #[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, #[serde(rename = "contentLength")] pub content_length: u64, pub access: OssObjectAccess, #[serde(skip_serializing_if = "Option::is_none")] pub etag: Option, #[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")] pub last_modified: Option, } #[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, #[serde(flatten)] pub metadata: BTreeMap, } #[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 { 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-match3d-assets" => Some(Self::Match3DAssets), "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::Match3DAssets => "generated-match3d-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 { 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 { 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 { 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::>(); 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 { 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 { 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::().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 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。 pub async fn put_object( &self, client: &reqwest::Client, request: OssPutObjectRequest, ) -> Result { 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::>(); 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, 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 { 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 { 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::>(); 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, ) -> Result, 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::(); 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::(); 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::(); collapse_dashes(&normalized) } fn sanitize_file_name(raw: &str) -> Result { 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::(); 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::() }) .filter(|extension| !extension.is_empty()); Ok(match extension { Some(extension) => format!("{stem}.{extension}"), None => stem, }) } fn normalize_required_value(value: String, message: &str) -> Result { 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) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } fn normalize_endpoint(raw: &str) -> Result { 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 { 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, ) -> Result { 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 { 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 { // 中文注释: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 { 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 { 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, 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(®ion_key, service)?; hmac_sha256_raw(&service_key, request) } fn hmac_sha256_raw(key: &[u8], content: &str) -> Result, 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::() } fn build_v4_canonical_headers(headers: &BTreeMap) -> String { headers .iter() .map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim())) .collect::() } fn build_canonical_query_string(params: &BTreeMap) -> String { params .iter() .map(|(key, value)| { format!( "{}={}", encode_url_query_value(key), encode_url_query_value(value) ) }) .collect::>() .join("&") } fn encode_url_path(path: &str) -> String { path.split('/') .map(encode_url_query_value) .collect::>() .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_eq!( LegacyAssetPrefix::parse("/generated-match3d-assets/*"), Some(LegacyAssetPrefix::Match3DAssets) ); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets")); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-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::>(); 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()) ); } }