模板正文目录门禁: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
+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';