c5564e59ed
- 新增 `project/fork_source.rs`:项目改编来源记录落在 `.agent/fork-source.json`
(`{schemaVersion,gameId,versionId,adoptedAtMillis}`,版本 `agc-fork-source.v1`)。
位置理由:`.agent/**` 随项目快照上云、随用户整目录拷贝,且导出包不带 `.agent`,
既随行又不干扰游戏产物;不改 `manifest.json`(它是 deny_unknown_fields + 只接受 v1)。
写入走统一入口 `write_game_creator_private_file`;读取容忍缺失/损坏/异形/非普通文件,
一律返回 None 而不报错、不 panic。
- 新增 `game_fork.rs` + `game_fork/desktop.rs` 与命令
`create_local_project_from_platform_game`(在 `desktop.rs` 的 generate_handler 注册):
取平台会话 → 取件元数据 → 带 Bearer 下载整包 → 校验字节数与 SHA-256(失败关闭、不落盘)
→ `init_local_game_project_at` 生成合规 Phaser4+Vite 脚手架 → 参考副本解压到
`<project>/reference/<gameId>/`(复用 `safe_archive_relative_path` 与
`extract_template_archive` 的条目数/单文件/符号链接门禁,按最小改动把后者提到 pub(crate))
→ 写来源记录;任一步失败都删掉半成品项目目录。
参考副本必须落在子目录:`create_npm_scaffold` 的判据是「没有 manifest 且根/`game` 都没有
index.html、package.json」,成品包若落在项目根或 `game/` 根,脚手架一个文件都不会生成。
- `game_distribution_publish.rs` 新增受鉴权取件实现:`fetch_platform_game_fork_source`
(元数据 GET + 带 Bearer 的整包下载 + 404/409/403/401 四态可区分映射 + 下载路径同源校验)。
不复用模板库的 `fetch_limited_bytes`:它写死 `client.get(url)`,带不了鉴权头,
复用它会让受保护内容变成匿名下载。
- 埋点:新增 `CreationSource::PlatformGame`(`analytics/contract.rs` 的 values! 登记)并在建项
成功后上报,来源标识沿用模板链路的 ID 槽位。
- 修既有编译错误:`game_distribution_publish.rs` 的资料摘要测试缺 `fork` 字段
(上一提交给 `GameDistributionCreateGameRequest` 加了该字段但没同步该测试字面量),
补 `fork: None` 以恢复测试目标可编译。
311 lines
14 KiB
Rust
311 lines
14 KiB
Rust
//! 从平台作品开始创作(成品包路径):受鉴权取件 → 摘要校验 → 合规脚手架 + 参考副本 + 来源记录。
|
||
//!
|
||
//! 边界(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 / §3.5.4 路线 A 一致):
|
||
//! 平台下发的是**已构建的发行成品包**(只有 `dist` 产物、没有 `package.json` 也没有源码),
|
||
//! 它只能当「可玩参考 + 素材来源」,**不能**变成可直接发布的项目。因此这里建立的是
|
||
//! 「合规的 Phaser4 + Vite 脚手架」+「子目录里的参考副本」,用户在脚手架上自己改造。
|
||
//!
|
||
//! 关键顺序(顺序本身就是合同):
|
||
//! 1. 先取件并按元数据校验字节(失败关闭,不落盘);
|
||
//! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是
|
||
//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以参考产物
|
||
//! 必须等脚手架生成之后再铺,且只能铺进子目录;
|
||
//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明改编来源。
|
||
|
||
#[cfg(not(test))]
|
||
mod desktop;
|
||
#[cfg(not(test))]
|
||
pub(crate) use desktop::*;
|
||
|
||
use super::*;
|
||
|
||
/// 参考副本的子目录名。绝不能落在项目根或 `game/` 根:那会让合规脚手架一个文件都不生成。
|
||
const FORK_REFERENCE_DIRECTORY_NAME: &str = "reference";
|
||
|
||
/// 取件字节校验:字节数与 SHA-256 都必须与取件元数据一致,任一不符即失败关闭。
|
||
///
|
||
/// 抽成纯函数是为了让「失败就绝不落盘」这条规则可被单测钉住:调用点在解压之前。
|
||
pub(crate) fn verify_fork_source_bytes(
|
||
bytes: &[u8],
|
||
expected_sha256: &str,
|
||
expected_bytes: u64,
|
||
) -> Result<(), String> {
|
||
if bytes.len() as u64 != expected_bytes {
|
||
return Err(format!(
|
||
"改编来源发行包大小校验失败(期望 {expected_bytes} 字节,实际 {} 字节),已放弃落盘",
|
||
bytes.len()
|
||
));
|
||
}
|
||
let actual = sha256_hex(bytes);
|
||
if actual != expected_sha256.trim().to_ascii_lowercase() {
|
||
return Err("改编来源发行包完整性校验失败,已放弃落盘".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn sha256_hex(bytes: &[u8]) -> String {
|
||
let mut hasher = sha2::Sha256::new();
|
||
hasher.update(bytes);
|
||
format!("{:x}", hasher.finalize())
|
||
}
|
||
|
||
/// 参考副本相对项目的路径:`reference/<gameId>/`。
|
||
///
|
||
/// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与
|
||
/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。
|
||
pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, String> {
|
||
let game_id = game_id.trim();
|
||
// 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被
|
||
// 归一成 `reference`,那不是某个作品的参考目录。
|
||
if game_id.is_empty() {
|
||
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
|
||
}
|
||
let relative = format!("{FORK_REFERENCE_DIRECTORY_NAME}/{game_id}");
|
||
let relative = crate::template_library::safe_archive_relative_path(&relative)
|
||
.map_err(|_| "改编来源作品标识无效,无法确定参考副本目录".to_string())?;
|
||
// 只接受「`reference` + 单一作品目录」两段:带 `/` 的标识会变成嵌套目录,既可能撞上
|
||
// 另一个作品的目录,也不是平台作品 ID 的形状。
|
||
if relative.components().count() != 2 {
|
||
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
|
||
}
|
||
Ok(relative)
|
||
}
|
||
|
||
/// 参考副本目录:`<project>/reference/<gameId>/`。
|
||
pub(crate) fn fork_reference_directory(
|
||
project_root: &Path,
|
||
game_id: &str,
|
||
) -> Result<PathBuf, String> {
|
||
Ok(project_root.join(fork_reference_relative_path(game_id)?))
|
||
}
|
||
|
||
/// 用平台作品的成品包建一个新项目。
|
||
///
|
||
/// 先按标准初始化生成合规脚手架,再把参考副本铺进 `<project>/reference/<gameId>/`,
|
||
/// 最后补齐来源记录;任一步失败都删掉半成品目录,不留无法解释的项目。
|
||
pub(crate) fn create_project_from_platform_fork_at(
|
||
projects_root: &Path,
|
||
game_id: &str,
|
||
version_id: &str,
|
||
package_bytes: &[u8],
|
||
requested_name: Option<&str>,
|
||
planning: bool,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let requested_name = requested_name
|
||
.map(normalize_game_creation_project_name)
|
||
.transpose()?;
|
||
// 目录名先算出来:标识非法时要在创建任何目录之前失败,不能留下半成品。
|
||
let reference_relative = fork_reference_relative_path(game_id)?;
|
||
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
|
||
return Err("自动工作区根目录必须是绝对路径".to_string());
|
||
}
|
||
ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
|
||
prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
|
||
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
|
||
format!(
|
||
"读取自动工作区根目录失败:{}: {error}",
|
||
projects_root.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("自动工作区根目录必须是普通文件夹".to_string());
|
||
}
|
||
|
||
for _ in 0..16 {
|
||
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
|
||
let short_id = &workspace_id[..8];
|
||
let project_name = requested_name.clone().unwrap_or_else(|| {
|
||
let prefix = if planning {
|
||
"策划项目"
|
||
} else {
|
||
"改编项目"
|
||
};
|
||
format!("{prefix} {short_id}")
|
||
});
|
||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||
match fs::create_dir(&project_root) {
|
||
Ok(()) => {
|
||
let result = (|| {
|
||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||
// 脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被
|
||
// 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。
|
||
let project = init_local_game_project_at(
|
||
&project_root,
|
||
&format!("gameagent-{workspace_id}"),
|
||
&project_name,
|
||
)?;
|
||
let reference_root = project_root.join(&reference_relative);
|
||
ensure_game_creator_private_directory_tree(&reference_root, "改编参考目录")?;
|
||
// 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、
|
||
// 条目路径只允许项目内相对路径。
|
||
crate::template_library::extract_template_archive(
|
||
package_bytes,
|
||
&reference_root,
|
||
)
|
||
.map_err(|error| format!("改编来源发行包解压失败:{error}"))?;
|
||
write_project_fork_source(
|
||
&project_root,
|
||
&ProjectForkSourceRecord::new(game_id, version_id),
|
||
)?;
|
||
Ok(project)
|
||
})();
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&project_root);
|
||
}
|
||
return result;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"创建自动工作区失败:{}: {error}",
|
||
project_root.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err("自动工作区命名冲突,请重试".to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use zip::write::SimpleFileOptions;
|
||
|
||
fn test_root(label: &str) -> PathBuf {
|
||
let nonce = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|elapsed| elapsed.as_nanos())
|
||
.unwrap_or_default();
|
||
let root = std::env::temp_dir().join(format!(
|
||
"agc-game-fork-{label}-{}-{nonce}",
|
||
std::process::id()
|
||
));
|
||
fs::create_dir_all(&root).expect("create temp root");
|
||
root
|
||
}
|
||
|
||
/// 最小成品包:发行包的真实形状是「运行产物 + 根 index.html」,不含 package.json。
|
||
fn release_package_bytes() -> Vec<u8> {
|
||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
let options = SimpleFileOptions::default();
|
||
writer.start_file("index.html", options).expect("start entry");
|
||
std::io::Write::write_all(&mut writer, b"<html></html>").expect("write entry");
|
||
writer.start_file("game/main.js", options).expect("start script");
|
||
std::io::Write::write_all(&mut writer, b"console.log('playable')").expect("write script");
|
||
writer.finish().expect("finish zip").into_inner()
|
||
}
|
||
|
||
#[test]
|
||
fn fork_source_bytes_must_match_declared_size_and_digest() {
|
||
let bytes = release_package_bytes();
|
||
let digest = sha256_hex(&bytes);
|
||
assert!(verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64).is_ok());
|
||
// 摘要大小写不敏感(服务端回小写,客户端仍按同一口径比较)。
|
||
assert!(verify_fork_source_bytes(&bytes, &digest.to_uppercase(), bytes.len() as u64).is_ok());
|
||
|
||
let size_error = verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64 + 1)
|
||
.expect_err("大小不符必须失败");
|
||
assert!(size_error.contains("大小校验失败"), "{size_error}");
|
||
let digest_error = verify_fork_source_bytes(&bytes, &"a".repeat(64), bytes.len() as u64)
|
||
.expect_err("摘要不符必须失败");
|
||
assert!(digest_error.contains("完整性校验失败"), "{digest_error}");
|
||
assert!(verify_fork_source_bytes(&[], &digest, 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn fork_reference_directory_stays_inside_the_project() {
|
||
let root = Path::new("C:/work/project");
|
||
assert_eq!(
|
||
fork_reference_directory(root, "game_1").expect("safe id"),
|
||
root.join("reference").join("game_1")
|
||
);
|
||
// 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。
|
||
for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] {
|
||
assert!(
|
||
fork_reference_directory(root, unsafe_id).is_err(),
|
||
"should reject {unsafe_id:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn platform_fork_project_gets_scaffold_reference_copy_and_source_record() {
|
||
let projects_root = test_root("create");
|
||
let bytes = release_package_bytes();
|
||
let project = create_project_from_platform_fork_at(
|
||
&projects_root,
|
||
"game_parent",
|
||
"gamever_parent",
|
||
&bytes,
|
||
Some("改编测试"),
|
||
false,
|
||
)
|
||
.expect("create fork project");
|
||
let root = Path::new(&project.project_path);
|
||
|
||
// 1) 合规脚手架正常生成:`create_npm_scaffold` 的判据没有被参考产物打断。
|
||
assert!(root.join("game/package.json").is_file());
|
||
assert!(root.join("game/vite.config.js").is_file());
|
||
assert!(root.join("game/index.html").is_file());
|
||
assert!(root.join(".agent/manifest.json").is_file());
|
||
|
||
// 2) 参考副本落在子目录里,且与脚手架互不覆盖。
|
||
assert!(root.join("reference/game_parent/index.html").is_file());
|
||
assert!(root.join("reference/game_parent/game/main.js").is_file());
|
||
assert_ne!(
|
||
fs::read_to_string(root.join("reference/game_parent/index.html")).unwrap(),
|
||
fs::read_to_string(root.join("game/index.html")).unwrap()
|
||
);
|
||
|
||
// 3) 来源记录与项目身份一致。
|
||
let record = read_project_fork_source(root).expect("fork source record");
|
||
assert_eq!(record.game_id, "game_parent");
|
||
assert_eq!(record.version_id, "gamever_parent");
|
||
assert_eq!(project.manifest.name, "改编测试");
|
||
}
|
||
|
||
#[test]
|
||
fn failed_fork_creation_leaves_no_half_built_project() {
|
||
let projects_root = test_root("rollback");
|
||
// 不是合法 zip:解压必须在铺参考副本这一步失败,然后整个项目目录被移除。
|
||
let error = create_project_from_platform_fork_at(
|
||
&projects_root,
|
||
"game_parent",
|
||
"gamever_parent",
|
||
b"not-a-zip",
|
||
None,
|
||
false,
|
||
)
|
||
.expect_err("invalid archive must fail");
|
||
assert!(error.contains("zip"), "{error}");
|
||
let leftovers = fs::read_dir(&projects_root)
|
||
.expect("read projects root")
|
||
.filter_map(Result::ok)
|
||
.filter(|entry| entry.file_name().to_string_lossy().starts_with("gameagent-"))
|
||
.count();
|
||
assert_eq!(leftovers, 0, "失败的项目目录必须被清理");
|
||
}
|
||
|
||
#[test]
|
||
fn unsafe_game_id_fails_before_any_directory_is_created() {
|
||
let projects_root = test_root("unsafe-id");
|
||
let bytes = release_package_bytes();
|
||
assert!(create_project_from_platform_fork_at(
|
||
&projects_root,
|
||
"../escape",
|
||
"gamever_parent",
|
||
&bytes,
|
||
None,
|
||
false,
|
||
)
|
||
.is_err());
|
||
assert_eq!(
|
||
fs::read_dir(&projects_root).expect("read root").count(),
|
||
0,
|
||
"标识非法时不得创建任何目录"
|
||
);
|
||
}
|
||
}
|