Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/export.rs
T
kdletters 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
OSS 写入显式使用 v1 签名
ossutil v2 默认 v4 签名,缺 region 会直接失败\n总号写入与回读统一走 buildOssutilArgs,默认 --sign-version v1,可切 v4 并配合 AGC_OSS_REGION\n补充参数构造单测
2026-09-20 18:33:02 +08:00

479 lines
19 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 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(&current_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"
]
);
}
}