Files
Genarrative/server-rs/crates/module-game-distribution/src/package.rs
T
kdletters c2c5e1ced5 发行包上限提升到 200 MiB 并支持 AGC 分片续传上传
- 发行包上限 100→200 MiB、展开总量 250→500 MiB,整包路由请求体上限继续从包上限派生;发行静态资源进程内缓存预算提到 256 MiB
- 反代放行量同步放宽到 210 MiB:Nginx 三份模板的 client_max_body_size、Pingora 网关默认值与 env 样例、路由对照矩阵
- platform-oss 新增内部对象追加写 append_internal_object(_with_retry),以 OSS 返回的 next-append-position 作为权威已收字节
- api-server 新增 upload-state / chunk / complete / reset 四条分片路由,抽出共享收口 confirm_validated_package;偏移不符返回 409 与权威偏移,校验失败删除半包并落 upload_failed
- AGC 新增原生上传器 game_package_upload.rs(内容寻址暂存、分片续传、受控重试、进度事件)与 prepare / upload 两条命令,退役整包回传命令
- 渲染进程改为 prepare → 创建游戏 → 创建版本 → 原生分片上传 → 送审,LocalProjectExportPackagePayload 整包类型退役
- 同时修正 live 用例无法指向本地栈的两处基础设施问题:平台基址按传入 URL 选择,桥接层把 jsdom realm 的 Headers / Blob / FormData 降级成 Node 原生值
- 测试:platform-oss 74、api-server game_distribution 23、AGC 原生 4、发布相关前端 20;live 用例补真实栈「中断 → 续传 → 确认」断言(分片偏移序列 [0, 8388608])
- 文档:玩法创作主规范的上传合同、运维与 Pingora 文档、决策记录、发行里程碑口径,以及新增的续传里程碑与实施计划
2026-09-23 19:06:02 +08:00

271 lines
9.4 KiB
Rust

use std::{
collections::HashSet,
io::{Cursor, Read},
path::Path,
};
use sha2::{Digest, Sha256};
/// 发行包体积上限。反代放行量与路由请求体上限都从它派生:Nginx
/// `client_max_body_size`、Pingora `MAX_API_BODY_BYTES` 必须同步放宽,否则合法包会在
/// 到达 `api-server` 之前被拒。
pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024;
/// 展开总量上限保持压缩包上限的 2.5 倍余量:包体本身基本不可再压时展开量约等于包体,
/// 纯文本 / JSON 资源占比高的包仍要有足够空间。
pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024;
pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_FILE_COUNT: usize = 10_000;
pub const MAX_COMPRESSION_RATIO: u64 = 100;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReleaseFileManifest {
pub path: String,
pub size_bytes: u64,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReleasePackageManifest {
pub package_bytes: u64,
pub package_sha256: String,
pub files: Vec<ReleaseFileManifest>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReleasePackageError {
EmptyPackage,
PackageTooLarge,
InvalidArchive,
MissingEntry,
TooManyFiles,
InvalidPath,
SymlinkNotAllowed,
EncryptedFileNotAllowed,
SensitiveFileNotAllowed,
NestedArchiveNotAllowed,
FileTooLarge,
ExpandedPackageTooLarge,
CompressionRatioTooHigh,
ReadFailed,
}
pub fn validate_release_zip(bytes: &[u8]) -> Result<ReleasePackageManifest, ReleasePackageError> {
if bytes.is_empty() {
return Err(ReleasePackageError::EmptyPackage);
}
let package_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if package_bytes > MAX_PACKAGE_BYTES {
return Err(ReleasePackageError::PackageTooLarge);
}
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|_| ReleasePackageError::InvalidArchive)?;
if archive.len() > MAX_FILE_COUNT {
return Err(ReleasePackageError::TooManyFiles);
}
let mut paths = HashSet::with_capacity(archive.len());
let mut case_folded_paths = HashSet::with_capacity(archive.len());
let mut files = Vec::with_capacity(archive.len());
let mut expanded_bytes = 0_u64;
let mut has_entry = false;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|_| ReleasePackageError::InvalidArchive)?;
if file.encrypted() {
return Err(ReleasePackageError::EncryptedFileNotAllowed);
}
if file.is_symlink() {
return Err(ReleasePackageError::SymlinkNotAllowed);
}
let path = file
.enclosed_name()
.ok_or(ReleasePackageError::InvalidPath)?;
let path = normalize_archive_path(&path)?;
if !paths.insert(path.clone()) || !case_folded_paths.insert(path.to_ascii_lowercase()) {
return Err(ReleasePackageError::InvalidPath);
}
if path == "index.html" {
has_entry = true;
}
if is_sensitive_path(&path) {
return Err(ReleasePackageError::SensitiveFileNotAllowed);
}
if path.to_ascii_lowercase().ends_with(".zip") {
return Err(ReleasePackageError::NestedArchiveNotAllowed);
}
if file.is_dir() {
continue;
}
let declared_size = file.size();
if declared_size > MAX_FILE_BYTES {
return Err(ReleasePackageError::FileTooLarge);
}
expanded_bytes = expanded_bytes.saturating_add(declared_size);
if expanded_bytes > MAX_EXPANDED_BYTES {
return Err(ReleasePackageError::ExpandedPackageTooLarge);
}
if declared_size > package_bytes.saturating_mul(MAX_COMPRESSION_RATIO) {
return Err(ReleasePackageError::CompressionRatioTooHigh);
}
let mut content = Vec::with_capacity(usize::try_from(declared_size).unwrap_or(0));
file.read_to_end(&mut content)
.map_err(|_| ReleasePackageError::ReadFailed)?;
if u64::try_from(content.len()).unwrap_or(u64::MAX) != declared_size {
return Err(ReleasePackageError::ReadFailed);
}
let digest = Sha256::digest(&content);
files.push(ReleaseFileManifest {
path,
size_bytes: declared_size,
sha256: hex::encode(digest),
});
}
if !has_entry {
return Err(ReleasePackageError::MissingEntry);
}
let package_digest = Sha256::digest(bytes);
Ok(ReleasePackageManifest {
package_bytes,
package_sha256: hex::encode(package_digest),
files,
})
}
pub(crate) fn normalize_archive_path(path: &Path) -> Result<String, ReleasePackageError> {
let path = path
.to_str()
.ok_or(ReleasePackageError::InvalidPath)?
.trim_end_matches('/');
if path.is_empty() || path.contains('\\') || path.starts_with('/') {
return Err(ReleasePackageError::InvalidPath);
}
let mut parts = Vec::new();
for part in path.split('/') {
if part.is_empty()
|| part == "."
|| part == ".."
|| part.ends_with(' ')
|| part.ends_with('.')
|| part
.chars()
.any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*'))
{
return Err(ReleasePackageError::InvalidPath);
}
parts.push(part);
}
Ok(parts.join("/"))
}
fn is_sensitive_path(path: &str) -> bool {
path.split('/').any(|part| {
matches!(part, ".git" | ".agent" | "node_modules")
|| part.starts_with(".env")
|| part.ends_with(".map")
|| part.ends_with(".pem")
|| part.ends_with(".key")
})
}
#[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()
}
/// 存储型条目的压缩包;用于构造体积可控且不参与 deflate 的大包。
fn stored_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().compression_method(zip::CompressionMethod::Stored),
)
.expect("zip entry");
writer.write_all(content).expect("zip content");
}
writer.finish().expect("finish zip");
output.into_inner()
}
#[test]
fn accepts_root_entry_and_returns_file_manifest() {
let bytes = archive(&[("index.html", b"<html></html>"), ("assets/a.txt", b"a")]);
let manifest = validate_release_zip(&bytes).expect("valid archive");
assert_eq!(manifest.files.len(), 2);
assert_eq!(manifest.files[0].path, "index.html");
}
#[test]
fn rejects_missing_entry_sensitive_and_traversal_paths() {
let missing = archive(&[("game.html", b"x")]);
assert_eq!(
validate_release_zip(&missing),
Err(ReleasePackageError::MissingEntry)
);
let sensitive = archive(&[("index.html", b"x"), (".env", b"secret")]);
assert_eq!(
validate_release_zip(&sensitive),
Err(ReleasePackageError::SensitiveFileNotAllowed)
);
let traversal = archive(&[("index.html", b"x"), ("../escape.txt", b"x")]);
assert_eq!(
validate_release_zip(&traversal),
Err(ReleasePackageError::InvalidPath)
);
let case_collision = archive(&[
("index.html", b"x"),
("ASSETS/a.txt", b"x"),
("assets/A.txt", b"x"),
]);
assert_eq!(
validate_release_zip(&case_collision),
Err(ReleasePackageError::InvalidPath)
);
}
#[test]
fn keeps_expansion_headroom_over_package_limit() {
// 口径约束:发行包上限调整时,展开总量至少要留出两倍余量,
// 否则高文本占比的合法包会在展开量检查处被误拒。
assert!(MAX_EXPANDED_BYTES >= MAX_PACKAGE_BYTES.saturating_mul(2));
}
#[test]
fn accepts_package_above_the_previous_hundred_mib_limit() {
// 上限从 100 MiB 提到 200 MiB 的回归防护:两个 50 MiB 存储型条目组成 100 MiB
// 出头的包,旧上限会在这里判 PackageTooLarge,新上限必须放行并给出完整清单。
let chunk = vec![0_u8; 50 * 1024 * 1024];
let bytes = stored_archive(&[
("index.html", b"<html></html>"),
("assets/a.bin", chunk.as_slice()),
("assets/b.bin", chunk.as_slice()),
]);
assert!(bytes.len() as u64 > 100 * 1024 * 1024);
let manifest = validate_release_zip(&bytes).expect("package above 100 MiB");
assert_eq!(manifest.package_bytes, bytes.len() as u64);
assert_eq!(manifest.files.len(), 3);
}
}