c2c5e1ced5
- 发行包上限 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 文档、决策记录、发行里程碑口径,以及新增的续传里程碑与实施计划
271 lines
9.4 KiB
Rust
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);
|
|
}
|
|
}
|