模板正文目录门禁:CLI 打包与后台上传统一拒绝身份/版本库/依赖/构建目录
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (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 crates (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled

- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode
- scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行
- server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories
- docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限
- docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致)
- docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
This commit is contained in:
kdletters
2026-09-21 16:25:35 +08:00
parent b5e1c0f1d6
commit c5afc02e94
7 changed files with 209 additions and 7 deletions
@@ -16,7 +16,7 @@
- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB`id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单;`runtime` 仅接受 `html / unity / godot / cocos`。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB(图片与元数据仍是 5 MiB)。
- 语义:一批**全有或全无**——任一模板校验失败都在写入前整批拒绝并逐项给出原因;通过后在同一把发布锁内写入全部内容寻址对象、逐个回读校验,最后提交一次清单。
- 新 ID 默认上架;已存在 ID 的导入就地更新该条目(保留 `enabled` 状态、所属分组与未知扩展字段,包含下架条目);同一 ID 同一 `templateVersion` 但 ZIP 字节不同时拒绝,提示递增 `templateVersion`
- ZIP 由服务端校验后按原字节发布(不重新打包、不解压落盘):合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、至少一个文件,条目数与解压后体积受上限保护。
- ZIP 由服务端校验后按原字节发布(不重新打包、不解压落盘):合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、至少一个文件,条目数与解压后体积受上限保护;正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)或根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的包直接拒绝,与 CLI 打包同一份段名单
- 封面**每个模板必填**(与 CLI 源布局 `v1/<id>/cover.*` 一致),仅接受真实 PNG / JPEG / WebP,上限与编辑路径相同(5 MiB、单边 4096、1600 万像素);上传按字节嗅探格式,不信任 multipart 声明的 content-type。SVG 仍只可能来自 CLI 历史发布,编辑与上传都不产生新的 SVG 封面。
- 后台「模板管理」页新增上传入口:可多选 ZIP、逐行编辑元数据(含可选封面)、批量提交、逐行显示校验错误;沿用现有写入确认、防重复提交与刷新语义。
@@ -57,7 +57,7 @@
| 验收项 | 证据 |
| --- | --- |
| 1 权限与路由 | 路由契约测试覆盖 `POST /admin/api/agc-templates/import`;页签权限矩阵用例覆盖 GET / PUT / POST 三条路径(`admin::tests::agc_template_routes_require_the_template_tab_permission` |
| 2 校验失败关闭(写入前整批拒绝) | `template_import_archive_validation_fails_closed`(缺 entry / 越界路径 / 符号链接)、`template_import_plan_rejects_unknown_and_unreferenced_fields`(未引用字段、缺 ZIP 字段、字段前缀、未知 manifest 字段、非法 zip |
| 2 校验失败关闭(写入前整批拒绝) | `template_import_archive_validation_fails_closed`(缺 entry / 越界路径 / 符号链接)、`template_import_plan_rejects_unknown_and_unreferenced_fields`(未引用字段、缺 ZIP 字段、字段前缀、未知 manifest 字段、非法 zip`template_import_archive_rejects_identity_and_build_directories`(13 条身份 / 版本库 / 依赖 / 构建目录逐条拒绝 + 正文内 `game/dist/**``game/.gitignore` 放行) |
| 3 批量原子性 | `module-assets``template_import_appends_new_entries_with_metadata`3 条口径:新 ID 入 `templates`、下架条目留在 `inactiveTemplates`、未知字段保留);发布编排在锁内写完全部对象后才提交一次清单 |
| 4 版本一致性 | `template_import_rejects_same_version_with_different_bytes`(同句文案)、`template_import_reuses_identical_bytes_for_the_same_version``reused` |
| 5 CAS 与锁 | `import_templates` 复用 `check_revision`409)与 `TemplatePublishSession``commit_index` 不确定即留锁 → 503);存储层既有用例覆盖锁与不确定写入 |
@@ -67,3 +67,7 @@
| 9 检查 | `cargo test`module-assets 11、platform-oss 12、api-server 定向 4)、`npm run admin-web:typecheck``check:encoding``check:doc-index`193 份)、`cargo fmt --check``git diff --check` 全部通过 |
仍未执行:真实 `agc-dev` bucket 的批量写入 smoke(需要用户显式确认;建议用两个 `smoke-import-<时间戳>` 模板验证一批多模板 + 一次清单提交,验证后下架保留历史对象)。
## 追加:模板正文目录门禁(2026-09-21)
验收标准 2 的补充落地——把「不要放进 ZIP」从文档约定升级为机器门禁,CLI 与后台上传同一份段名单(任意层级 `.agent` / `.git` / `.svn` / `node_modules`,根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`)。证据:Rust `admin_templates` 8 项通过(含新增 `template_import_archive_rejects_identity_and_build_directories`);Node 发布回归 28 项通过(含新增 CLI 门禁用例);仓库 9 个模板源无一条命中,本地重打包 ZIP 摘要与线上清单 `zipSha256` 逐条一致。规则说明见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`
@@ -1,5 +1,13 @@
# 决策记录
## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单
- 背景:模板包组织指南把 `.agent/``.git/``node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。
- 决策:升级成机器门禁,CLI`readProjectFiles`)与后台上传(`validate_import_archive`)用同一份段名单与同一句文案——任意层级拒绝 `.agent` / `.git` / `.svn` / `node_modules`,根目录拒绝 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`。同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容;`.gitignore` 不等于 `.git`Cocos 模板的 `.creator` / `.gitignore` 不受影响。
- 原因:两类目录性质不同。`.agent/``.git/``.svn/``node_modules/` 放哪一层都是错的,进包即污染用户项目;而 `dist` / `library` / `temp` 这类只说明「作者把编辑器缓存或构建产物当成了模板内容」,出现在根目录才是信号,全面禁止会误伤工程内的正常同名目录。
- 影响范围:`scripts/agc-template-library-publish.mjs` 及其测试、`server-rs/crates/api-server/src/admin_templates.rs``docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md``docs/【模板规范】AGC模板包组织指南-2026-09-21.md``docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md`
- 验证方式:`node --test scripts/agc-template-library-publish.test.mjs`(28 项,新增 1 项:13 条拒绝用例 + `game/dist/**``game/.gitignore` 放行断言);`cargo test --locked -p api-server admin_templates`8 项,新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中,本地重打包的 ZIP 摘要与线上清单 `zipSha256` 逐条一致;`cargo fmt --check``check:encoding``check:doc-index``git diff --check` 通过。
## 2026-09-21 后台模板上传:成品 ZIP + 每模板封面,批量全有或全无
- 背景:模板发布此前只有本地 CLI(源目录 + 确定性打包 + `--only`),后台上传需要一条不依赖本地仓库的通道,并支持一次提交多个模板。
@@ -39,7 +39,7 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,
- 接口 `POST /admin/api/agc-templates/import``multipart/form-data`):`manifest` 文本字段 + `zip_<index>` / `cover_<index>` 文件字段,下标与 manifest 条目顺序一一对应。manifest 为 `{ expectedRevision, templates: [{ id, title, summary, tags, runtime, engine, engineVersion, entry, templateVersion, zipField, coverField }] }`,禁止未知字段。
- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB`runtime` 仅接受 `html / unity / godot / cocos``id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB,图片与元数据仍是 5 MiB。
- 语义:一批**全有或全无**。任一模板的 manifest 字段、归档或封面不合法,都在任何写入之前整批拒绝,并逐项给出模板 ID 与原因。
- 归档校验不落盘:合法 zip、无符号链接、无绝对路径 / `..` / 盘符 / 反斜杠条目、必须包含 manifest 声明的 `entry`、条目数 ≤ 4096 且解压后总大小 ≤ 512 MiB。
- 归档校验不落盘:合法 zip、无符号链接、无绝对路径 / `..` / 盘符 / 反斜杠条目、必须包含 manifest 声明的 `entry`、条目数 ≤ 4096 且解压后总大小 ≤ 512 MiB;并拒绝含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的包(与 CLI 打包同一门禁,见 [`【模板规范】AGC 模板包组织指南`](../【模板规范】AGC模板包组织指南-2026-09-21.md)
- 封面每个模板必填,按字节嗅探格式(只接受真实 PNG / JPEG / WebP),不信任 multipart 声明的 content-type;上限与编辑路径相同(5 MiB、单边 4096、1600 万像素)。SVG 仍只可能来自 CLI 历史发布。
- 发布复用既有协议:ZIP 按上传字节原样发布(不重新打包、不删除历史对象),对象键为 `templates/v1/<id>/sha256/<摘要>/{template.zip,cover.*,template.json}`;在发布锁内先 CAS 校验 `expectedRevision`(过期返回 409),写入后逐个回读校验,最后提交一次清单;清单写入结果不明时保留锁并返回 503。断连由独立任务持有,不会在清单 PUT 在途时提前解锁。
- 版本与保留语义:新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段(含下架条目);同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本(与 CLI 同一句文案),字节完全一致时按内容复用,响应里以 `reusedObjects` 标出。
@@ -79,7 +79,7 @@ templates/
- 任何一项校验失败(schema、标识符、sha256、尺寸、键前缀)都让整次清单读取失败,前端拿到的是全有或全无的清单。
- 模板源在仓库 `apps/ai-game-creator-shell/template-library/``v1/<id>/{meta.json, project/**, cover.(png|jpg|webp|svg)}``template.zip` **不落仓库**,由脚本按 `project/` 现场打包(条目排序、固定时间戳,同内容重复打包摘要一致)。
- 模板包内容怎么组织(根目录结构、Cocos 工程保留项、不要放的东西、封面与体积上限、发布前自检)见 [`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`](../【模板规范】AGC模板包组织指南-2026-09-21.md)。
- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--only <id,id,...>]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。
- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--only <id,id,...>]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。打包阶段执行与后台上传相同的正文门禁(`readProjectFiles`):正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)或根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板直接报错,不产生任何上传对象。
- 只更新指定模板时使用 `--only <id,id,...>`,在发布锁内读取最新清单,只替换指定 ID,其余条目和未知扩展字段保留。首次清单 404 可由本次选择初始化;读取异常或清单非法时停止。全量发布也遵守相同锁与版本门禁。
- `templates/README.md` 与上述正文不同:它不是内容寻址对象,而是**覆盖写的说明文档**,源在仓库 `apps/ai-game-creator-shell/template-library/README.md`(上限 64 KiB),由同一次发布在清单之前写入并回读校验。客户端从不读它,改契约只改仓库源即可,不要再手工维护线上副本。
- 正式发布先通过 `GetBucketVersioning` 确认 Bucket 从未开启版本控制,再用 `x-oss-forbid-overwrite: true` 原子创建 `.publish-lock.json`;版本控制 Enabled、Suspended、检查无权限或无法判定时均在写入前停止。所有写同一清单的发布进程必须使用此锁,发布期间不得改变 Bucket 版本控制配置。锁没有自动过期或抢占机制,已被占用时直接失败,重新执行须重新获取锁并读取最新清单。
@@ -133,6 +133,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项
`2026-09-21` 模板库重发布:把线上库从 09-17 的旧产物对齐到仓库当前源。线上 9 个模板里 7 个(`blank-3d-scene``cocos-empty-2d/3d/3d-hq/hello-world``phaser-2d-starter``threejs-3d-starter`)的 ZIP 与仓库不一致(旧产物来自 PR 定稿前的源,例如模板内嵌 `package-lock.json` 已由 `5e4ff54a9` 删除、`game.js` / `main.js` 后来改过),其中 2 个原本一致(`blank-2d-canvas``blank-web`)。按「同版本 ZIP 不得变」门禁,为这 7 个模板递增 `templateVersion``0.1.1` 后重发:28 个内容寻址对象逐个回读校验通过,`index.json``templates/README.md` 在锁内提交;线上清单现在全部指向 `v1/<id>/sha256/<摘要>/` 键,`libraryVersion=1`、9 个模板、无下架条目。发布脚本本次同时补齐说明文档受管写入(源文件即 `apps/ai-game-creator-shell/template-library/README.md`)并修掉「守卫拒绝后直接 `process.exit(1)` 触发 libuv 断言崩溃、看不到原因」的问题;线上 README 与仓库源已逐字节一致。
`2026-09-21` 模板正文目录门禁:CLI 打包(`readProjectFiles`)与后台上传(`validate_import_archive`)统一拒绝正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板,两处用同一份段名单与同一句文案。同名目录段只在根目录受限:正文内 `game/dist/**``.gitignore` 仍合法(`.gitignore` 不等于 `.git`),Cocos 模板的 `.creator` / `.gitignore` 不受影响。证据:Node 发布回归 28 项通过(新增 1 项,13 条拒绝用例 + 2 条放行断言);Rust `admin_templates` 8 项回归通过(新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中门禁,本地重打包的 9 个 ZIP 摘要与线上 `index.json``zipSha256` 逐条一致(匿名读清单 200、9 个模板、0 个下架条目),即门禁未改变任何已发布字节。未执行真实 OSS 写入。
## 本地压测假数据注入(feature 控制)
模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。
@@ -103,7 +103,8 @@ assets/… # 可选
## 已知边界
- 当前工具**不会**自动拦截 `.agent/``.git/``node_modules/` 这类目录:ZIP 里放了什么,建项后用户项目里就有什么(只有安装标记 `installed.json` 不会被复制)。这条依赖模板作者遵守本指南
- 上面「不要放进 ZIP」的目录里,**身份 / 版本库 / 依赖 / 构建产物已有机器门禁**:任意层级的 `.agent/``.git/``.svn/``node_modules/`,以及根目录的 `dist/``build/``library/``temp/``local/``.idea/``.vscode/`,都会在发布前被拒绝——CLI 在收集正文时就报错,后台「上传模板」在任何写入之前整批拒绝,不会出现「传上去了才发现」。同名目录段只在根目录受限,所以正文内的 `game/dist/**` 属于模板自身内容,不会被拦
- 门禁只覆盖上面列出的目录。其余内容(`.env*`、密钥、日志、个人绝对路径等)仍依赖模板作者遵守本指南:ZIP 里放了什么,建项后用户项目里就有什么(只有安装标记 `installed.json` 不会被复制)。
- 客户端按 `installedVersion != templateVersion` 判断是否需要更新;同版本换内容不会触发更新,所以改了字节就必须递增版本。
## 相关文档
+27
View File
@@ -200,12 +200,39 @@ function buildZip(entries) {
}
function readProjectFiles(projectRoot) {
/** 正文任何层级都不允许出现的目录段:项目身份与版本库 / 依赖元数据。 */
const forbiddenSegments = new Set(['.agent', '.git', '.svn', 'node_modules']);
/** 只允许出现在正文根目录之外的构建产物与编辑器工作区目录。 */
const forbiddenRootSegments = new Set([
'dist',
'build',
'library',
'temp',
'local',
'.idea',
'.vscode',
]);
// 与后台「上传模板」共用同一条门禁,依据 docs/【模板规范】AGC模板包组织指南-2026-09-21.md。
const violation = (relative) => {
const segments = relative.split('/');
for (const segment of segments) {
if (forbiddenSegments.has(segment)) {
return `模板正文不允许包含 ${segment} 目录:${relative}`;
}
}
if (forbiddenRootSegments.has(segments[0])) {
return `模板正文根目录不允许包含 ${segments[0]} 目录:${relative}`;
}
return null;
};
const files = [];
const walk = (directory, prefix) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
)) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const rejected = violation(relative);
if (rejected) throw new Error(rejected);
const full = join(directory, entry.name);
if (entry.isDirectory()) walk(full, relative);
else if (entry.isFile())
+88 -2
View File
@@ -1,9 +1,15 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash, createHmac } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { basename, dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -87,6 +93,86 @@ test('官方 Cocos 模板生成完整且可重复的原生项目包', async () =
}
});
function fixtureSource(projectEntries = []) {
const directory = mkdtempSync(join(tmpdir(), 'agc-template-publish-'));
const templateRoot = join(directory, 'v1', 'fixture-template');
const write = (relative, bytes) => {
const full = join(templateRoot, relative);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, bytes);
};
write(
'meta.json',
`${JSON.stringify(
{
id: 'fixture-template',
title: '夹具模板',
summary: '',
tags: ['fixture'],
runtime: 'html',
entry: 'game/index.html',
templateVersion: '0.1.0',
},
null,
2,
)}\n`,
);
write('cover.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>\n');
write('game/index.html', '<html></html>\n');
for (const relative of projectEntries) write(`project/${relative}`, 'x\n');
return directory;
}
test('模板正文含身份/版本库/依赖/构建目录时拒绝打包', async () => {
const rejected = [
['.agent/manifest.json', '不允许包含 .agent 目录'],
['game/.agent/ledger.json', '不允许包含 .agent 目录'],
['.git/config', '不允许包含 .git 目录'],
['.svn/entries', '不允许包含 .svn 目录'],
['node_modules/three/package.json', '不允许包含 node_modules 目录'],
['assets/node_modules/keep.txt', '不允许包含 node_modules 目录'],
['dist/game.js', '根目录不允许包含 dist 目录'],
['build/index.html', '根目录不允许包含 build 目录'],
['library/import.json', '根目录不允许包含 library 目录'],
['temp/asset.json', '根目录不允许包含 temp 目录'],
['local/settings.json', '根目录不允许包含 local 目录'],
['.vscode/settings.json', '根目录不允许包含 .vscode 目录'],
['.idea/misc.xml', '根目录不允许包含 .idea 目录'],
];
for (const [relative, expected] of rejected) {
const directory = fixtureSource([relative]);
try {
assert.throws(
() => buildLibrary(directory, 'templates'),
(error) => String(error.message).includes(expected),
`${relative} 必须被拒绝`,
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
// 同名目录段只在根目录被拒绝:正文内 game/dist/** 属于模板自身内容,.gitignore 也不是 .git。
const directory = fixtureSource(['game/dist/app.js', 'game/.gitignore']);
try {
const built = buildLibrary(directory, 'templates');
assert.deepEqual(
built.indexJson.templates.map((entry) => entry.id),
['fixture-template'],
);
const zip = await JSZip.loadAsync(
built.objects.find(
(object) => object.key === built.indexJson.templates[0].zipKey,
).body,
{ checkCRC32: true },
);
assert.ok(Object.keys(zip.files).includes('game/dist/app.js'));
assert.ok(Object.keys(zip.files).includes('game/.gitignore'));
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
const indexKey = 'templates/index.json';
const lockKey = 'templates/.publish-lock.json';
@@ -331,6 +331,30 @@ fn safe_import_archive_path(value: &str) -> Result<String, String> {
Ok(trimmed.to_string())
}
/// 任何层级都不允许出现的条目段:项目身份(`.agent`)与版本库 / 依赖元数据。
const IMPORT_FORBIDDEN_SEGMENTS: [&str; 4] = [".agent", ".git", ".svn", "node_modules"];
/// 只允许出现在模板根目录之外的构建产物与编辑器工作区目录。
const IMPORT_FORBIDDEN_ROOT_SEGMENTS: [&str; 7] = [
"dist", "build", "library", "temp", "local", ".idea", ".vscode",
];
/// 模板作者约定(见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`):这些内容
/// 进了包就会跟着建到用户项目里,因此发布前失败关闭,而不是靠作者自觉。
fn import_entry_path_violation(relative: &str) -> Option<String> {
let segments = relative.split('/').collect::<Vec<_>>();
for segment in &segments {
if IMPORT_FORBIDDEN_SEGMENTS.contains(segment) {
return Some(format!("模板包不允许包含 {segment} 目录"));
}
}
if let Some(first) = segments.first() {
if IMPORT_FORBIDDEN_ROOT_SEGMENTS.contains(first) {
return Some(format!("模板包根目录不允许包含 {first} 目录"));
}
}
None
}
/// 只做校验、不落盘:确认上传的是合法 zip、条目安全,并包含清单声明的 entry。
fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
@@ -354,6 +378,9 @@ fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> {
return Err("模板包不允许包含符号链接".to_string());
}
let relative = safe_import_archive_path(file.name())?;
if let Some(violation) = import_entry_path_violation(&relative) {
return Err(violation);
}
if relative == entry {
has_entry = true;
}
@@ -799,6 +826,53 @@ mod tests {
);
}
#[test]
fn template_import_archive_rejects_identity_and_build_directories() {
let forbidden = [
(".agent/manifest.json", "模板包不允许包含 .agent 目录"),
("game/.agent/ledger.json", "模板包不允许包含 .agent 目录"),
(".git/config", "模板包不允许包含 .git 目录"),
(".svn/entries", "模板包不允许包含 .svn 目录"),
(
"node_modules/three/package.json",
"模板包不允许包含 node_modules 目录",
),
(
"assets/node_modules/keep.txt",
"模板包不允许包含 node_modules 目录",
),
("dist/game.js", "模板包根目录不允许包含 dist 目录"),
("build/index.html", "模板包根目录不允许包含 build 目录"),
("library/import.json", "模板包根目录不允许包含 library 目录"),
("temp/asset.json", "模板包根目录不允许包含 temp 目录"),
("local/settings.json", "模板包根目录不允许包含 local 目录"),
(
".vscode/settings.json",
"模板包根目录不允许包含 .vscode 目录",
),
(".idea/misc.xml", "模板包根目录不允许包含 .idea 目录"),
];
for (name, expected) in forbidden {
let bytes = archive(&[
("game/index.html", b"<html></html>", None),
(name, b"x", None),
]);
assert_eq!(
validate_import_archive(&bytes, "game/index.html").expect_err(name),
expected,
"条目 {name} 必须被拒绝"
);
}
// 同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容,`.gitignore` 也不是 `.git`。
let nested = archive(&[
("game/index.html", b"<html></html>", None),
("game/dist/app.js", b"x", None),
("game/.gitignore", b"node_modules\n", None),
]);
assert!(validate_import_archive(&nested, "game/index.html").is_ok());
}
#[test]
fn template_import_plan_uses_content_addressed_keys_and_sniffed_cover() {
let files = import_files();