Files
Genarrative/server-rs/crates/module-game-distribution/src/package.rs
T
kdletters 328ac31844 恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
2026-09-20 20:49:42 +08:00

226 lines
7.2 KiB
Rust

use std::{
collections::HashSet,
io::{Cursor, Read},
path::Path,
};
use sha2::{Digest, Sha256};
pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024;
pub const MAX_EXPANDED_BYTES: u64 = 250 * 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()
}
#[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)
);
}
}