328ac31844
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
180 lines
6.2 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|