修复AGC随包资源staging边界
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 1m44s
Project CI / Frontend tests (pull_request) Successful in 2m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m30s

修复 --no-bundle 构建前的资源准备接线。

拒绝插件随包目录中的未声明文件并同步Rust校验。

增加staging替换失败后的旧资源恢复与回归测试。
This commit is contained in:
2026-09-30 19:12:30 +08:00
parent 6d789b9dc0
commit d2d78fa3d4
7 changed files with 733 additions and 40 deletions
@@ -505,10 +505,11 @@ export function runTauriBuild(
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
const features = resolveEditorFeatures({ argv: args, target });
// --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。
if (!args.includes('--no-bundle')) {
stageRuntime(target);
stageBundled(target, { features });
}
stageBundled(target, { features });
const configPath = writeChannelConfigFile(
channel,
target,
@@ -1001,6 +1001,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
events.push('bundled');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
@@ -1013,6 +1014,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
assert.deepEqual(events, ['bundled', 'build']);
assert.throws(
() =>
runTauriBuild(['--target', windowsTarget], context, {
@@ -485,6 +485,7 @@ function renderLibraryStaging(entry) {
['targets', rustStrings(entry.targets)],
['features', rustStrings(entry.features)],
['layout', rustString(entry.layout)],
['files', rustStrings(entry.files)],
],
1,
);
@@ -488,8 +488,11 @@ function buildUnitInto(
}
function stageAtomically(unitPath, builder) {
const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`;
const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
const stagingPath = `${unitPath}-staging-${suffix}`;
const backupPath = `${unitPath}-backup-${suffix}`;
rmSync(stagingPath, { recursive: true, force: true });
rmSync(backupPath, { recursive: true, force: true });
mkdirSync(stagingPath, { recursive: true });
try {
builder(stagingPath);
@@ -497,15 +500,31 @@ function stageAtomically(unitPath, builder) {
rmSync(stagingPath, { recursive: true, force: true });
throw error;
}
rmSync(unitPath, { recursive: true, force: true });
const hadPrevious = existsSync(unitPath);
try {
if (hadPrevious) {
renameSync(unitPath, backupPath);
}
renameSync(stagingPath, unitPath);
} catch (error) {
// 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。
let restored = !hadPrevious;
if (hadPrevious && existsSync(backupPath)) {
try {
if (existsSync(unitPath)) {
rmSync(unitPath, { recursive: true, force: true });
}
renameSync(backupPath, unitPath);
restored = true;
} catch {
restored = false;
}
}
fail(
`替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`,
`替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`,
);
}
rmSync(backupPath, { recursive: true, force: true });
}
function prepareCodex({
@@ -860,6 +879,30 @@ function subdirectoryEnabled(subdirectory, target, features) {
return matchesContains && matchesTarget && matchesFeatures;
}
function collectTreeFiles(root, label) {
const files = [];
const stack = [['', root]];
while (stack.length > 0) {
const [prefix, directory] = stack.pop();
if (!existsSync(directory)) {
continue;
}
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const entryPath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
fail(`${label}不允许符号链接:${entryPath}`);
}
if (entry.isDirectory()) {
stack.push([relative, entryPath]);
} else if (entry.isFile()) {
files.push(relative);
}
}
}
return files.sort();
}
/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。
function skipDirectory(rules, name) {
return (
@@ -958,7 +1001,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name !== staging.plugin ||
libraryStagingEnabled(staging, target, features)
!libraryStagingEnabled(staging, target, features)
) {
continue;
}
@@ -978,7 +1021,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (
plugin.name !== payload.plugin ||
nativePayloadEnabled(payload, target, features)
!nativePayloadEnabled(payload, target, features)
) {
continue;
}
@@ -1007,8 +1050,11 @@ function pluginTreeMatches(
if (!existsSync(destination)) {
return false;
}
const allowedFiles = new Set();
for (const plugin of plugins) {
const pluginDestination = path.join(destination, plugin.name);
const pluginPrefix = `${plugin.name}/`;
allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`);
if (
!existsSync(
path.join(pluginDestination, declaration.plugins.manifestFileName),
@@ -1022,17 +1068,24 @@ function pluginTreeMatches(
}
const stagedDirectory = path.join(pluginDestination, subdirectory.path);
if (!subdirectoryEnabled(subdirectory, target, features)) {
// 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。
if (existsSync(stagedDirectory)) {
return false;
}
continue;
}
const relativePrefix = `${plugin.name}/${subdirectory.path}/`;
const sourceRoot = path.join(plugin.root, subdirectory.path);
if (subdirectory.origin !== 'source') {
if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) {
return false;
}
for (const relative of walkFiles(
declaration.plugins,
sourceRoot,
'插件资源',
)) {
allowedFiles.add(`${relativePrefix}${relative}`);
}
continue;
}
for (const relative of walkFiles(
@@ -1046,6 +1099,7 @@ function pluginTreeMatches(
subdirectory.path,
relative,
);
allowedFiles.add(`${relativePrefix}${relative}`);
if (!existsSync(staged)) {
return false;
}
@@ -1057,8 +1111,32 @@ function pluginTreeMatches(
}
}
}
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name === staging.plugin &&
libraryStagingEnabled(staging, target, features)
) {
for (const relative of staging.files) {
allowedFiles.add(
`${plugin.name}/${staging.sourceSubdirectory}/${relative}`,
);
}
}
}
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (
plugin.name === payload.plugin &&
nativePayloadEnabled(payload, target, features)
) {
allowedFiles.add(
`${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`,
);
}
}
}
return true;
return collectTreeFiles(destination, '插件随包目录').every((relative) =>
allowedFiles.has(relative),
);
}
function assertOwnedPluginRoot(destination, plugins) {
@@ -1504,10 +1582,16 @@ function preparePlugins({
});
return {
summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`,
record: { fingerprint },
record: {
fingerprint: pluginSourceFingerprint(
declaration,
plugins,
target,
features,
),
},
};
}
/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。
export function prepareBundledResources({
target = resolveHostTarget(),
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
@@ -286,6 +287,41 @@ function prepare(fixture, overrides = {}) {
});
}
/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。
/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门;
/// body 是同步的,注入窗口内不会有别的调用跑进来。
function withRenameFailure(predicate, body) {
const original = fs.renameSync;
let injected = false;
fs.renameSync = (from, to) => {
if (
!injected &&
predicate(path.resolve(String(from)), path.resolve(String(to)))
) {
injected = true;
throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' });
}
return original.call(fs, from, to);
};
syncBuiltinESMExports();
try {
return body();
} finally {
fs.renameSync = original;
syncBuiltinESMExports();
}
}
/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。
function expectThrow(body) {
try {
body();
} catch (error) {
return error;
}
throw new Error('预期抛错但调用成功了');
}
test('stages declared codex components with manifest and preserved notice', () => {
const fixture = buildFixture();
try {
@@ -824,3 +860,300 @@ test('delivers editor branch artifacts declared as prepared or library staging',
fixture.cleanup();
}
});
/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。
const ALL_WINDOWS_FEATURES = new Set([
'unity-editor-execute',
'godot-editor-execute',
'cocos-editor-injection',
]);
function pluginStaged(fixture, relative) {
return path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
relative,
);
}
test('keeps the previous codex resources when the replacement fails', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const declaration = fixture.declaration;
const layout = declaration.codex.targets.find(
(entry) => entry.target === WINDOWS_TARGET,
);
const unit = path.join(
fixture.destinationRoot,
declaration.codex.resourceDirectory,
layout.directory,
);
const before = snapshot(unit);
const component = layout.files.find((relative) =>
relative.includes('code-mode-host'),
);
const vendor = path.join(
fixture.appRoot,
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`,
);
// 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。
fs.writeFileSync(path.join(vendor, component), 'changed component\n');
// 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。
fs.rmSync(fixture.recordPath);
const error = expectThrow(() =>
withRenameFailure(
(from, to) => to === path.resolve(unit),
() => prepare(fixture),
),
);
assert.match(error.message, /替换 .*win-x64.* 失败/u);
assert.match(error.message, /旧资源已恢复/u);
assert.deepEqual(
snapshot(unit),
before,
'替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变',
);
assert.equal(
fs.readFileSync(
path.join(unit, declaration.codex.noticeFileName),
'utf8',
),
'windows codex notice\n',
'受版本控制的第三方声明必须还在原位',
);
const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1];
assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`);
// 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。
const siblings = fs.readdirSync(path.dirname(unit));
assert.ok(siblings.includes(path.basename(unit)));
assert.ok(siblings.includes(path.basename(stagingPath)));
assert.ok(
siblings.every((entry) => !entry.includes('-backup-')),
'恢复成功后不得留下 backup 目录',
);
assert.equal(
fs.readFileSync(path.join(stagingPath, component), 'utf8'),
'changed component\n',
'没有落盘的新产物必须留在 staging 里供人工检查',
);
// 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。
const summaries = prepare(fixture);
assert.match(summaries[0], /重新生成/u);
assert.equal(
fs.readFileSync(path.join(unit, component), 'utf8'),
'changed component\n',
);
} finally {
fixture.cleanup();
}
});
test('keeps the previous plugin resources when the replacement fails', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const destination = path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
);
// 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。
fs.writeFileSync(
path.join(destination, 'agc-demo-editor/src/rogue.mjs'),
'rogue\n',
);
const before = snapshot(destination);
const error = expectThrow(() =>
withRenameFailure(
(from, to) => to === path.resolve(destination),
() => prepare(fixture),
),
);
assert.match(error.message, /替换 .*plugins.* 失败/u);
assert.match(error.message, /旧资源已恢复/u);
assert.deepEqual(
snapshot(destination),
before,
'替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留',
);
prepare(fixture);
assert.ok(
!fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')),
'注入消失后重试必须成功并清掉额外文件',
);
assert.ok(
fs.existsSync(
path.join(destination, 'agc-demo-editor/panels/panel.html'),
),
'重试后声明的随包内容必须齐全',
);
} finally {
fixture.cleanup();
}
});
test('clears undeclared and hidden entries left in the staged plugin tree', () => {
const fixture = buildFixture();
try {
prepare(fixture);
// 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。
for (const relative of [
'agc-demo-editor/extra/rogue.txt',
'agc-demo-editor/.cache/tmp.bin',
'agc-demo-editor/src/.env',
]) {
const file = pluginStaged(fixture, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'residue\n');
}
const summaries = prepare(fixture);
assert.match(summaries[1], /plugins 重新生成/u);
for (const relative of [
'agc-demo-editor/extra',
'agc-demo-editor/.cache',
'agc-demo-editor/src/.env',
]) {
assert.ok(
!fs.existsSync(pluginStaged(fixture, relative)),
`未声明的残留 ${relative} 必须被清掉`,
);
}
for (const relative of [
'agc-demo-editor/plugin.json',
'agc-demo-editor/src/entry.mjs',
'agc-demo-editor/panels/panel.html',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`声明的随包内容 ${relative} 必须还在`,
);
}
assert.ok(
!fs.existsSync(
pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'),
),
'跳过规则在重建后依然生效',
);
} finally {
fixture.cleanup();
}
});
test('removes staged plugin files whose source has been deleted', () => {
const fixture = buildFixture();
try {
prepare(fixture);
assert.ok(
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')),
'前置条件:源码里的文件已经随包',
);
fs.rmSync(
path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'),
);
const summaries = prepare(fixture);
assert.match(summaries[1], /plugins 重新生成/u);
assert.ok(
!fs.existsSync(
pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'),
),
'源码里已删除的文件不得留在随包目录',
);
assert.ok(
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')),
'同一插件的其他声明内容必须还在',
);
// 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。
assert.match(prepare(fixture)[1], /命中缓存/u);
} finally {
fixture.cleanup();
}
});
test('clears staged prepared payloads when their feature is disabled', () => {
const fixture = buildFixture();
try {
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
for (const relative of [
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
'agc-godot-editor/native/gdextension/vendor/provenance.json',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`前置条件:feature 全开时 ${relative} 必须随包`,
);
}
prepare(fixture, { features: new Set() });
for (const relative of [
'agc-cocos-editor/native/payload',
'agc-unity-editor/dotnet',
'agc-godot-editor/native',
]) {
assert.ok(
!fs.existsSync(pluginStaged(fixture, relative)),
`feature 关闭后 ${relative} 不得作为残留继续随包`,
);
}
for (const plugin of [
'agc-cocos-editor',
'agc-unity-editor',
'agc-godot-editor',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)),
`feature 关闭不得动到 ${plugin} 的声明内容`,
);
assert.ok(
fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)),
`feature 关闭不得动到 ${plugin} 的源码内容`,
);
}
} finally {
fixture.cleanup();
}
});
test('treats declared prepared and library-staging artifacts as owned on a second run', () => {
const fixture = buildFixture();
try {
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
const destination = path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
);
const before = snapshot(destination);
const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES });
assert.match(
summaries[1],
/plugins 命中缓存(未写入/u,
'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建',
);
assert.deepEqual(
snapshot(destination),
before,
'命中缓存时一个字节、一个时间戳都不许动',
);
for (const relative of [
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
'agc-godot-editor/native/gdextension/vendor/provenance.json',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`已声明产物 ${relative} 不得因为缓存判定被删掉`,
);
}
} finally {
fixture.cleanup();
}
});
@@ -153,6 +153,7 @@ LibraryStaging {
targets: &["x86_64-pc-windows-msvc"],
features: &["godot-editor-execute"],
layout: "godot-bundle",
files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"],
}
],
skip_directory_names: &["target", "node_modules"],
@@ -59,6 +59,9 @@ pub struct Subdirectory {
}
/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。
///
/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与
/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。
#[derive(Debug)]
pub struct LibraryStaging {
pub plugin: &'static str,
@@ -66,6 +69,7 @@ pub struct LibraryStaging {
pub targets: &'static [&'static str],
pub features: &'static [&'static str],
pub layout: &'static str,
pub files: &'static [&'static str],
}
#[derive(Debug)]
@@ -290,8 +294,11 @@ pub fn declared_relative_path(relative: &str) -> PathBuf {
/// 只读校验插件随包工作区。
///
/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录
/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。
/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、
/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256
/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责),
/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、
/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。
pub fn validate_staged_plugins(
source_root: &Path,
destination_root: &Path,
@@ -307,7 +314,36 @@ pub fn validate_staged_plugins(
destination_root.display()
));
}
for plugin in plugin_directories(source_root, plugins().manifest_file_name)? {
let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?;
let declared_names = declared_plugins
.iter()
.map(|plugin| plugin.name.as_str())
.collect::<BTreeSet<_>>();
for entry in std::fs::read_dir(destination_root).map_err(|error| {
format!(
"插件随包资源目录不可读 {}:{error}",
destination_root.display()
)
})? {
let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?;
let file_type = entry
.file_type()
.map_err(|error| format!("插件随包目录项类型不可读:{error}"))?;
if file_type.is_symlink() {
return Err(format!(
"插件随包目录不允许符号链接:{}",
entry.path().display()
));
}
let name = entry.file_name().to_string_lossy().to_string();
if !declared_names.contains(name.as_str()) {
return Err(format!(
"插件随包目录存在未声明条目:{}",
entry.path().display()
));
}
}
for plugin in &declared_plugins {
let staged = destination_root.join(&plugin.name);
let source_manifest = plugin.path.join(plugins().manifest_file_name);
let staged_manifest = staged.join(plugins().manifest_file_name);
@@ -328,39 +364,34 @@ pub fn validate_staged_plugins(
staged_manifest.display()
));
}
let mut allowed = BTreeSet::new();
allowed.insert(plugins().manifest_file_name.to_string());
for subdirectory in plugins().subdirectories {
if !subdirectory_enabled(subdirectory, target, &feature_enabled) {
if !subdirectory_applies_to_plugin(subdirectory, &plugin.name)
|| !subdirectory_enabled(subdirectory, target, &feature_enabled)
{
continue;
}
let relative = declared_relative_path(subdirectory.path);
let source = plugin.path.join(&relative);
let source_directory = plugin.path.join(&relative);
let staged_directory = staged.join(&relative);
if subdirectory_is_prepared(subdirectory) {
if source.exists() && !staged_directory.is_dir() {
return Err(format!(
"随包构建期产物缺失:{}(插件 {})",
staged_directory.display(),
plugin.name
));
}
continue;
}
if !source.is_dir() {
continue;
}
if !staged_directory.is_dir() {
return Err(format!(
"随包插件缺少必需目录:{}(插件 {})",
staged_directory.display(),
plugin.name
));
}
for relative_file in collect_sources(&source)? {
let source_file = source.join(declared_relative_path(&relative_file));
let staged_file = staged_directory.join(declared_relative_path(&relative_file));
for file in source_subdirectory_files(&source_directory)? {
allowed.insert(format!("{}/{}", subdirectory.path, file));
let staged_file = staged_directory.join(declared_relative_path(&file));
if !staged_file.is_file() {
return Err(format!("随包插件缺少文件:{}", staged_file.display()));
return Err(if subdirectory_is_prepared(subdirectory) {
format!(
"随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)",
staged_file.display()
)
} else {
format!("随包插件缺少文件:{}", staged_file.display())
});
}
if !subdirectory_is_source_derived(subdirectory) {
continue;
}
let source_file = source_directory.join(declared_relative_path(&file));
let source_digest = sha256_file(&source_file).map_err(|error| {
format!("读取插件文件失败 {}:{error}", source_file.display())
})?;
@@ -375,11 +406,45 @@ pub fn validate_staged_plugins(
}
}
}
for staging in plugins().library_staging {
if staging.plugin != plugin.name.as_str()
|| !library_staging_enabled(staging, target, &feature_enabled)
{
continue;
}
for file in staging.files {
let relative = format!("{}/{}", staging.source_subdirectory, file);
let staged_file = staged.join(declared_relative_path(&relative));
if !staged_file.is_file() {
return Err(format!(
"随包库缺少声明文件:{}(请先执行随包资源准备步骤)",
staged_file.display()
));
}
allowed.insert(relative);
}
}
for relative in collect_tree_files(&staged)? {
if !allowed.contains(&relative) {
return Err(format!(
"随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)",
staged.join(declared_relative_path(&relative)).display()
));
}
}
}
collect_tree_files(destination_root)?;
Ok(())
}
/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。
fn source_subdirectory_files(source_directory: &Path) -> Result<BTreeSet<String>, String> {
if !source_directory.is_dir() {
return Ok(BTreeSet::new());
}
collect_sources(source_directory)
}
/// 声明为「由准备步骤按源码派生」的子目录。
pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool {
subdirectory.origin == "source"
@@ -1124,6 +1189,29 @@ mod tests {
.expect("write staged entry");
}
/// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。
fn write_plugin_copy(
source_root: &Path,
destination_root: &Path,
plugin_name: &str,
relative_files: &[&str],
) {
for root in [source_root, destination_root] {
let plugin = root.join(plugin_name);
fs::create_dir_all(&plugin).expect("create plugin");
fs::write(
plugin.join("plugin.json"),
format!("{{\"name\":\"{plugin_name}\"}}\n"),
)
.expect("write manifest");
for relative in relative_files {
let path = plugin.join(declared_relative_path(relative));
fs::create_dir_all(path.parent().expect("path parent")).expect("create parent");
fs::write(&path, format!("{relative}\n")).expect("write file");
}
}
}
#[test]
fn staged_plugins_are_checked_against_repository_sources() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1205,6 +1293,189 @@ mod tests {
assert!(error.contains("符号链接"), "{error}");
}
/// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。
#[test]
fn staged_plugins_reject_undeclared_files() {
let temp = tempfile::tempdir().expect("tempdir");
let source_root = temp.path().join("plugins");
let destination_root = temp.path().join("resources/plugins");
write_plugin_fixture(&source_root, &destination_root);
let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs");
fs::write(&leftover, "stale\n").expect("write leftover");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"aarch64-apple-darwin",
|_| true,
)
.expect_err("必须拒绝源码子目录里的残留文件");
assert!(error.contains("未声明文件"), "{error}");
assert!(error.contains("leftover.mjs"), "{error}");
fs::remove_file(&leftover).expect("remove leftover");
let root_file = destination_root.join("agc-demo-editor/README.md");
fs::write(&root_file, "stale\n").expect("write plugin root file");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"aarch64-apple-darwin",
|_| true,
)
.expect_err("必须拒绝插件根目录的未知文件");
assert!(error.contains("未声明文件"), "{error}");
fs::remove_file(&root_file).expect("remove plugin root file");
let stray = destination_root.join("stray.txt");
fs::write(&stray, "stale\n").expect("write stray entry");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"aarch64-apple-darwin",
|_| true,
)
.expect_err("必须拒绝未声明的根条目");
assert!(error.contains("未声明条目"), "{error}");
}
/// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。
#[test]
fn staged_plugins_reject_files_removed_from_sources() {
let temp = tempfile::tempdir().expect("tempdir");
let source_root = temp.path().join("plugins");
let destination_root = temp.path().join("resources/plugins");
write_plugin_fixture(&source_root, &destination_root);
fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"aarch64-apple-darwin",
|_| true,
)
.expect_err("源码删除后的随包副本必须被拒绝");
assert!(error.contains("未声明文件"), "{error}");
assert!(error.contains("entry.mjs"), "{error}");
}
/// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。
#[test]
fn staged_plugins_reject_prepared_artifacts_of_disabled_features() {
let temp = tempfile::tempdir().expect("tempdir");
let source_root = temp.path().join("plugins");
let destination_root = temp.path().join("resources/plugins");
write_plugin_copy(
&source_root,
&destination_root,
"agc-cocos-editor",
&["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"],
);
validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|name| name == "cocos-editor-injection",
)
.expect("feature 开启时 prepared 产物合法");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|_| false,
)
.expect_err("feature 关闭后必须拒绝 prepared 产物");
assert!(error.contains("未声明文件"), "{error}");
assert!(error.contains("cocos-editor-bridge.dll"), "{error}");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"aarch64-apple-darwin",
|_| true,
)
.expect_err("目标不含 windows 时必须拒绝 prepared 产物");
assert!(error.contains("未声明文件"), "{error}");
}
/// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。
#[test]
fn staged_plugins_follow_declared_library_staging_files() {
let staging = PLUGINS
.library_staging
.iter()
.find(|entry| entry.layout == "godot-bundle")
.expect("godot staging");
let temp = tempfile::tempdir().expect("tempdir");
let source_root = temp.path().join("plugins");
let destination_root = temp.path().join("resources/plugins");
let mut relative_files = vec!["src/entry.mjs".to_string()];
relative_files.extend(
staging
.files
.iter()
.map(|file| format!("{}/{}", staging.source_subdirectory, file)),
);
let relative_files = relative_files
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
write_plugin_copy(
&source_root,
&destination_root,
"agc-godot-editor",
&relative_files,
);
let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp");
fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir");
fs::write(&source_only, "void build_input() {}\n").expect("write source-only file");
validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|name| name == "godot-editor-execute",
)
.expect("声明文件齐备时随包库合法");
let stale =
destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll");
fs::write(&stale, "stale\n").expect("write stale library file");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|name| name == "godot-editor-execute",
)
.expect_err("必须拒绝随包库里未声明的文件");
assert!(error.contains("未声明文件"), "{error}");
fs::remove_file(&stale).expect("remove stale library file");
let declared = destination_root
.join("agc-godot-editor")
.join(declared_relative_path(&format!(
"{}/{}",
staging.source_subdirectory, staging.files[0]
)));
fs::remove_file(&declared).expect("remove declared library file");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|name| name == "godot-editor-execute",
)
.expect_err("必须拒绝缺少声明文件的随包库");
assert!(error.contains("缺少声明文件"), "{error}");
let error = validate_staged_plugins(
&source_root,
&destination_root,
"x86_64-pc-windows-msvc",
|_| false,
)
.expect_err("feature 关闭后必须拒绝随包库");
assert!(error.contains("未声明文件"), "{error}");
}
#[test]
fn collect_sources_applies_declared_skip_rules() {
let temp = tempfile::tempdir().expect("tempdir");