Files
Genarrative/server-rs/crates/module-game-distribution/src/release.rs
T
kdletters 328ac31844 恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
2026-09-20 20:49:42 +08:00

180 lines
6.2 KiB
Rust

use std::io::{Cursor, Read};
use crate::package::{MAX_FILE_BYTES, normalize_archive_path};
/// 单次发行资源响应的字节上限,与单文件上限同口径。
pub const MAX_RELEASE_ASSET_BYTES: u64 = MAX_FILE_BYTES;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReleaseAssetError {
InvalidPath,
NotInPackage,
FileTooLarge,
InvalidArchive,
ReadFailed,
}
/// 归一化发行资源路径。
///
/// 路径来自公开 URL,必须在查表前完成归一化并拒绝相对段、控制字符和 Windows
/// 保留字符,不能依赖 ZIP 条目的原始写法。
pub fn normalize_release_asset_path(raw: &str) -> Result<String, ReleaseAssetError> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.len() > 1024 {
return Err(ReleaseAssetError::InvalidPath);
}
if trimmed.contains('\\') || trimmed.starts_with('/') || trimmed.contains('\0') {
return Err(ReleaseAssetError::InvalidPath);
}
normalize_archive_path(std::path::Path::new(trimmed))
.map_err(|_| ReleaseAssetError::InvalidPath)
}
/// 发行资源的响应内容类型白名单。
///
/// 未知扩展名返回 `None` 并让调用方失败关闭,避免把未知内容当作 HTML 返回。
pub fn release_asset_content_type(path: &str) -> Option<&'static str> {
let extension = path.rsplit_once('.').map(|(_, value)| value)?;
let extension = extension.to_ascii_lowercase();
Some(match extension.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json; charset=utf-8",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"avif" => "image/avif",
"ico" => "image/x-icon",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"mp4" => "video/mp4",
"webm" => "video/webm",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" => "text/plain; charset=utf-8",
"xml" => "application/xml",
_ => return None,
})
}
/// 从已确认的发行包中取出单个资源。
///
/// 只按归一化后的精确路径命中,不做大小写折叠或前缀匹配,避免一个资源伪装成另一个。
pub fn extract_release_asset(
package: &[u8],
asset_path: &str,
) -> Result<Vec<u8>, ReleaseAssetError> {
let asset_path = normalize_release_asset_path(asset_path)?;
let mut archive = zip::ZipArchive::new(Cursor::new(package))
.map_err(|_| ReleaseAssetError::InvalidArchive)?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|_| ReleaseAssetError::InvalidArchive)?;
if file.is_dir() || file.is_symlink() || file.encrypted() {
continue;
}
let Ok(candidate) =
normalize_archive_path(&file.enclosed_name().ok_or(ReleaseAssetError::InvalidPath)?)
else {
continue;
};
if candidate != asset_path {
continue;
}
let size = file.size();
if size > MAX_RELEASE_ASSET_BYTES {
return Err(ReleaseAssetError::FileTooLarge);
}
let mut content = Vec::with_capacity(usize::try_from(size).unwrap_or(0));
file.read_to_end(&mut content)
.map_err(|_| ReleaseAssetError::ReadFailed)?;
if u64::try_from(content.len()).unwrap_or(u64::MAX) != size {
return Err(ReleaseAssetError::ReadFailed);
}
return Ok(content);
}
Err(ReleaseAssetError::NotInPackage)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use zip::{ZipWriter, write::SimpleFileOptions};
use super::*;
fn archive(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut output = Cursor::new(Vec::new());
let mut writer = ZipWriter::new(&mut output);
for (path, content) in files {
writer
.start_file(*path, SimpleFileOptions::default())
.expect("zip entry");
writer.write_all(content).expect("zip content");
}
writer.finish().expect("finish zip");
output.into_inner()
}
#[test]
fn content_type_allowlist_fails_closed_for_unknown_extensions() {
assert_eq!(
release_asset_content_type("index.html"),
Some("text/html; charset=utf-8")
);
assert_eq!(
release_asset_content_type("app.js"),
Some("text/javascript; charset=utf-8")
);
assert_eq!(release_asset_content_type("payload"), None);
assert_eq!(release_asset_content_type("evil.php"), None);
}
#[test]
fn asset_path_rejects_traversal_and_absolute_input() {
assert_eq!(
normalize_release_asset_path("../secret").expect_err("相对段应拒绝"),
ReleaseAssetError::InvalidPath
);
assert_eq!(
normalize_release_asset_path("/etc/passwd").expect_err("绝对路径应拒绝"),
ReleaseAssetError::InvalidPath
);
assert_eq!(
normalize_release_asset_path("assets/./a.png").expect_err("点段应拒绝"),
ReleaseAssetError::InvalidPath
);
assert_eq!(
normalize_release_asset_path("assets/app.js").expect("合法路径"),
"assets/app.js"
);
}
#[test]
fn extraction_only_returns_the_exact_declared_entry() {
let bytes = archive(&[("index.html", b"<html></html>"), ("assets/app.js", b"1")]);
assert_eq!(
extract_release_asset(&bytes, "assets/app.js").expect("命中"),
b"1".to_vec()
);
assert_eq!(
extract_release_asset(&bytes, "assets/./app.js").expect_err("归一化后拒绝"),
ReleaseAssetError::InvalidPath
);
assert_eq!(
extract_release_asset(&bytes, "assets/missing.js").expect_err("未命中"),
ReleaseAssetError::NotInPackage
);
}
}