Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/template_library.rs
T
kdletters 19ed1776f1
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
收口 AGC 模板库客户端接入里程碑:补缓存回退与建项失败清理证据
- 客户端清单来源抽成可测判定:远端合法正文优先并标 network,远端失败回退本机缓存并标 cache,无缓存时暴露远端错误
- 远端已答话但正文非法(不是合法 UTF-8 或不符合 schema)与缓存自身损坏时一律失败关闭,不用缓存掩盖远端问题
- 补 5 项清单来源判定用例
- 大小 / 摘要不一致与越界归档用例补断言:拒绝后不留安装目录、不产生已下载判据
- 新增建项失败清理用例:复制阶段失败时删除刚创建的项目目录,不留半成品
- 主规范并入清单来源与安装判据口径,并写入 2026-09-21 复核证据
- 里程碑置为 accepted 并补 7 条验收项证据表,按工作流删除对应实施计划
- 验证:cargo test template_library 24 项与 --ignored 线上 3 项、前端 4 个模板用例文件 38 项、appSurface 首页模板 2 项、AGC typecheck、check:encoding、check:doc-index、cargo fmt --check、git diff --check
2026-09-21 14:40:15 +08:00

1782 lines
72 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.
//! AGC 游戏模板库:读取公共 OSS 模板清单、下载模板 zip、安装到本机并据此建项目。
//!
//! 边界:模板库只做「远端清单 → 本机安装 → 复制进新项目」这条路,不生成模板内容,
//! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀;
//! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。
use super::*;
use crate::platform_session::{
current_platform_session, validate_platform_session_identity,
with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot,
};
use serde::{Deserialize, Serialize};
const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1";
const TEMPLATE_LIBRARY_INDEX_KEY: &str = "templates/index.json";
const TEMPLATE_LIBRARY_OBJECT_PREFIX: &str = "templates/";
const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str =
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com";
const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL";
const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates";
const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed";
const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json";
const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json";
const TEMPLATE_LIBRARY_MAX_INDEX_BYTES: u64 = 4 * 1024 * 1024;
const TEMPLATE_ARCHIVE_MAX_BYTES: u64 = 512 * 1024 * 1024;
const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096;
const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
const TEMPLATE_ID_MAX_CHARS: usize = 64;
const TEMPLATE_VERSION_MAX_CHARS: usize = 32;
const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放";
async fn template_library_access_for_session(
session: &PlatformSessionSnapshot,
) -> Result<bool, String> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?;
let response = client
.get(format!(
"{}/api/runtime/frontend-config",
session.api_base_url.trim_end_matches('/')
))
.bearer_auth(&session.access_token)
.send()
.await
.map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?;
validate_platform_session_identity(&session.identity())?;
if !response.status().is_success() {
return Err(format!(
"template-library-unavailable: 检查模板库权限返回 HTTP {}",
response.status().as_u16()
));
}
const MAX_BYTES: usize = 64 * 1024;
if response
.content_length()
.is_some_and(|length| length > MAX_BYTES as u64)
{
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?;
if bytes.len() + chunk.len() > MAX_BYTES {
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
}
bytes.extend_from_slice(&chunk);
}
validate_platform_session_identity(&session.identity())?;
let payload: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?;
Ok(payload
.get("agcTemplateLibraryEnabled")
.and_then(|value| value.as_bool())
== Some(true))
}
async fn require_template_library_access() -> Result<PlatformSessionIdentity, String> {
let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?;
if !template_library_access_for_session(&session).await? {
return Err(TEMPLATE_ACCESS_ERROR.to_string());
}
Ok(session.identity())
}
#[tauri::command]
pub(crate) async fn get_game_template_library_access() -> Result<bool, String> {
let Some(session) = current_platform_session() else {
return Ok(false);
};
template_library_access_for_session(&session).await
}
/// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameTemplateSummary {
pub(crate) id: String,
pub(crate) title: String,
#[serde(default)]
pub(crate) summary: String,
#[serde(default)]
pub(crate) tags: Vec<String>,
#[serde(default)]
pub(crate) runtime: String,
#[serde(default)]
pub(crate) engine: String,
#[serde(default)]
pub(crate) engine_version: String,
pub(crate) template_version: String,
#[serde(default)]
pub(crate) updated_at: String,
#[serde(default)]
pub(crate) entry: String,
pub(crate) zip_key: String,
pub(crate) zip_size_bytes: u64,
pub(crate) zip_sha256: String,
pub(crate) cover_key: String,
#[serde(default)]
pub(crate) cover_width: u32,
#[serde(default)]
pub(crate) cover_height: u32,
#[serde(default)]
pub(crate) cover_sha256: String,
#[serde(default)]
pub(crate) metadata_key: String,
}
/// `templates/index.json` 的库头信息。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameTemplateLibraryHeader {
pub(crate) schema_version: String,
#[serde(default)]
pub(crate) library: String,
#[serde(default)]
pub(crate) library_version: u32,
#[serde(default)]
pub(crate) updated_at: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
struct GameTemplateLibraryIndex {
#[serde(flatten)]
header: GameTemplateLibraryHeader,
#[serde(default)]
templates: Vec<GameTemplateSummary>,
}
/// 返回给前端的模板条目:清单字段 + 远端地址 + 本机安装状态。
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameTemplateEntry {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) summary: String,
pub(crate) tags: Vec<String>,
pub(crate) runtime: String,
pub(crate) engine: String,
pub(crate) engine_version: String,
pub(crate) template_version: String,
pub(crate) updated_at: String,
pub(crate) entry: String,
pub(crate) zip_url: String,
pub(crate) zip_size_bytes: u64,
pub(crate) zip_sha256: String,
pub(crate) cover_url: String,
pub(crate) cover_width: u32,
pub(crate) cover_height: u32,
pub(crate) installed: bool,
pub(crate) installed_version: Option<String>,
pub(crate) installed_at_millis: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameTemplateLibrarySnapshot {
pub(crate) schema_version: String,
pub(crate) library: String,
pub(crate) library_version: u32,
pub(crate) updated_at: String,
pub(crate) fetched_at_millis: u64,
/// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。
pub(crate) source: String,
pub(crate) templates: Vec<GameTemplateEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct InstalledGameTemplateRecord {
template_id: String,
template_version: String,
installed_at_millis: u64,
zip_sha256: String,
file_count: usize,
project_dir: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InstalledGameTemplate {
pub(crate) template_id: String,
pub(crate) template_version: String,
pub(crate) installed_at_millis: u64,
pub(crate) zip_sha256: String,
pub(crate) file_count: usize,
pub(crate) project_dir: String,
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
fn template_library_base_url() -> String {
std::env::var(TEMPLATE_LIBRARY_BASE_URL_ENV)
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_TEMPLATE_LIBRARY_BASE_URL.to_string())
}
/// 只接受受信任 OSS 主机下的 HTTPS 地址;返回去掉尾斜杠的 base。
pub(crate) fn validated_template_library_base_url(raw: &str) -> Result<String, String> {
let trimmed = raw.trim().trim_end_matches('/');
let parsed = url::Url::parse(trimmed).map_err(|_| "模板库地址无效".to_string())?;
if parsed.scheme() != "https" || parsed.host_str() != Some(TEMPLATE_LIBRARY_OSS_HOST) {
return Err("模板库地址必须来自受信任的 OSS".to_string());
}
if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
return Err("模板库地址只能包含主机名".to_string());
}
Ok(trimmed.to_string())
}
fn template_library_base() -> Result<String, String> {
validated_template_library_base_url(&template_library_base_url())
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn is_valid_sha256(value: &str) -> bool {
let trimmed = value.trim();
trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn validate_template_identifier(value: &str, max_chars: usize, label: &str) -> Result<(), String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.chars().count() > max_chars {
return Err(format!("{label}无效"));
}
if !trimmed.chars().all(|value| {
value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.')
}) {
return Err(format!("{label}无效"));
}
if trimmed.contains("..") {
return Err(format!("{label}无效"));
}
Ok(())
}
/// 远端对象键必须落在 `templates/` 前缀下,且不含绝对路径、上跳或反斜杠。
pub(crate) fn validate_template_object_key(key: &str) -> Result<(), String> {
let trimmed = key.trim();
if trimmed.is_empty() || !trimmed.starts_with(TEMPLATE_LIBRARY_OBJECT_PREFIX) {
return Err("模板对象键必须位于 templates/ 前缀下".to_string());
}
if trimmed.starts_with('/') || trimmed.contains('\\') || trimmed.contains("..") {
return Err("模板对象键无效".to_string());
}
if trimmed
.chars()
.any(|value| value.is_control() || value == ' ' || value == '?')
{
return Err("模板对象键无效".to_string());
}
Ok(())
}
fn template_object_url(key: &str) -> Result<String, String> {
validate_template_object_key(key)?;
Ok(format!("{}/{}", template_library_base()?, key.trim()))
}
fn validate_template_summary(entry: &GameTemplateSummary) -> Result<(), String> {
validate_template_identifier(&entry.id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?;
validate_template_identifier(
&entry.template_version,
TEMPLATE_VERSION_MAX_CHARS,
"模板版本",
)?;
if entry.title.trim().is_empty() || entry.title.chars().count() > 120 {
return Err(format!("模板 {} 的标题无效", entry.id));
}
if entry.tags.len() > 32 {
return Err(format!("模板 {} 的标签过多", entry.id));
}
validate_template_object_key(&entry.zip_key)?;
validate_template_object_key(&entry.cover_key)?;
if !entry.metadata_key.trim().is_empty() {
validate_template_object_key(&entry.metadata_key)?;
}
if entry.zip_size_bytes == 0 || entry.zip_size_bytes > TEMPLATE_ARCHIVE_MAX_BYTES {
return Err(format!("模板 {} 的包大小无效", entry.id));
}
if !is_valid_sha256(&entry.zip_sha256) {
return Err(format!("模板 {} 的包摘要无效", entry.id));
}
if !entry.cover_sha256.trim().is_empty() && !is_valid_sha256(&entry.cover_sha256) {
return Err(format!("模板 {} 的封面摘要无效", entry.id));
}
Ok(())
}
/// 解析并校验远端清单;任何一条不合法都让整次读取失败,避免前端拿到半可信数据。
pub(crate) fn parse_game_template_library_index(
body: &str,
) -> Result<(GameTemplateLibraryHeader, Vec<GameTemplateSummary>), String> {
let index: GameTemplateLibraryIndex =
serde_json::from_str(body).map_err(|error| format!("模板库清单不是有效 JSON:{error}"))?;
if index.header.schema_version != TEMPLATE_LIBRARY_SCHEMA_VERSION {
return Err("模板库清单版本不受支持".to_string());
}
let mut seen = std::collections::BTreeSet::new();
for entry in &index.templates {
validate_template_summary(entry)?;
if !seen.insert(entry.id.clone()) {
return Err(format!("模板库清单存在重复模板:{}", entry.id));
}
}
Ok((index.header, index.templates))
}
fn template_cache_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME))
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
fn installed_templates_root(cache_root: &Path) -> PathBuf {
cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME)
}
fn installed_template_dir(
cache_root: &Path,
template_id: &str,
template_version: &str,
) -> Result<PathBuf, String> {
validate_template_identifier(template_id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?;
validate_template_identifier(template_version, TEMPLATE_VERSION_MAX_CHARS, "模板版本")?;
Ok(installed_templates_root(cache_root)
.join(template_id.trim())
.join(template_version.trim()))
}
fn read_installed_record(directory: &Path) -> Option<InstalledGameTemplateRecord> {
let body = fs::read_to_string(directory.join(TEMPLATE_INSTALLED_MARKER_FILE)).ok()?;
serde_json::from_str(&body).ok()
}
/// 扫描本机已安装模板,返回 `(模板 ID, 安装记录)`;损坏或缺少标记的目录视为未安装。
fn collect_installed_records(cache_root: &Path) -> Vec<InstalledGameTemplateRecord> {
let root = installed_templates_root(cache_root);
let Ok(template_entries) = fs::read_dir(&root) else {
return Vec::new();
};
let mut records = Vec::new();
for template_entry in template_entries.flatten() {
let Ok(version_entries) = fs::read_dir(template_entry.path()) else {
continue;
};
for version_entry in version_entries.flatten() {
if let Some(record) = read_installed_record(&version_entry.path()) {
records.push(record);
}
}
}
records
}
fn installed_record_for(
records: &[InstalledGameTemplateRecord],
template_id: &str,
) -> Option<InstalledGameTemplateRecord> {
records
.iter()
.filter(|record| record.template_id == template_id)
.max_by_key(|record| record.installed_at_millis)
.cloned()
}
fn to_entry(
summary: &GameTemplateSummary,
installed: Option<&InstalledGameTemplateRecord>,
) -> Result<GameTemplateEntry, String> {
Ok(GameTemplateEntry {
id: summary.id.clone(),
title: summary.title.clone(),
summary: summary.summary.clone(),
tags: summary.tags.clone(),
runtime: summary.runtime.clone(),
engine: summary.engine.clone(),
engine_version: summary.engine_version.clone(),
template_version: summary.template_version.clone(),
updated_at: summary.updated_at.clone(),
entry: summary.entry.clone(),
zip_url: template_object_url(&summary.zip_key)?,
zip_size_bytes: summary.zip_size_bytes,
zip_sha256: summary.zip_sha256.to_ascii_lowercase(),
cover_url: template_object_url(&summary.cover_key)?,
cover_width: summary.cover_width,
cover_height: summary.cover_height,
installed: installed.is_some(),
installed_version: installed.map(|record| record.template_version.clone()),
installed_at_millis: installed.map(|record| record.installed_at_millis),
})
}
/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。
///
/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`);
/// 未开启时是恒等透传,正式构建里没有任何注入分支。
fn apply_template_library_fixtures(entries: Vec<GameTemplateEntry>) -> Vec<GameTemplateEntry> {
#[cfg(feature = "template-library-fixtures")]
{
return fixtures::pad_synthetic_templates(entries, fixtures::synthetic_template_count());
}
#[cfg(not(feature = "template-library-fixtures"))]
{
entries
}
}
/// 本地假数据注入实现:只在 `template-library-fixtures` feature(或测试构建)下编译。
///
/// 位置刻意放在 Rust 侧:模板库的清单校验、安装状态与建项目都在这一侧,TS 只消费快照做渲染;
/// 在这里注入才能压到与真实一致的整条链路。正式构建不含该模块,因此不存在误触发路径。
#[cfg(any(test, feature = "template-library-fixtures"))]
pub(crate) mod fixtures {
use super::*;
pub(crate) const SYNTHETIC_COUNT_ENV: &str = "AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT";
pub(crate) const DEFAULT_SYNTHETIC_COUNT: usize = 1_000;
const MAX_SYNTHETIC_COUNT: usize = 20_000;
/// 假数据条数:环境变量优先,缺省 1000;0 表示不注入。
pub(crate) fn synthetic_template_count() -> usize {
parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref())
}
fn parse_synthetic_count(raw: Option<&str>) -> usize {
raw.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_SYNTHETIC_COUNT)
.min(MAX_SYNTHETIC_COUNT)
}
/// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。
pub(crate) fn pad_synthetic_templates(
entries: Vec<GameTemplateEntry>,
target: usize,
) -> Vec<GameTemplateEntry> {
if target <= entries.len() || entries.is_empty() {
return entries;
}
let base = entries.clone();
let mut padded = entries;
let mut index = padded.len();
while padded.len() < target {
let source = &base[index % base.len()];
let installed = index % 3 == 0;
let mut next = source.clone();
next.id = format!("{}-{:04}", source.id, index);
next.title = format!("{} · 假数据 {index:04}", source.title);
let mut tags = source.tags.clone();
tags.push(format!("批次-{:02}", index % 20));
next.tags = tags;
next.cover_url = format!("{}?synthetic={index}", source.cover_url);
next.installed = installed;
next.installed_version = installed.then(|| source.template_version.clone());
next.installed_at_millis = installed.then(|| now_millis());
padded.push(next);
index += 1;
}
padded
}
#[cfg(test)]
mod tests {
use super::*;
fn base_entry(id: &str, tag: &str) -> GameTemplateEntry {
GameTemplateEntry {
id: id.to_string(),
title: format!("模板 {id}"),
summary: "假数据基础条目".to_string(),
tags: vec![tag.to_string()],
runtime: "html".to_string(),
engine: "phaser".to_string(),
engine_version: "4.2.1".to_string(),
template_version: "0.1.0".to_string(),
updated_at: "2026-09-17T00:00:00Z".to_string(),
entry: "game/index.html".to_string(),
zip_url: format!("https://oss.example/templates/v1/{id}/template.zip"),
zip_size_bytes: 1024,
zip_sha256: "a".repeat(64),
cover_url: format!("https://oss.example/templates/v1/{id}/cover.svg"),
cover_width: 960,
cover_height: 540,
installed: false,
installed_version: None,
installed_at_millis: None,
}
}
#[test]
fn parses_synthetic_count_from_env_value() {
assert_eq!(parse_synthetic_count(None), DEFAULT_SYNTHETIC_COUNT);
assert_eq!(parse_synthetic_count(Some(" 250 ")), 250);
assert_eq!(parse_synthetic_count(Some("0")), 0);
assert_eq!(
parse_synthetic_count(Some("not-a-number")),
DEFAULT_SYNTHETIC_COUNT
);
assert_eq!(parse_synthetic_count(Some("999999")), MAX_SYNTHETIC_COUNT);
}
#[test]
fn pads_entries_with_unique_identity_and_mixed_install_state() {
let base = vec![
base_entry("blank-web", "空白"),
base_entry("blank-2d", "2d"),
];
let padded = pad_synthetic_templates(base.clone(), 9);
assert_eq!(padded.len(), 9);
// 真实条目保持原样排在最前。
assert_eq!(padded[0].id, "blank-web");
assert_eq!(padded[1].id, "blank-2d");
let ids = padded
.iter()
.map(|entry| entry.id.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(ids.len(), 9, "假数据 id 必须唯一");
let covers = padded
.iter()
.map(|entry| entry.cover_url.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(covers.len(), 9, "假数据封面地址必须唯一");
assert!(padded.iter().any(|entry| entry.installed));
assert!(padded.iter().any(|entry| !entry.installed));
assert!(padded
.iter()
.skip(2)
.all(|entry| entry.tags.iter().any(|tag| tag.starts_with("批次-"))));
}
#[test]
fn padding_is_a_no_op_without_room_to_fill() {
let base = vec![base_entry("blank-web", "空白")];
assert_eq!(pad_synthetic_templates(base.clone(), 1).len(), 1);
assert_eq!(pad_synthetic_templates(base.clone(), 0).len(), 1);
assert!(pad_synthetic_templates(Vec::new(), 10).is_empty());
}
}
}
fn build_template_library_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
async fn fetch_limited_bytes(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("请求模板库失败:{error}"))?;
if !response.status().is_success() {
return Err(format!("模板库返回 HTTP {}", response.status().as_u16()));
}
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);
}
Ok(body)
}
fn write_cached_index(cache_root: &Path, body: &str) {
let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录");
let _ = write_game_creator_private_file(
&cache_root.join(TEMPLATE_INDEX_CACHE_FILE),
body.as_bytes(),
"模板库清单缓存",
);
}
fn read_cached_index(cache_root: &Path) -> Option<String> {
fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok()
}
/// 单个 zip 条目路径:只允许相对普通路径,禁止绝对路径、上跳、反斜杠与盘符。
pub(crate) fn safe_archive_relative_path(raw: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("模板包条目名为空".to_string());
}
let normalized = trimmed.replace('\\', "/");
if normalized.starts_with('/') || normalized.contains(':') {
return Err(format!("模板包条目路径无效:{raw}"));
}
let mut path = PathBuf::new();
for segment in normalized.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return Err(format!("模板包条目路径无效:{raw}"));
}
path.push(segment);
}
if path.as_os_str().is_empty() {
return Err(format!("模板包条目路径无效:{raw}"));
}
Ok(path)
}
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 {
return Err("模板包文件数量超过上限".to_string());
}
let mut written = 0_usize;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取模板包条目失败:{error}"))?;
if entry
.unix_mode()
.is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err("模板包不允许包含符号链接".to_string());
}
let relative = safe_archive_relative_path(entry.name())?;
let target = destination.join(&relative);
if entry.is_dir() {
ensure_game_creator_private_directory_tree(&target, "模板目录")?;
continue;
}
if entry.size() > TEMPLATE_ARCHIVE_MAX_FILE_BYTES {
return Err(format!("模板包文件超过大小上限:{}", entry.name()));
}
let mut buffer = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut buffer)
.map_err(|error| format!("读取模板包文件失败:{error}"))?;
if let Some(parent) = target.parent() {
ensure_game_creator_private_directory_tree(parent, "模板目录")?;
}
write_game_creator_private_file(&target, &buffer, "模板文件")?;
written += 1;
}
if written == 0 {
return Err("模板包没有可写入的文件".to_string());
}
Ok(written)
}
fn install_template_archive(
cache_root: &Path,
summary: &GameTemplateSummary,
bytes: &[u8],
) -> Result<InstalledGameTemplateRecord, String> {
if bytes.len() as u64 != summary.zip_size_bytes {
return Err("模板包大小校验失败".to_string());
}
let actual_sha256 = sha256_hex(bytes);
if actual_sha256 != summary.zip_sha256.trim().to_ascii_lowercase() {
return Err("模板包完整性校验失败".to_string());
}
let directory = installed_template_dir(cache_root, &summary.id, &summary.template_version)?;
if directory.exists() {
// 只清理本模板自己的安装目录;路径由标识符白名单拼出,不含远端输入。
let _ = fs::remove_dir_all(&directory);
}
ensure_game_creator_private_directory_tree(&directory, "模板安装目录")?;
let file_count = extract_template_archive(bytes, &directory)?;
let record = InstalledGameTemplateRecord {
template_id: summary.id.clone(),
template_version: summary.template_version.clone(),
installed_at_millis: now_millis(),
zip_sha256: actual_sha256,
file_count,
project_dir: directory.to_string_lossy().into_owned(),
};
let body = serde_json::to_string_pretty(&record)
.map_err(|error| format!("写入模板安装记录失败:{error}"))?;
write_game_creator_private_file(
&directory.join(TEMPLATE_INSTALLED_MARKER_FILE),
body.as_bytes(),
"模板安装记录",
)?;
Ok(record)
}
fn copy_template_project(source_dir: &Path, target_root: &Path) -> Result<usize, String> {
let mut copied = 0_usize;
let mut stack = vec![source_dir.to_path_buf()];
while let Some(directory) = stack.pop() {
let entries =
fs::read_dir(&directory).map_err(|error| format!("读取模板目录失败:{error}"))?;
for entry in entries.flatten() {
let path = entry.path();
if entry.file_name() == TEMPLATE_INSTALLED_MARKER_FILE {
continue;
}
let metadata = entry
.metadata()
.map_err(|error| format!("读取模板条目失败:{error}"))?;
if metadata.is_dir() {
stack.push(path);
continue;
}
if !metadata.is_file() {
return Err(format!("模板包含不支持的条目:{}", path.display()));
}
let relative = path
.strip_prefix(source_dir)
.map_err(|error| format!("模板条目路径无效:{error}"))?;
let target = target_root.join(relative);
if let Some(parent) = target.parent() {
ensure_game_creator_private_directory_tree(parent, "项目模板目录")?;
}
let bytes = fs::read(&path).map_err(|error| format!("读取模板文件失败:{error}"))?;
write_game_creator_private_file(&target, &bytes, "项目模板文件")?;
copied += 1;
}
}
if copied == 0 {
return Err("模板没有可复制的文件".to_string());
}
Ok(copied)
}
fn find_template_summary(
cache_root: &Path,
template_id: &str,
template_version: &str,
) -> Result<GameTemplateSummary, String> {
let body = read_cached_index(cache_root).ok_or_else(|| "本机没有模板库清单缓存".to_string())?;
let (_, templates) = parse_game_template_library_index(&body)?;
templates
.into_iter()
.find(|entry| entry.id == template_id && entry.template_version == template_version)
.ok_or_else(|| "模板库清单里没有该模板版本".to_string())
}
async fn ensure_template_installed(
cache_root: &Path,
template_id: &str,
template_version: &str,
identity: &PlatformSessionIdentity,
) -> Result<InstalledGameTemplateRecord, String> {
validate_platform_session_identity(identity)?;
let installed_directory = installed_template_dir(cache_root, template_id, template_version)?;
if let Some(record) = read_installed_record(&installed_directory) {
return Ok(record);
}
let summary = find_template_summary(cache_root, template_id, template_version)?;
let client = build_template_library_client();
let url = template_object_url(&summary.zip_key)?;
let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?;
with_validated_platform_session_identity(identity, || {
install_template_archive(cache_root, &summary, &bytes)
})
}
/// 清单来源:远端读取或本机缓存兜底。快照里的 `source` 原样回传,前端据此提示「本机缓存」。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TemplateIndexSource {
Network,
Cache,
}
impl TemplateIndexSource {
fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Cache => "cache",
}
}
}
/// 远端清单不可用时回退本机缓存。
///
/// 两条路径都要过同一份 schema 校验:远端已经答话但正文非法时直接失败关闭,
/// 不用缓存掩盖;缓存自己损坏时也不能被当成可用清单。
fn resolve_template_index(
remote: Result<String, String>,
cached: Option<String>,
) -> Result<(String, TemplateIndexSource), String> {
match remote {
Ok(body) => {
parse_game_template_library_index(&body)?;
Ok((body, TemplateIndexSource::Network))
}
Err(error) => match cached {
Some(cached) => {
parse_game_template_library_index(&cached)?;
Ok((cached, TemplateIndexSource::Cache))
}
None => Err(error),
},
}
}
#[tauri::command]
pub(crate) async fn fetch_game_template_library(
app: tauri::AppHandle,
) -> Result<GameTemplateLibrarySnapshot, String> {
let identity = require_template_library_access().await?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let index_url = format!(
"{}/{}",
template_library_base()?,
TEMPLATE_LIBRARY_INDEX_KEY
);
let client = build_template_library_client();
let remote =
match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await {
// 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。
Ok(bytes) => {
Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?)
}
Err(error) => Err(error),
};
let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?;
if source == TemplateIndexSource::Network {
with_validated_platform_session_identity(&identity, || {
write_cached_index(&cache_root, &body);
Ok(())
})?;
}
validate_platform_session_identity(&identity)?;
let (header, templates) = parse_game_template_library_index(&body)?;
let installed = collect_installed_records(&cache_root);
let entries = templates
.iter()
.map(|summary| {
to_entry(
summary,
installed_record_for(&installed, &summary.id).as_ref(),
)
})
.collect::<Result<Vec<_>, _>>()?;
let entries = apply_template_library_fixtures(entries);
Ok(GameTemplateLibrarySnapshot {
schema_version: header.schema_version,
library: header.library,
library_version: header.library_version,
updated_at: header.updated_at,
fetched_at_millis: now_millis(),
source: source.as_str().to_string(),
templates: entries,
})
}
#[tauri::command]
pub(crate) async fn download_game_template(
app: tauri::AppHandle,
template_id: String,
template_version: String,
) -> Result<InstalledGameTemplate, String> {
let identity = require_template_library_access().await?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let record = ensure_template_installed(
&cache_root,
template_id.trim(),
template_version.trim(),
&identity,
)
.await?;
validate_platform_session_identity(&identity)?;
Ok(InstalledGameTemplate {
template_id: record.template_id,
template_version: record.template_version,
installed_at_millis: record.installed_at_millis,
zip_sha256: record.zip_sha256,
file_count: record.file_count,
project_dir: record.project_dir,
})
}
/// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。
pub(crate) fn create_project_from_installed_template_at(
projects_root: &Path,
installed_project_dir: &Path,
requested_name: Option<&str>,
planning: bool,
) -> Result<InitLocalProjectResult, String> {
let requested_name = requested_name
.map(normalize_game_creation_project_name)
.transpose()?;
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
return Err("自动工作区根目录必须是绝对路径".to_string());
}
if !installed_project_dir.is_dir() {
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 {
"GameAgent 项目"
};
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")?;
copy_template_project(installed_project_dir, &project_root)?;
if discover_local_cocos_project_root(&project_root)?.is_some() {
let package_path = project_root.join("package.json");
let mut package: serde_json::Value = serde_json::from_slice(
&fs::read(&package_path)
.map_err(|error| format!("读取 Cocos 项目配置失败:{error}"))?,
)
.map_err(|error| format!("解析 Cocos 项目配置失败:{error}"))?;
package["name"] = serde_json::json!(project_name);
package["uuid"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
let bytes = serde_json::to_vec_pretty(&package)
.map_err(|error| format!("序列化 Cocos 项目配置失败:{error}"))?;
write_game_creator_private_file(&package_path, &bytes, "Cocos 项目配置")?;
return import_local_cocos_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
);
}
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
)
})();
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())
}
#[tauri::command]
pub(crate) async fn create_automatic_local_game_project_from_template(
app: tauri::AppHandle,
template_id: String,
template_version: String,
name: Option<String>,
planning: Option<bool>,
projects_root: Option<String>,
) -> Result<InitLocalProjectResult, String> {
let identity = require_template_library_access().await?;
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let record = ensure_template_installed(
&cache_root,
template_id.trim(),
template_version.trim(),
&identity,
)
.await?;
with_validated_platform_session_identity(&identity, || {
create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
name.as_deref(),
planning.unwrap_or(false),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn access_server(
status: u16,
body: &str,
change_identity: bool,
) -> (String, std::thread::JoinHandle<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let body = body.to_string();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let size = socket.read(&mut buffer).unwrap();
assert!(size > 0);
request.extend_from_slice(&buffer[..size]);
}
if change_identity {
crate::platform_session::clear_platform_session(2, 2);
}
write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
String::from_utf8(request).unwrap()
});
(url, server)
}
#[tokio::test]
async fn template_access_requires_current_account_and_explicit_server_grant() {
for (status, body, allowed) in [
(200, r#"{"agcTemplateLibraryEnabled":true}"#, true),
(200, r#"{"agcTemplateLibraryEnabled":false}"#, false),
(200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false),
(200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false),
(503, r#"{"agcTemplateLibraryEnabled":true}"#, false),
(200, "invalid JSON", false),
] {
let (origin, server) = access_server(status, body, false);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"template-test-token",
&origin,
);
assert_eq!(require_template_library_access().await.is_ok(), allowed);
let request = server.join().unwrap().to_lowercase();
assert!(request.starts_with("get /api/runtime/frontend-config "));
assert!(request.contains("authorization: bearer template-test-token"));
}
let _session = crate::platform_session::clear_test_platform_session();
assert!(!get_game_template_library_access().await.unwrap());
assert!(require_template_library_access().await.is_err());
}
#[tokio::test]
async fn template_access_preserves_identity_during_token_rotation() {
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"old-token",
&origin,
);
let frozen = current_platform_session().unwrap();
crate::platform_session::install_platform_session(
"template-user",
"new-token",
&origin,
1,
2,
)
.unwrap();
assert!(template_library_access_for_session(&frozen).await.unwrap());
server.join().unwrap();
}
#[tokio::test]
async fn template_access_rejects_old_account_response_and_cached_install() {
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"template-test-token",
&origin,
);
let identity = current_platform_session().unwrap().identity();
let error = require_template_library_access().await.unwrap_err();
assert!(error.contains("authentication-required"));
server.join().unwrap();
let error = ensure_template_installed(
Path::new("unused-cache"),
"demo-template",
"0.1.0",
&identity,
)
.await
.unwrap_err();
assert!(error.contains("authentication-required"));
assert!(
with_validated_platform_session_identity::<()>(&identity, || panic!(
"旧会话不得写入项目"
))
.is_err()
);
}
fn sample_index_body() -> String {
serde_json::json!({
"schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION,
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T00:00:00Z",
"templates": [
{
"id": "demo-template",
"title": "演示模板",
"summary": "用于测试",
"tags": ["2d", "demo"],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "1.0.0",
"updatedAt": "2026-09-17T00:00:00Z",
"entry": "game/index.html",
"zipKey": "templates/v1/demo-template/template.zip",
"zipSizeBytes": 128,
"zipSha256": "0".repeat(64),
"coverKey": "templates/v1/demo-template/cover.png",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "1".repeat(64),
"metadataKey": "templates/v1/demo-template/template.json"
}
]
})
.to_string()
}
fn sample_summary() -> GameTemplateSummary {
parse_game_template_library_index(&sample_index_body())
.expect("parse sample index")
.1
.remove(0)
}
fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
for (name, bytes) in entries {
writer.start_file(*name, options).expect("start file");
writer.write_all(bytes).expect("write file");
}
writer.finish().expect("finish archive").into_inner()
}
/// 自动工作区根目录要走私有 DACL 校验,和 `tests::unique_project_path` 一样
/// 在临时目录下取一个尚未存在的唯一路径,而不是用 tempdir 预先建好的目录。
fn unique_projects_root() -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_millis();
std::env::temp_dir().join(format!(
"genarrative-agc-template-library-test-{}-{millis}",
std::process::id()
))
}
#[test]
fn template_index_prefers_the_network_body_and_labels_its_source() {
let body = sample_index_body();
let (resolved, source) =
resolve_template_index(Ok(body.clone()), Some("{not json".to_string()))
.expect("合法远端正文优先于缓存");
assert_eq!(source.as_str(), "network");
assert_eq!(resolved, body);
}
#[test]
fn template_index_falls_back_to_the_cache_body_when_the_remote_fetch_fails() {
let cached = sample_index_body();
let (resolved, source) =
resolve_template_index(Err("网络不可用".to_string()), Some(cached.clone()))
.expect("远端不可用时回退本机缓存");
assert_eq!(source.as_str(), "cache");
assert_eq!(resolved, cached);
}
#[test]
fn template_index_keeps_the_remote_error_when_no_cache_exists() {
let error = resolve_template_index(Err("远端超时".to_string()), None)
.expect_err("没有缓存时必须暴露远端错误");
assert_eq!(error, "远端超时");
}
#[test]
fn template_index_rejects_an_invalid_remote_body_without_masking_it_with_cache() {
let cached = sample_index_body();
let invalid = cached.replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2");
let error = resolve_template_index(Ok(invalid), Some(cached))
.expect_err("远端已答话但正文非法时不得用缓存掩盖");
assert!(error.contains("版本不受支持"), "{error}");
}
#[test]
fn template_index_rejects_a_corrupt_cache_instead_of_serving_it() {
let error =
resolve_template_index(Err("网络不可用".to_string()), Some("{not json".to_string()))
.expect_err("损坏的缓存必须失败关闭");
assert!(error.contains("不是有效 JSON"), "{error}");
}
#[test]
fn rejects_index_with_unsupported_schema_or_duplicate_templates() {
let unsupported =
sample_index_body().replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2");
assert!(parse_game_template_library_index(&unsupported).is_err());
let entry = serde_json::to_string(&sample_summary()).expect("serialize summary");
let duplicated = serde_json::json!({
"schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION,
"templates": [
serde_json::from_str::<serde_json::Value>(&entry).expect("value"),
serde_json::from_str::<serde_json::Value>(&entry).expect("value"),
]
})
.to_string();
let error = parse_game_template_library_index(&duplicated).expect_err("duplicate rejected");
assert!(error.contains("重复模板"), "{error}");
}
#[test]
fn parses_content_addressed_objects_without_changing_the_template_contract() {
let mut index: serde_json::Value =
serde_json::from_str(&sample_index_body()).expect("sample index");
let keys = [
("zipKey", "template.zip", "a"),
("coverKey", "cover.png", "b"),
("metadataKey", "template.json", "c"),
]
.map(|(field, file, hash)| {
(
field,
format!(
"templates/v1/demo-template/sha256/{}/{file}",
hash.repeat(64)
),
)
});
for (field, key) in &keys {
index["templates"][0][field] = serde_json::json!(key);
}
let (_, templates) =
parse_game_template_library_index(&index.to_string()).expect("content addressed index");
let parsed = serde_json::to_value(&templates[0]).expect("serialize template");
for (field, key) in &keys {
assert_eq!(parsed[field], *key);
assert!(template_object_url(key)
.expect("trusted URL")
.ends_with(key));
}
assert_eq!(templates[0].template_version, "1.0.0");
}
#[test]
fn rejects_object_keys_outside_the_templates_prefix() {
assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err());
assert!(validate_template_object_key("templates/../secret").is_err());
assert!(validate_template_object_key("/templates/a.zip").is_err());
assert!(validate_template_object_key("templates\\a.zip").is_err());
assert!(validate_template_object_key("templates/v1/demo/template.zip").is_ok());
}
#[test]
fn rejects_template_base_url_outside_trusted_oss() {
assert!(validated_template_library_base_url(
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com"
)
.is_ok());
assert!(validated_template_library_base_url(
"http://agc-dev.oss-rg-china-mainland.aliyuncs.com"
)
.is_err());
assert!(validated_template_library_base_url("https://evil.example.com").is_err());
assert!(validated_template_library_base_url(
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com/other-prefix"
)
.is_err());
}
/// 固定 2026-09-17 实际发布的 `templates/index.json`:客户端解析必须与线上契约一致。
#[test]
fn parses_the_published_library_index_fixture() {
let body = include_str!("../tests/fixtures/agc-template-library-index.json");
let (header, templates) =
parse_game_template_library_index(body).expect("parse published index");
assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION);
assert_eq!(header.library, "agc-game-templates");
assert_eq!(
templates
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec![
"blank-2d-canvas",
"blank-3d-scene",
"blank-web",
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
"phaser-2d-starter",
"threejs-3d-starter",
]
);
let first = templates
.iter()
.find(|entry| entry.id == "phaser-2d-starter")
.expect("phaser template");
assert_eq!(first.runtime, "html");
assert_eq!(first.tags.first().map(String::as_str), Some("起步工程"));
assert!(first.cover_width > 0 && first.cover_height > 0);
let entry = to_entry(first, None).expect("build entry");
let base = template_library_base().expect("trusted base");
assert_eq!(
entry.zip_url,
format!("{base}/templates/v1/phaser-2d-starter/template.zip")
);
assert_eq!(
entry.cover_url,
format!("{base}/templates/v1/phaser-2d-starter/cover.svg")
);
assert!(!entry.installed);
}
#[test]
fn rejects_archive_entries_that_escape_the_destination() {
assert!(safe_archive_relative_path("game/index.html").is_ok());
assert!(safe_archive_relative_path("./game/index.html").is_ok());
assert!(safe_archive_relative_path("../escape.txt").is_err());
assert!(safe_archive_relative_path("game/../../escape.txt").is_err());
assert!(safe_archive_relative_path("C:/escape.txt").is_err());
assert!(safe_archive_relative_path("/escape.txt").is_err());
let archive = build_archive(&[("../escape.txt", b"nope")]);
let destination = tempfile::tempdir().expect("temp dir");
assert!(extract_template_archive(&archive, destination.path()).is_err());
assert!(!destination
.path()
.parent()
.unwrap()
.join("escape.txt")
.exists());
// 越界归档失败后不能留下安装记录:目录残留不算「已下载」。
assert!(collect_installed_records(destination.path()).is_empty());
}
#[test]
fn installs_template_archive_and_reports_it_as_installed() {
let cache_root = tempfile::tempdir().expect("temp dir");
let archive = build_archive(&[
("game/index.html", b"<html></html>"),
("game/game.js", b"console.log('demo');"),
]);
let mut summary = sample_summary();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
let record = install_template_archive(cache_root.path(), &summary, &archive)
.expect("install template");
assert_eq!(record.file_count, 2);
let installed = collect_installed_records(cache_root.path());
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].template_version, "1.0.0");
let entry = to_entry(
&summary,
installed_record_for(&installed, &summary.id).as_ref(),
)
.expect("build entry");
assert!(entry.installed);
assert_eq!(entry.installed_version.as_deref(), Some("1.0.0"));
assert!(entry.zip_url.starts_with("https://"));
assert!(entry
.cover_url
.ends_with("/templates/v1/demo-template/cover.png"));
}
#[test]
fn rejects_archive_when_size_or_digest_do_not_match_the_index() {
let cache_root = tempfile::tempdir().expect("temp dir");
let archive = build_archive(&[("game/index.html", b"<html></html>")]);
let mut summary = sample_summary();
summary.zip_size_bytes = archive.len() as u64 + 1;
let error = install_template_archive(cache_root.path(), &summary, &archive)
.expect_err("size mismatch rejected");
assert!(error.contains("大小校验失败"), "{error}");
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = "f".repeat(64);
let error = install_template_archive(cache_root.path(), &summary, &archive)
.expect_err("digest mismatch rejected");
assert!(error.contains("完整性校验失败"), "{error}");
// 大小或摘要不一致必须在任何落盘之前失败:不留安装目录,也不产生「已下载」判据。
let directory =
installed_template_dir(cache_root.path(), &summary.id, &summary.template_version)
.expect("install dir");
assert!(!directory.exists(), "拒绝的模板不得留下安装目录");
assert!(collect_installed_records(cache_root.path()).is_empty());
}
#[test]
fn creates_project_from_installed_template_without_leaking_install_marker() {
let cache_root = tempfile::tempdir().expect("temp dir");
let projects_root = unique_projects_root();
let archive = build_archive(&[
("game/index.html", b"<html><body>template</body></html>"),
("assets/README.txt", b"template assets"),
]);
let mut summary = sample_summary();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
let record = install_template_archive(cache_root.path(), &summary, &archive)
.expect("install template");
let result = create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
Some("模板项目"),
false,
)
.expect("create project from template");
assert_eq!(result.manifest.name, "模板项目");
let project_root = Path::new(&result.project_path);
let index =
fs::read_to_string(project_root.join("game/index.html")).expect("template file copied");
assert!(index.contains("template"));
assert!(project_root.join("assets/README.txt").is_file());
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
assert!(!project_root.join("game/index.html.orig").exists());
fs::remove_dir_all(&projects_root).ok();
}
#[test]
fn refuses_to_create_project_when_template_is_not_installed() {
let projects_root = tempfile::tempdir().expect("temp dir");
let missing = projects_root.path().join("missing-template");
let error =
create_project_from_installed_template_at(projects_root.path(), &missing, None, false)
.expect_err("missing template rejected");
assert!(error.contains("模板尚未安装"), "{error}");
}
#[test]
fn failed_project_creation_removes_the_partial_project_directory() {
let cache_root = tempfile::tempdir().expect("temp dir");
let projects_root = unique_projects_root();
// 安装目录里只有安装记录、没有任何可复制文件:复制阶段必须失败关闭,
// 且已经创建的项目目录要被清掉,不能把半成品留在自动工作区里。
let directory = installed_template_dir(cache_root.path(), "empty-template", "1.0.0")
.expect("install dir");
ensure_game_creator_private_directory_tree(&directory, "模板安装目录")
.expect("create install dir");
write_game_creator_private_file(
&directory.join(TEMPLATE_INSTALLED_MARKER_FILE),
b"{\"templateId\":\"empty-template\"}",
"模板安装记录",
)
.expect("write install marker");
let error =
create_project_from_installed_template_at(&projects_root, &directory, None, false)
.expect_err("模板没有可复制文件时必须失败关闭");
assert!(error.contains("没有可复制的文件"), "{error}");
let leftovers = fs::read_dir(&projects_root)
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.collect::<Vec<_>>()
})
.unwrap_or_default();
assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}");
}
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(directory).expect("read template directory") {
let path = entry.expect("template entry").path();
if path.is_dir() {
collect(root, &path, files);
} else {
files.push((
path.strip_prefix(root)
.expect("relative path")
.to_string_lossy()
.replace('\\', "/"),
fs::read(&path).expect("read template file"),
));
}
}
}
let mut files = Vec::new();
collect(root, root, &mut files);
files.sort_by(|left, right| left.0.cmp(&right.0));
files
}
fn assert_cocos_template_creates_independent_projects(
summary: &GameTemplateSummary,
archive: &[u8],
) {
let cache_root = tempfile::tempdir().expect("cache directory");
let projects_root = unique_projects_root();
let record = install_template_archive(cache_root.path(), summary, archive)
.expect("install Cocos template");
let installed_root = Path::new(&record.project_dir);
let installed_files = template_source_files(installed_root);
let original_package: serde_json::Value = serde_json::from_slice(
&fs::read(installed_root.join("package.json")).expect("installed package"),
)
.expect("parse installed package");
let mut project_uuids = std::collections::HashSet::new();
for name in ["Cocos 模板项目一", "Cocos 模板项目二"] {
let created = create_project_from_installed_template_at(
&projects_root,
installed_root,
Some(name),
false,
)
.expect("create Cocos project from template");
let root = Path::new(&created.project_path);
assert_eq!(created.manifest.name, name);
assert_eq!(created.manifest.cocos_project_root.as_deref(), Some("."));
assert!(created.manifest.godot_project_root.is_none());
assert!(root.join(".agent/manifest.json").is_file());
assert!(root.join(".agent/agent.db").is_file());
for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] {
assert!(!root.join(unexpected).exists(), "unexpected {unexpected}");
}
let package: serde_json::Value = serde_json::from_slice(
&fs::read(root.join("package.json")).expect("created package"),
)
.expect("parse created package");
assert_eq!(package["name"], name);
assert_eq!(package["creator"]["version"], "3.8.8");
let uuid = package["uuid"].as_str().expect("project uuid");
uuid::Uuid::parse_str(uuid).expect("valid project uuid");
assert_ne!(package["uuid"], original_package["uuid"]);
assert!(project_uuids.insert(uuid.to_string()));
for (relative, bytes) in &installed_files {
if relative != "package.json" && relative != TEMPLATE_INSTALLED_MARKER_FILE {
assert_eq!(&fs::read(root.join(relative)).expect("copied file"), bytes);
}
}
}
assert_eq!(template_source_files(installed_root), installed_files);
fs::remove_dir_all(&projects_root).expect("remove test projects");
}
#[test]
fn installs_official_cocos_templates_and_creates_native_projects() {
let library = Path::new(env!("CARGO_MANIFEST_DIR")).join("../template-library/v1");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let source = library.join(id).join("project");
let source_files = template_source_files(&source);
let entries = source_files
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect::<Vec<_>>();
let archive = build_archive(&entries);
let mut summary = sample_summary();
summary.id = id.to_string();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
assert_cocos_template_creates_independent_projects(&summary, &archive);
assert_eq!(template_source_files(&source), source_files);
}
}
#[tokio::test]
#[ignore = "需要网络:下载线上 Cocos 模板并验证原生建项"]
async fn downloads_and_creates_live_cocos_templates() {
let base = template_library_base().expect("trusted base");
let client = build_template_library_client();
let body = fetch_limited_bytes(
&client,
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
)
.await
.expect("fetch live index");
let (_, templates) =
parse_game_template_library_index(&String::from_utf8(body).expect("utf-8 index"))
.expect("parse live index");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let summary = templates.iter().find(|entry| entry.id == id).expect(id);
assert_eq!(summary.runtime, "cocos");
assert_eq!(summary.engine_version, "3.8.8");
assert_eq!(summary.entry, "package.json");
let bytes = fetch_limited_bytes(
&client,
&template_object_url(&summary.zip_key).expect("trusted zip url"),
TEMPLATE_ARCHIVE_MAX_BYTES,
)
.await
.expect("download Cocos template");
assert_cocos_template_creates_independent_projects(summary, &bytes);
}
}
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
#[cfg(not(feature = "template-library-fixtures"))]
#[test]
fn fixtures_are_inert_without_the_feature() {
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
let (_, templates) =
parse_game_template_library_index(index_body).expect("parse fixture index");
let entries = templates
.iter()
.map(|summary| to_entry(summary, None).expect("entry"))
.collect::<Vec<_>>();
let original = entries.len();
let applied = apply_template_library_fixtures(entries);
assert_eq!(applied.len(), original, "默认构建不应注入假数据");
}
/// 开启 feature 后同一次调用必须补齐到配置条数。
#[cfg(feature = "template-library-fixtures")]
#[test]
fn fixtures_expand_entries_when_the_feature_is_enabled() {
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
let (_, templates) =
parse_game_template_library_index(index_body).expect("parse fixture index");
let entries = templates
.iter()
.map(|summary| to_entry(summary, None).expect("entry"))
.collect::<Vec<_>>();
let applied = apply_template_library_fixtures(entries);
assert_eq!(applied.len(), fixtures::synthetic_template_count());
assert!(applied.len() > templates.len());
}
/// 可选的真连检查:`cargo test --bin genarrative-ai-game-creator-shell template_library -- --ignored`。
/// 默认跳过,避免离线环境因网络失败误报。
#[tokio::test]
#[ignore = "需要网络:校验客户端能真实读到线上模板库清单与对象键"]
async fn fetches_the_live_template_library_index() {
let base = template_library_base().expect("trusted base");
let client = build_template_library_client();
let body = fetch_limited_bytes(
&client,
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
)
.await
.expect("fetch live index");
let body = String::from_utf8(body).expect("utf-8 index");
let (header, templates) =
parse_game_template_library_index(&body).expect("parse live index");
assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION);
assert!(!templates.is_empty(), "线上模板库应当至少有一个模板");
for entry in &templates {
let zip_url = template_object_url(&entry.zip_key).expect("trusted zip url");
assert!(zip_url.starts_with(&format!("{base}/")));
}
}
/// 可选的真连检查:下载线上模板包并安装到临时目录,证明"清单 → 下载 → 摘要校验 → 解压"整条链路可用。
#[tokio::test]
#[ignore = "需要网络:下载并安装线上模板包"]
async fn downloads_and_installs_a_live_template() {
let cache_root = tempfile::tempdir().expect("temp dir");
let base = template_library_base().expect("trusted base");
let client = build_template_library_client();
let body = fetch_limited_bytes(
&client,
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
)
.await
.expect("fetch live index");
let body = String::from_utf8(body).expect("utf-8 index");
let (_, templates) = parse_game_template_library_index(&body).expect("parse live index");
let entry = templates
.iter()
.find(|template| template.id == "blank-web")
.expect("线上应存在 blank-web 模板");
let bytes = fetch_limited_bytes(
&client,
&template_object_url(&entry.zip_key).expect("trusted zip url"),
TEMPLATE_ARCHIVE_MAX_BYTES,
)
.await
.expect("download live template");
let record = install_template_archive(cache_root.path(), entry, &bytes)
.expect("install live template");
assert!(record.file_count >= 5, "模板文件数 {}", record.file_count);
let project_root = Path::new(&record.project_dir);
assert!(project_root.join("game/index.html").is_file());
assert!(project_root.join("game/main.js").is_file());
assert!(project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).is_file());
// 同一条链路继续建项目:线上模板 → 本机安装 → 新项目目录。
let projects_root = unique_projects_root();
let created = create_project_from_installed_template_at(
&projects_root,
project_root,
Some("线上模板项目"),
false,
)
.expect("create project from live template");
assert_eq!(created.manifest.name, "线上模板项目");
let created_root = Path::new(&created.project_path);
assert!(created_root.join("game/index.html").is_file());
assert!(created_root.join("game/main.js").is_file());
assert!(created_root.join(".agent/manifest.json").is_file());
fs::remove_dir_all(&projects_root).ok();
}
}