feat(游戏共创): 客户端落地「从平台作品开始创作」取件链路(本地来源记录 + 新命令)
- 新增 `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` 以恢复测试目标可编译。
This commit is contained in:
@@ -60,7 +60,9 @@ values!(CreationSource {
|
||||
HomeGame,
|
||||
HomeDesign,
|
||||
Template,
|
||||
SelectedDirectory
|
||||
SelectedDirectory,
|
||||
// 从平台作品开始创作(改编 Fork):取件自平台已授权公开作品的发行成品包。
|
||||
PlatformGame
|
||||
});
|
||||
values!(OpenSource {
|
||||
Create,
|
||||
|
||||
@@ -275,3 +275,24 @@ fn frozen_context_and_origin_do_not_inherit_new_account() {
|
||||
== false
|
||||
);
|
||||
}
|
||||
|
||||
/// 新增创建来源变体(平台作品改编)不得改变既有取值或解析:老变体照常,新变体按 snake_case
|
||||
/// 落到 `platform_game`,未知值仍然失败关闭。
|
||||
#[test]
|
||||
fn creation_source_gains_platform_game_without_breaking_existing_values() {
|
||||
for (wire, expected) in [
|
||||
("home_game", CreationSource::HomeGame),
|
||||
("home_design", CreationSource::HomeDesign),
|
||||
("template", CreationSource::Template),
|
||||
("selected_directory", CreationSource::SelectedDirectory),
|
||||
("platform_game", CreationSource::PlatformGame),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CreationSource>(json!(wire)).unwrap(),
|
||||
expected,
|
||||
"{wire}"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(expected).unwrap(), json!(wire));
|
||||
}
|
||||
assert!(serde_json::from_value::<CreationSource>(json!("remix")).is_err());
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use client_extensions::*;
|
||||
use environment_check::preflight_web_game_creation;
|
||||
use error_report::*;
|
||||
use game_distribution_publish::*;
|
||||
use game_fork::*;
|
||||
use plugin_host::{
|
||||
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
|
||||
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path,
|
||||
@@ -539,6 +540,7 @@ pub(super) fn run() {
|
||||
stop_game_creator_external_mcp,
|
||||
create_automatic_local_game_project,
|
||||
create_automatic_local_game_project_from_template,
|
||||
create_local_project_from_platform_game,
|
||||
init_local_game_project,
|
||||
fetch_game_template_library,
|
||||
get_game_template_library_access,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::platform_session::{
|
||||
current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot,
|
||||
};
|
||||
use base64::Engine;
|
||||
use futures::StreamExt;
|
||||
|
||||
use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE};
|
||||
use reqwest::{Method, StatusCode};
|
||||
@@ -26,8 +27,9 @@ use shared_contracts::game_creation_app::{
|
||||
};
|
||||
use shared_contracts::game_distribution::{
|
||||
GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest,
|
||||
GameDistributionDeviceSupport, GameDistributionInputMode, GameDistributionOrientation,
|
||||
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
|
||||
GameDistributionDeviceSupport, GameDistributionForkSource, GameDistributionInputMode,
|
||||
GameDistributionOrientation, GameDistributionPublishMetadataSuggestion,
|
||||
GameDistributionPublishMetadataSuggestionRequest,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
@@ -261,7 +263,10 @@ struct SubmittedVersionSummary {
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
fn require_platform_session() -> Result<PlatformSessionSnapshot, String> {
|
||||
/// 取当前平台会话;缺失时给出可操作的登录提示。
|
||||
///
|
||||
/// `pub(crate)`:Fork 取件(`game_fork`)在建项前要冻结同一份会话身份。
|
||||
pub(crate) fn require_platform_session() -> Result<PlatformSessionSnapshot, String> {
|
||||
current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string())
|
||||
}
|
||||
@@ -501,6 +506,204 @@ fn parse_owner_game_entry(value: Value) -> Result<OwnerGameEntry, String> {
|
||||
serde_json::from_value(value).map_err(|error| format!("作者作品响应无效:{error}"))
|
||||
}
|
||||
|
||||
/// 取件整包上限:与模板整包同一档(512 MiB),也远低于服务端进程内缓存上限的 4 倍余量。
|
||||
const FORK_SOURCE_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// 取件失败的用户可见分类:404 / 409 / 403 / 401 四态必须可区分。
|
||||
///
|
||||
/// 状态码本身就是服务端合同(`fork_source_target`):行不存在 → 404;不可用作来源
|
||||
/// (未公开 / 已软删除 / 没有当前公开版本 / 缺版本元数据)→ 409;授权禁止或未知 → 403;
|
||||
/// 未登录 → 401。因此这里按状态映射,不依赖 `error.code`。
|
||||
fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
|
||||
let (code, message) = parse_error_payload(body);
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string()
|
||||
}
|
||||
StatusCode::FORBIDDEN => {
|
||||
"permission-denied: 作者没有开放这个作品的共创授权,无法改编".to_string()
|
||||
}
|
||||
StatusCode::NOT_FOUND => "该作品不存在或已被删除,无法改编".to_string(),
|
||||
StatusCode::CONFLICT => {
|
||||
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)".to_string()
|
||||
}
|
||||
_ => format!(
|
||||
"读取改编来源失败:{}",
|
||||
server_error_detail(code, message, || format!("HTTP {}", status.as_u16()))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取件下载路径 → 路径段:只接受同源相对路径,拒绝绝对 URL、盘符、反斜杠与查询/片段。
|
||||
fn fork_source_download_segments(download_path: &str) -> Result<Vec<String>, String> {
|
||||
let trimmed = download_path.trim();
|
||||
if !trimmed.starts_with('/') || trimmed.starts_with("//") {
|
||||
return Err("改编来源下载路径无效".to_string());
|
||||
}
|
||||
if trimmed.contains(['\\', '?', '#']) || trimmed.contains("://") {
|
||||
return Err("改编来源下载路径无效".to_string());
|
||||
}
|
||||
let segments = trimmed
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if segments.is_empty()
|
||||
|| segments
|
||||
.iter()
|
||||
.any(|segment| segment == "." || segment == ".." || segment.contains(':'))
|
||||
{
|
||||
return Err("改编来源下载路径无效".to_string());
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
/// 取件元数据(`GET …/fork-source`):受鉴权但不叠加发布灰度。
|
||||
async fn request_fork_source_metadata(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
game_id: &str,
|
||||
) -> Result<Value, String> {
|
||||
let current = current_scoped_session(snapshot)?;
|
||||
let url = endpoint(
|
||||
snapshot,
|
||||
&["api", "game-distribution", "games", game_id, "fork-source"],
|
||||
)?;
|
||||
let response = client
|
||||
.request(Method::GET, &url)
|
||||
.bearer_auth(¤t.access_token)
|
||||
.header(
|
||||
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
|
||||
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
|
||||
)
|
||||
.header(
|
||||
HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
|
||||
HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
|
||||
)
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"读取改编来源失败:请求超时,请稍后重试".to_string()
|
||||
} else {
|
||||
"读取改编来源失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
|
||||
.to_string()
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取改编来源失败:读取响应失败:{error}"))?;
|
||||
validate_session(snapshot)?;
|
||||
if !status.is_success() {
|
||||
return Err(map_fork_source_http_error(status, &text));
|
||||
}
|
||||
response_data(&text).map_err(|error| format!("读取改编来源失败:{error}"))
|
||||
}
|
||||
|
||||
/// 取件本体(`GET {downloadPath}`):整包进内存,超过契约上限即失败关闭。
|
||||
///
|
||||
/// **必须带 Bearer**:取件包与发行网关同源受保护,模板库那条 `fetch_limited_bytes` 的签名
|
||||
/// 写死 `client.get(url)`、带不了鉴权头,绝不能复用它来取受保护内容(会静默变成匿名下载)。
|
||||
async fn request_fork_source_package(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
segments: &[String],
|
||||
max_bytes: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let current = current_scoped_session(snapshot)?;
|
||||
let segment_refs = segments.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let url = endpoint(snapshot, &segment_refs)?;
|
||||
let response = client
|
||||
.request(Method::GET, &url)
|
||||
.bearer_auth(¤t.access_token)
|
||||
.header(
|
||||
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
|
||||
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
|
||||
)
|
||||
.header(
|
||||
HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
|
||||
HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
|
||||
)
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"下载改编来源失败:请求超时,请稍后重试".to_string()
|
||||
} else {
|
||||
"下载改编来源失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
|
||||
.to_string()
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
validate_session(snapshot)?;
|
||||
return Err(map_fork_source_http_error(status, &text));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_bytes)
|
||||
{
|
||||
return Err("改编来源发行包超过客户端下载上限,无法改编".to_string());
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| format!("下载改编来源失败:读取响应失败:{error}"))?;
|
||||
if body.len() as u64 + chunk.len() as u64 > max_bytes {
|
||||
return Err("改编来源发行包超过客户端下载上限,无法改编".to_string());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
validate_session(snapshot)?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 取件:读 Fork 来源元数据,再按 `bytes` 上限下载整包字节。
|
||||
///
|
||||
/// 摘要与字节数**不在这里**校验:调用方(`game_fork`)必须在任何落盘之前用
|
||||
/// `verify_fork_source_bytes` 校验,保证失败关闭发生在解压之前。
|
||||
pub(crate) async fn fetch_platform_game_fork_source(
|
||||
game_id: &str,
|
||||
) -> Result<(GameDistributionForkSource, Vec<u8>), String> {
|
||||
let game_id = game_id.trim();
|
||||
// 路径安全判据与发行入口一致:只接受 ASCII 字母数字与 `-`/`_`,不接受分隔符与盘符。
|
||||
if game_id.is_empty()
|
||||
|| !game_id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_')
|
||||
{
|
||||
return Err("改编来源作品标识无效".to_string());
|
||||
}
|
||||
let snapshot = require_platform_session()?;
|
||||
let client = build_client()?;
|
||||
let value = request_fork_source_metadata(&client, &snapshot, game_id).await?;
|
||||
let source: GameDistributionForkSource = serde_json::from_value(
|
||||
value
|
||||
.get("forkSource")
|
||||
.cloned()
|
||||
.ok_or_else(|| "改编来源响应缺少来源信息,请稍后重试".to_string())?,
|
||||
)
|
||||
.map_err(|error| format!("改编来源响应无效:{error}"))?;
|
||||
if source.game_id.trim().is_empty() || source.version_id.trim().is_empty() {
|
||||
return Err("改编来源响应无效:缺少作品或版本标识".to_string());
|
||||
}
|
||||
if source.bytes == 0 || source.bytes > FORK_SOURCE_PACKAGE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"改编来源发行包大小异常({} 字节),客户端无法下载",
|
||||
source.bytes
|
||||
));
|
||||
}
|
||||
let segments = fork_source_download_segments(&source.download_path)?;
|
||||
let bytes = request_fork_source_package(&client, &snapshot, &segments, source.bytes).await?;
|
||||
Ok((source, bytes))
|
||||
}
|
||||
|
||||
fn first_publish_state(message: Option<String>) -> GameDistributionPublicationReadResult {
|
||||
GameDistributionPublicationReadResult {
|
||||
state: "first-publish".to_string(),
|
||||
@@ -1633,6 +1836,8 @@ mod tests {
|
||||
// 上架时的共创授权档位;AGC 发布面板接线前先按缺省值构造(与不传该字段等价)。
|
||||
fork_authorization:
|
||||
shared_contracts::game_distribution::GameDistributionForkAuthorization::Forbidden,
|
||||
// 改编来源声明:只在从平台作品改编时由发布链路并入,这里构造基线资料时为空。
|
||||
fork: None,
|
||||
};
|
||||
let first = metadata_digest(&metadata).expect("digest");
|
||||
assert_eq!(first.len(), 64);
|
||||
@@ -1641,6 +1846,75 @@ mod tests {
|
||||
assert_ne!(metadata_digest(&metadata).expect("changed"), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_errors_keep_contract_statuses_distinguishable() {
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(StatusCode::UNAUTHORIZED, "{}"),
|
||||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
|
||||
);
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
r#"{"error":{"code":"FORK_NOT_AUTHORIZED","message":"未开放共创"}}"#
|
||||
),
|
||||
"permission-denied: 作者没有开放这个作品的共创授权,无法改编"
|
||||
);
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"#
|
||||
),
|
||||
"该作品不存在或已被删除,无法改编"
|
||||
);
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(
|
||||
StatusCode::CONFLICT,
|
||||
r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"#
|
||||
),
|
||||
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
|
||||
);
|
||||
// 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
r#"{"error":{"code":"INTERNAL","message":"服务异常"}}"#
|
||||
),
|
||||
"读取改编来源失败:服务异常"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_download_path_accepts_only_same_origin_relative_paths() {
|
||||
assert_eq!(
|
||||
fork_source_download_segments("/api/game-distribution/games/game_1/fork-source/package")
|
||||
.expect("relative path"),
|
||||
vec![
|
||||
"api",
|
||||
"game-distribution",
|
||||
"games",
|
||||
"game_1",
|
||||
"fork-source",
|
||||
"package"
|
||||
]
|
||||
);
|
||||
for unsafe_path in [
|
||||
"",
|
||||
"api/game-distribution/games/game_1/fork-source/package",
|
||||
"//evil.example.com/package",
|
||||
"https://evil.example.com/package",
|
||||
"/api/../package",
|
||||
"/api\\package",
|
||||
"/api/package?token=1",
|
||||
"/api/package#frag",
|
||||
"/api/C:package",
|
||||
] {
|
||||
assert!(
|
||||
fork_source_download_segments(unsafe_path).is_err(),
|
||||
"should reject {unsafe_path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_game_entry_parses_author_payload_with_local_project_id() {
|
||||
let entry = parse_owner_game_entry(json!({
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 从平台作品开始创作(成品包路径):受鉴权取件 → 摘要校验 → 合规脚手架 + 参考副本 + 来源记录。
|
||||
//!
|
||||
//! 边界(与 `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,
|
||||
"标识非法时不得创建任何目录"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 桌面 Fork 取件接入;摘要校验、参考副本落点与来源记录规则保留在父模块供现有测试验证。
|
||||
use super::*;
|
||||
use crate::game_distribution_publish::{
|
||||
fetch_platform_game_fork_source, require_platform_session,
|
||||
};
|
||||
|
||||
/// 从平台作品开始创作:取平台已授权公开作品的发行成品包,建成「合规脚手架 + 参考副本」项目。
|
||||
///
|
||||
/// 失败关闭点(按顺序):
|
||||
/// 1. 没有平台登录态 → 直接给出可操作的登录提示,不发起任何请求;
|
||||
/// 2. 取件元数据/下载失败(404 / 409 / 403 / 401)→ 原样返回可区分的错误,不建目录;
|
||||
/// 3. 字节数或 SHA-256 与元数据不符 → 在解压**之前**失败,不落盘;
|
||||
/// 4. 建项或解压途中失败 → 删除半成品项目目录。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_local_project_from_platform_game(
|
||||
app: tauri::AppHandle,
|
||||
game_id: String,
|
||||
name: Option<String>,
|
||||
planning: Option<bool>,
|
||||
projects_root: Option<String>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let analytics_context = crate::analytics::gui::capture_analytics_context();
|
||||
let game_id = game_id.trim().to_string();
|
||||
if game_id.is_empty() {
|
||||
return Err("请先选择要改编的平台作品".to_string());
|
||||
}
|
||||
// 先冻结会话身份:取件与建项之间换号必须失败关闭,不能把 A 的取件结果落成 B 的项目。
|
||||
let session = require_platform_session()?;
|
||||
let identity = session.identity();
|
||||
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
|
||||
let (source, package_bytes) = fetch_platform_game_fork_source(&game_id).await?;
|
||||
verify_fork_source_bytes(&package_bytes, &source.sha256, source.bytes)?;
|
||||
let result = with_validated_platform_session_identity(&identity, || {
|
||||
create_project_from_platform_fork_at(
|
||||
&projects_root,
|
||||
&source.game_id,
|
||||
&source.version_id,
|
||||
&package_bytes,
|
||||
name.as_deref(),
|
||||
planning.unwrap_or(false),
|
||||
)
|
||||
});
|
||||
if let Ok(project) = &result {
|
||||
crate::analytics::gui::created(
|
||||
analytics_context,
|
||||
Path::new(&project.project_path),
|
||||
project.manifest.project_id.clone(),
|
||||
crate::analytics::contract::CreationSource::PlatformGame,
|
||||
// 平台作品 ID 作为来源标识:与模板链路的 `template_id` 位置同义(创建来源的稳定 ID)。
|
||||
Some(source.game_id.clone()),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -103,6 +103,7 @@ mod editor_adapters;
|
||||
mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_distribution_publish;
|
||||
mod game_fork;
|
||||
mod game_package_upload;
|
||||
mod game_publish_attempt;
|
||||
mod git_inspect;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod conversation;
|
||||
mod export;
|
||||
mod external_editor_bindings;
|
||||
mod filesystem;
|
||||
mod fork_source;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod model_usage;
|
||||
@@ -32,6 +33,7 @@ pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
pub(crate) use external_editor_bindings::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use fork_source::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use model_usage::*;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 项目改编来源记录:`.agent/fork-source.json`。
|
||||
//!
|
||||
//! 承载「本项目改编自平台作品 X」这一事实:由「从平台作品开始创作」在取件校验通过、
|
||||
//! 参考副本铺好之后写入,由发布链路的首次发布读取并并入创建作品请求的 `fork` 声明。
|
||||
//!
|
||||
//! 落点选择(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 一致):
|
||||
//! - `.agent/**` 随项目快照上云、随用户整目录拷贝,是唯一既「随行」又不干扰游戏产物的位置;
|
||||
//! - 导出包(`exports/playtest-package-*.zip`)**不**透传 `.agent/`:这是已知取舍——
|
||||
//! 血缘不需要随试玩包分发,平台侧的血缘在创建作品时一次性写入;
|
||||
//! - 不改 `manifest.json`:它是 `deny_unknown_fields` 且只接受 v1,加顶层字段等于改契约。
|
||||
//!
|
||||
//! 读取必须是「可选项语义」:文件缺失、损坏、字段不合法或路径不合格一律按「没有来源记录」
|
||||
//! 处理(返回 `None`),绝不能让一个可选元数据文件把发布整体拖失败。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 记录格式版本。字段形状变化时递增,旧版本按「没有来源记录」处理(失败关闭到不声明来源)。
|
||||
pub(crate) const FORK_SOURCE_SCHEMA_VERSION: &str = "agc-fork-source.v1";
|
||||
|
||||
/// 相对项目根的位置:`.agent` 是项目身份与 Agent 状态的权威目录,整目录随项目同步。
|
||||
pub(crate) const FORK_SOURCE_RELATIVE_PATH: &str = ".agent/fork-source.json";
|
||||
|
||||
const FORK_SOURCE_LABEL: &str = "项目改编来源记录";
|
||||
|
||||
/// 「本项目改编自平台作品」的本地记录。
|
||||
///
|
||||
/// `schema_version` 必填且必须等于当前版本;`game_id` / `version_id` 是平台侧的作品身份与
|
||||
/// 建立血缘时锁定的父版本,发布时原样并入创建作品请求的 `fork` 字段。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ProjectForkSourceRecord {
|
||||
pub(crate) schema_version: String,
|
||||
pub(crate) game_id: String,
|
||||
pub(crate) version_id: String,
|
||||
/// 取件成功并铺好参考副本的时刻(epoch 毫秒),只作本地审计,不参与任何上传。
|
||||
pub(crate) adopted_at_millis: u64,
|
||||
}
|
||||
|
||||
impl ProjectForkSourceRecord {
|
||||
/// 新建一条来源记录;时间戳由本模块统一生成,调用方不自己造时间。
|
||||
pub(crate) fn new(game_id: &str, version_id: &str) -> Self {
|
||||
Self {
|
||||
schema_version: FORK_SOURCE_SCHEMA_VERSION.to_string(),
|
||||
game_id: game_id.to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
adopted_at_millis: now_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 来源记录在本项目里的绝对路径。
|
||||
pub(crate) fn fork_source_record_path(project_root: &Path) -> PathBuf {
|
||||
project_root.join(FORK_SOURCE_RELATIVE_PATH)
|
||||
}
|
||||
|
||||
/// 解析来源记录正文:格式版本不符、字段缺失或标识为空都按「没有来源记录」处理。
|
||||
///
|
||||
/// 抽成纯函数是为了让「损坏不 panic」这条规则可以被单测钉住,而不是散在读取路径里。
|
||||
pub(crate) fn parse_project_fork_source(bytes: &[u8]) -> Option<ProjectForkSourceRecord> {
|
||||
let record: ProjectForkSourceRecord = serde_json::from_slice(bytes).ok()?;
|
||||
if record.schema_version != FORK_SOURCE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if record.game_id.trim().is_empty() || record.version_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// 读取来源记录:缺失、损坏、异形一律 `None`,不返回错误、不 panic。
|
||||
pub(crate) fn read_project_fork_source(project_root: &Path) -> Option<ProjectForkSourceRecord> {
|
||||
let path = fork_source_record_path(project_root);
|
||||
// 复用私有路径门禁:链接、非普通文件或权限不达标的记录都不读,失败即当作「没有记录」。
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, FORK_SOURCE_LABEL).ok()? {
|
||||
return None;
|
||||
}
|
||||
let bytes = fs::read(&path).ok()?;
|
||||
parse_project_fork_source(&bytes)
|
||||
}
|
||||
|
||||
/// 写入来源记录:走 AGC 管理文件的统一入口(校验父目录策略、临时 inode 原子安装)。
|
||||
pub(crate) fn write_project_fork_source(
|
||||
project_root: &Path,
|
||||
record: &ProjectForkSourceRecord,
|
||||
) -> Result<(), String> {
|
||||
let body = serde_json::to_vec_pretty(record)
|
||||
.map_err(|error| format!("序列化 {FORK_SOURCE_LABEL}失败:{error}"))?;
|
||||
write_game_creator_private_file(
|
||||
&fork_source_record_path(project_root),
|
||||
&body,
|
||||
FORK_SOURCE_LABEL,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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-fork-source-{label}-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&root).expect("create temp project root");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_record_round_trips_through_project_agent_directory() {
|
||||
let root = test_root("round-trip");
|
||||
let record = ProjectForkSourceRecord::new("game_parent_1", "gamever_parent_1");
|
||||
write_project_fork_source(&root, &record).expect("write fork source record");
|
||||
|
||||
// 落点固定:`.agent/fork-source.json`,不落在项目根,也不进 manifest。
|
||||
assert!(fork_source_record_path(&root).is_file());
|
||||
assert_eq!(
|
||||
fork_source_record_path(&root),
|
||||
root.join(".agent/fork-source.json")
|
||||
);
|
||||
let read = read_project_fork_source(&root).expect("read back");
|
||||
assert_eq!(read, record);
|
||||
assert_eq!(read.schema_version, FORK_SOURCE_SCHEMA_VERSION);
|
||||
assert_eq!(read.game_id, "game_parent_1");
|
||||
assert_eq!(read.version_id, "gamever_parent_1");
|
||||
assert!(read.adopted_at_millis > 0);
|
||||
|
||||
// 覆盖写:同一路径第二次写入必须替换而不是失败。
|
||||
let updated = ProjectForkSourceRecord::new("game_parent_2", "gamever_parent_2");
|
||||
write_project_fork_source(&root, &updated).expect("overwrite fork source record");
|
||||
assert_eq!(read_project_fork_source(&root), Some(updated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fork_source_record_reads_as_none() {
|
||||
let root = test_root("missing");
|
||||
assert_eq!(read_project_fork_source(&root), None);
|
||||
// 连 `.agent` 目录都没有时同样只能是 None,不能报错。
|
||||
assert!(!root.join(".agent").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_fork_source_record_reads_as_none_without_panicking() {
|
||||
let root = test_root("corrupted");
|
||||
let path = fork_source_record_path(&root);
|
||||
for body in [
|
||||
// 不是 JSON
|
||||
b"not-json{".as_slice(),
|
||||
// 不是对象
|
||||
b"[]".as_slice(),
|
||||
// 合法 JSON 但缺字段
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1"}"#.as_slice(),
|
||||
// 未知顶层字段(deny_unknown_fields)
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1","versionId":"v1","adoptedAtMillis":1,"extra":true}"#.as_slice(),
|
||||
// 格式版本不是当前版本
|
||||
br#"{"schemaVersion":"agc-fork-source.v2","gameId":"game_1","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 标识为空
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":" ","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 空文件
|
||||
b"".as_slice(),
|
||||
] {
|
||||
write_game_creator_private_file(&path, body, FORK_SOURCE_LABEL)
|
||||
.expect("write corrupted record");
|
||||
assert_eq!(read_project_fork_source(&root), None, "{body:?}");
|
||||
}
|
||||
// 目录顶替文件:不是普通文件,同样只能得到 None。
|
||||
fs::remove_file(&path).expect("remove file");
|
||||
fs::create_dir(&path).expect("create directory in place of record");
|
||||
assert_eq!(read_project_fork_source(&root), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_parse_accepts_only_current_schema_and_non_empty_identifiers() {
|
||||
let valid = br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1","versionId":"gamever_1","adoptedAtMillis":7}"#;
|
||||
let record = parse_project_fork_source(valid).expect("valid record");
|
||||
assert_eq!(record.adopted_at_millis, 7);
|
||||
assert!(parse_project_fork_source(b"null").is_none());
|
||||
}
|
||||
}
|
||||
@@ -622,7 +622,10 @@ pub(crate) fn safe_archive_relative_path(raw: &str) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result<usize, String> {
|
||||
/// 解压模板归档到目标目录:条目数、单文件大小、符号链接与条目路径都走同一套门禁。
|
||||
///
|
||||
/// `pub(crate)`:Fork 取件(`game_fork`)复用同一套归档门禁,不另起一份解压实现。
|
||||
pub(crate) fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result<usize, String> {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
|
||||
.map_err(|error| format!("模板包不是有效 zip:{error}"))?;
|
||||
if archive.len() > TEMPLATE_ARCHIVE_MAX_FILES {
|
||||
|
||||
Reference in New Issue
Block a user