Files
Genarrative/server-rs/crates/module-game-distribution/src/package.rs
T
kdletters e6c3729adb
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 1m46s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
AGC 发布前检查平台包体上限,并把上限单一来源
- shared-contracts 新增 GAME_DISTRIBUTION_MAX_PACKAGE_BYTES(200 MiB),服务端 module-game-distribution 保留同值常量并在注释里指明;api-server 新增 publish_package_limit_matches_the_shared_contract 守卫测试把两处锁一致
- AGC project/export 新增 ensure_within_platform_package_limit:读包阶段预检,超限返回「发行包 X MiB 超过平台上限 200 MiB;请精简资源后重新导出再发布」,不再让 200–512 MiB 的包白读盘/暂存后吃服务端 413
- 用例:project::export::npm_export_tests::publish_precheck_rejects_packages_over_the_platform_limit(边界=上限放行、超限文案、本地导出上限确实更宽)
- 里程碑 C1 与 pitfalls 记录「本地导出上限 ≠ 平台发布上限」这一口径
2026-09-29 00:36:28 +08:00

301 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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` 之前被拒。
///
/// 客户端发布前检查读的是 `shared_contracts::game_distribution::
/// GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`;本 crate 不依赖 `shared-contracts`,两处数字
/// 由 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 rejects_symlink_entries() {
let mut output = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut output);
writer
.start_file(
"index.html",
SimpleFileOptions::default().unix_permissions(0o644),
)
.expect("index entry");
writer.write_all(b"<html></html>").expect("index content");
// 只能用 `add_symlink`:`unix_permissions` 会把 mode 掩成 `0o777`,
// 正常写入路径补的是 `S_IFREG`,造不出 `S_IFLNK` 条目。
writer
.add_symlink("escape", "/tmp", SimpleFileOptions::default())
.expect("symlink entry");
writer.finish().expect("finish zip");
}
assert_eq!(
validate_release_zip(&output.into_inner()),
Err(ReleasePackageError::SymlinkNotAllowed)
);
}
#[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);
}
}