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, pub(crate) package_sha256: String, pub(crate) package_size_bytes: u64, pub(crate) files: Vec, } pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { 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::(); 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 { 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::>(); 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 { 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 { 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, 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 { 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 { 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::>(); assert_eq!( names, vec![ "exports/README.md", "game/assets/main.js", "game/index.html" ] ); } }