328ac31844
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
226 lines
7.2 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|