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
- 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 记录「本地导出上限 ≠ 平台发布上限」这一口径
301 lines
11 KiB
Rust
301 lines
11 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` 之前被拒。
|
||
///
|
||
/// 客户端发布前检查读的是 `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);
|
||
}
|
||
}
|