39aed2e486
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 19s
Project CI / AI game creator shell Rust crates (push) Failing after 18s
Project CI / Native shell tests (push) Failing after 18s
Project CI / Backend tests (push) Failing after 18s
Project CI / AI game creator shell Rust smoke (push) Failing after 19s
Project CI / Frontend tests (push) Failing after 7s
Project CI / AI game creator shell web tests (push) Failing after 11s
Project CI / Repository checks (push) Failing after 11s
ossutil v2 默认 v4 签名,缺 region 会直接失败\n总号写入与回读统一走 buildOssutilArgs,默认 --sign-version v1,可切 v4 并配合 AGC_OSS_REGION\n补充参数构造单测
479 lines
19 KiB
Rust
479 lines
19 KiB
Rust
use super::*;
|
||
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::BTreeSet;
|
||
use std::io::{Cursor, Read};
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct LocalProjectExportPackageFileDigest {
|
||
pub(crate) path: String,
|
||
pub(crate) size_bytes: u64,
|
||
pub(crate) sha256: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct LocalProjectExportPackagePayload {
|
||
pub(crate) package_relative_path: String,
|
||
pub(crate) package_bytes: Vec<u8>,
|
||
pub(crate) package_sha256: String,
|
||
pub(crate) package_size_bytes: u64,
|
||
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
|
||
}
|
||
|
||
pub(crate) fn export_local_project_package_at(
|
||
root: &Path,
|
||
) -> Result<LocalProjectExportPackageResult, String> {
|
||
validate_project_root(root)?;
|
||
super::verification::validate_project_game_entry(root)?;
|
||
ensure_project_export_package_dir(root, "exports")?;
|
||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||
if !readme_path.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||
if !readme_metadata.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
|
||
let files = collect_project_export_package_files(root)?;
|
||
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
|
||
if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES {
|
||
return Err(format!(
|
||
"试玩包文件数量超过上限:{} > {}",
|
||
files.len(),
|
||
MAX_PROJECT_EXPORT_PACKAGE_FILES
|
||
));
|
||
}
|
||
if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||
return Err(format!(
|
||
"试玩包文件总大小超过上限:{}B > {}B",
|
||
total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES
|
||
));
|
||
}
|
||
let package_relative_path = next_project_export_package_relative_path(root)?;
|
||
let package_path = resolve_local_project_path(root, &package_relative_path)?;
|
||
if let Some(parent) = package_path.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "导出目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "导出目录")?;
|
||
}
|
||
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
|
||
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let file = options
|
||
.open(&package_path)
|
||
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") {
|
||
drop(file);
|
||
let _ = fs::remove_file(&package_path);
|
||
return Err(error);
|
||
}
|
||
let mut writer = zip::ZipWriter::new(file);
|
||
let options = zip::write::SimpleFileOptions::default()
|
||
.compression_method(zip::CompressionMethod::Deflated);
|
||
for (relative_path, absolute_path, _) in &files {
|
||
writer
|
||
.start_file(relative_path, options)
|
||
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
|
||
prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?;
|
||
let bytes = fs::read(absolute_path)
|
||
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
|
||
writer
|
||
.write_all(&bytes)
|
||
.map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?;
|
||
}
|
||
writer
|
||
.finish()
|
||
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
|
||
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
|
||
|
||
let updated_at = unix_timestamp();
|
||
let log_path = root.join(".agent/logs/command.log");
|
||
let output = format!(
|
||
"导出试玩包:{},{} 个文件,{}B",
|
||
package_relative_path,
|
||
files.len(),
|
||
total_bytes
|
||
);
|
||
let line = format!("{updated_at} project.export_package: {output}\n");
|
||
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
|
||
record_command_run(
|
||
root,
|
||
GameCreationAppCommandRunState {
|
||
command_id: "project.export_package".to_string(),
|
||
status: GameCreationAppCommandRunStatus::Completed,
|
||
output,
|
||
log_path: log_path.to_string_lossy().into_owned(),
|
||
updated_at,
|
||
},
|
||
)?;
|
||
append_agent_db_record(
|
||
root,
|
||
serde_json::json!({
|
||
"recordType": "project.export_package",
|
||
"packagePath": package_relative_path,
|
||
"fileCount": files.len(),
|
||
"totalBytes": total_bytes,
|
||
}),
|
||
)?;
|
||
|
||
Ok(LocalProjectExportPackageResult {
|
||
package_path: package_path.to_string_lossy().into_owned(),
|
||
package_relative_path,
|
||
file_count: files.len(),
|
||
total_bytes,
|
||
})
|
||
}
|
||
|
||
/// Read a previously exported package for the explicit AGC publish flow.
|
||
///
|
||
/// The caller receives the package bytes and a deterministic file manifest, but
|
||
/// never receives a filesystem path that it could accidentally send to the API.
|
||
pub(crate) fn read_local_project_export_package_at(
|
||
root: &Path,
|
||
package_relative_path: &str,
|
||
) -> Result<LocalProjectExportPackagePayload, String> {
|
||
validate_project_root(root)?;
|
||
let normalized = normalize_export_package_entry_path(package_relative_path)?;
|
||
if !normalized.starts_with("exports/playtest-package-")
|
||
|| !normalized.ends_with(".zip")
|
||
|| normalized.contains('/') && normalized.split('/').count() != 2
|
||
{
|
||
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
|
||
}
|
||
let package_path = resolve_local_project_path(root, &normalized)?;
|
||
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
|
||
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
|
||
if !metadata.is_file() {
|
||
return Err("发行包必须是普通文件".to_string());
|
||
}
|
||
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||
return Err("发行包大小超出本地发布上限".to_string());
|
||
}
|
||
let package_bytes =
|
||
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
|
||
if package_bytes.len() as u64 != metadata.len() {
|
||
return Err("发行包在读取期间发生变化,请重新导出".to_string());
|
||
}
|
||
|
||
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
|
||
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
|
||
let mut entries = Vec::with_capacity(archive.len());
|
||
let mut seen = BTreeSet::new();
|
||
for index in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(index)
|
||
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
|
||
if entry.is_dir() {
|
||
continue;
|
||
}
|
||
let source_path = normalize_export_package_entry_path(entry.name())?;
|
||
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
|
||
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
|
||
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
|
||
let path = source_path
|
||
.strip_prefix("game/")
|
||
.unwrap_or(source_path.as_str())
|
||
.to_string();
|
||
let path = normalize_export_package_entry_path(&path)?;
|
||
if !seen.insert(path.clone()) {
|
||
return Err(format!("发行包包含重复条目:{path}"));
|
||
}
|
||
let expected_size = entry.size();
|
||
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
|
||
entry
|
||
.read_to_end(&mut content)
|
||
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
|
||
if content.len() as u64 != expected_size {
|
||
return Err(format!("发行包条目长度不一致:{path}"));
|
||
}
|
||
entries.push((path, content));
|
||
}
|
||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||
if entries.is_empty() {
|
||
return Err("发行包没有可上传文件".to_string());
|
||
}
|
||
|
||
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||
let options = zip::write::SimpleFileOptions::default()
|
||
.compression_method(zip::CompressionMethod::Deflated);
|
||
for (path, content) in &entries {
|
||
normalized_writer
|
||
.start_file(path, options)
|
||
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
|
||
normalized_writer
|
||
.write_all(content)
|
||
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
|
||
}
|
||
let normalized_cursor = normalized_writer
|
||
.finish()
|
||
.map_err(|error| format!("完成发行包失败:{error}"))?;
|
||
let package_bytes = normalized_cursor.into_inner();
|
||
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||
return Err("归一化发行包大小超出本地发布上限".to_string());
|
||
}
|
||
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
|
||
let files = entries
|
||
.into_iter()
|
||
.map(|(path, content)| LocalProjectExportPackageFileDigest {
|
||
size_bytes: content.len() as u64,
|
||
sha256: format!("{:x}", Sha256::digest(&content)),
|
||
path,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if !files.iter().any(|file| file.path == "index.html") {
|
||
return Err("归一化发行包缺少根 index.html".to_string());
|
||
}
|
||
Ok(LocalProjectExportPackagePayload {
|
||
package_relative_path: normalized,
|
||
package_size_bytes: package_bytes.len() as u64,
|
||
package_bytes,
|
||
package_sha256,
|
||
files,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
|
||
let seed = unix_millis();
|
||
for suffix in 0..1000 {
|
||
let file_name = if suffix == 0 {
|
||
format!("playtest-package-{seed}.zip")
|
||
} else {
|
||
format!("playtest-package-{seed}-{suffix}.zip")
|
||
};
|
||
let relative_path = format!("exports/{file_name}");
|
||
let package_path = resolve_local_project_path(root, &relative_path)?;
|
||
if !package_path.exists() {
|
||
return Ok(relative_path);
|
||
}
|
||
}
|
||
Err("无法生成唯一试玩包文件名".to_string())
|
||
}
|
||
|
||
pub(crate) fn list_local_project_export_packages_at(
|
||
root: &Path,
|
||
) -> Result<LocalProjectExportPackagesResult, String> {
|
||
validate_project_root(root)?;
|
||
let export_dir = resolve_local_project_path(root, "exports")?;
|
||
if !export_dir.exists() {
|
||
return Ok(LocalProjectExportPackagesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
packages: Vec::new(),
|
||
});
|
||
}
|
||
let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?;
|
||
if !export_dir_metadata.is_dir() {
|
||
return Err("exports 必须是目录".to_string());
|
||
}
|
||
|
||
let mut packages = Vec::new();
|
||
for entry in fs::read_dir(&export_dir)
|
||
.map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))?
|
||
{
|
||
let entry = entry
|
||
.map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?;
|
||
let file_type = entry.file_type().map_err(|error| {
|
||
format!(
|
||
"读取试玩包文件类型失败:{}: {error}",
|
||
entry.path().display()
|
||
)
|
||
})?;
|
||
if file_type.is_symlink() || !file_type.is_file() {
|
||
continue;
|
||
}
|
||
let file_name = match entry.file_name().to_str() {
|
||
Some(value) => value.to_string(),
|
||
None => continue,
|
||
};
|
||
if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") {
|
||
continue;
|
||
}
|
||
let package_relative_path =
|
||
normalize_export_package_entry_path(&format!("exports/{file_name}"))?;
|
||
let metadata = entry.metadata().map_err(|error| {
|
||
format!("读取试玩包元数据失败:{}: {error}", entry.path().display())
|
||
})?;
|
||
let modified_at = metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||
.unwrap_or(0);
|
||
packages.push(LocalProjectExportPackageSummary {
|
||
package_path: entry.path().to_string_lossy().into_owned(),
|
||
package_relative_path,
|
||
total_bytes: metadata.len(),
|
||
modified_at,
|
||
});
|
||
}
|
||
packages.sort_by(|left, right| {
|
||
(right.modified_at, &right.package_relative_path)
|
||
.cmp(&(left.modified_at, &left.package_relative_path))
|
||
});
|
||
|
||
Ok(LocalProjectExportPackagesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
packages,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn collect_project_export_package_files(
|
||
root: &Path,
|
||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||
let mut files = Vec::new();
|
||
let game_root = crate::preview::project_game_root(root);
|
||
let built = game_root == root.join("dist") || game_root == root.join("game/dist");
|
||
if built {
|
||
let relative = relative_project_path(root, &game_root)?;
|
||
collect_project_export_package_dir_files(root, &relative, &mut files)?;
|
||
for (name, _, _) in &mut files {
|
||
*name = format!(
|
||
"game/{}",
|
||
name.strip_prefix(&format!("{relative}/"))
|
||
.ok_or("构建产物路径非法")?
|
||
);
|
||
}
|
||
} else {
|
||
collect_project_export_package_dir_files(root, "game", &mut files)?;
|
||
}
|
||
if !built && resolve_local_project_path(root, "assets")?.exists() {
|
||
collect_project_export_package_dir_files(root, "assets", &mut files)?;
|
||
}
|
||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||
if !readme_metadata.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
let readme_size = readme_metadata.len();
|
||
files.push(("exports/README.md".to_string(), readme_path, readme_size));
|
||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||
Ok(files)
|
||
}
|
||
|
||
pub(crate) fn ensure_project_export_package_dir(
|
||
root: &Path,
|
||
relative_dir: &str,
|
||
) -> Result<(), String> {
|
||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||
let metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||
if !metadata.is_dir() {
|
||
return Err(format!("{relative_dir} 必须是目录"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn collect_project_export_package_dir_files(
|
||
root: &Path,
|
||
relative_dir: &str,
|
||
files: &mut Vec<(String, PathBuf, u64)>,
|
||
) -> Result<(), String> {
|
||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||
if !dir.exists() {
|
||
return Ok(());
|
||
}
|
||
let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||
if !dir_metadata.is_dir() {
|
||
return Err(format!("{relative_dir} 必须是目录"));
|
||
}
|
||
let mut dirs = vec![dir];
|
||
while let Some(current_dir) = dirs.pop() {
|
||
for entry in fs::read_dir(¤t_dir)
|
||
.map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))?
|
||
{
|
||
let entry = entry
|
||
.map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?;
|
||
let file_type = entry.file_type().map_err(|error| {
|
||
format!("读取导出文件类型失败:{}: {error}", entry.path().display())
|
||
})?;
|
||
let relative_path =
|
||
normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?;
|
||
if file_type.is_symlink() {
|
||
return Err(format!("试玩包不能包含符号链接:{relative_path}"));
|
||
}
|
||
if file_type.is_dir() {
|
||
dirs.push(entry.path());
|
||
} else if file_type.is_file() {
|
||
let size = entry
|
||
.metadata()
|
||
.map_err(|error| {
|
||
format!(
|
||
"读取导出文件元数据失败:{}: {error}",
|
||
entry.path().display()
|
||
)
|
||
})?
|
||
.len();
|
||
files.push((relative_path, entry.path(), size));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn checked_export_package_metadata(
|
||
path: &Path,
|
||
relative_path: &str,
|
||
) -> Result<fs::Metadata, String> {
|
||
let metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!(
|
||
"试玩包不能包含符号链接:{}",
|
||
normalize_export_package_entry_path(relative_path)?
|
||
));
|
||
}
|
||
Ok(metadata)
|
||
}
|
||
|
||
pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result<String, String> {
|
||
if relative_path.chars().any(char::is_control) {
|
||
return Err("试玩包条目路径不能包含控制字符".to_string());
|
||
}
|
||
normalize_relative_path(relative_path)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod npm_export_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn npm_package_contains_only_dist_and_publish_readme() {
|
||
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
|
||
fs::create_dir_all(&base).unwrap();
|
||
let root = tempfile::tempdir_in(base).unwrap();
|
||
for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] {
|
||
fs::create_dir_all(root.path().join(directory)).unwrap();
|
||
}
|
||
for file in [
|
||
"package.json",
|
||
"dist/index.html",
|
||
"dist/assets/main.js",
|
||
"assets/hero.png",
|
||
"exports/README.md",
|
||
"node_modules/private.js",
|
||
"game/source.js",
|
||
] {
|
||
fs::write(root.path().join(file), "test").unwrap();
|
||
}
|
||
let files = collect_project_export_package_files(root.path()).unwrap();
|
||
let names = files
|
||
.iter()
|
||
.map(|(name, _, _)| name.as_str())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
names,
|
||
vec![
|
||
"exports/README.md",
|
||
"game/assets/main.js",
|
||
"game/index.html"
|
||
]
|
||
);
|
||
}
|
||
}
|