Merge branch 'master' into fix/empty-input
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m59s
Project CI / Backend tests (pull_request) Successful in 6m59s
Project CI / Repository checks (pull_request) Successful in 3m52s
Project CI / Frontend tests (pull_request) Successful in 5m58s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m59s
Project CI / Backend tests (pull_request) Successful in 6m59s
Project CI / Repository checks (pull_request) Successful in 3m52s
Project CI / Frontend tests (pull_request) Successful in 5m58s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m9s
This commit is contained in:
@@ -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` 判断是否需要更新;同版本换内容不会触发更新,所以改了字节就必须递增版本。
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user