From d2d78fa3d4296e9dfbfeb37b8ff41fd770fd75af Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 19:12:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90staging=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 --no-bundle 构建前的资源准备接线。 拒绝插件随包目录中的未声明文件并同步Rust校验。 增加staging替换失败后的旧资源恢复与回归测试。 --- .../scripts/build-release.mjs | 3 +- .../scripts/build-release.test.mjs | 2 + .../scripts/check-package-layout.mjs | 1 + .../scripts/prepare-bundled-resources.mjs | 104 +++++- .../prepare-bundled-resources.test.mjs | 333 ++++++++++++++++++ .../build_support/package-layout.generated.rs | 1 + .../src-tauri/build_support/package_layout.rs | 329 +++++++++++++++-- 7 files changed, 733 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index eccab786c..00f144de6 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -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, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index dcb19454d..e6438ed7f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -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, { diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 0928c0b7f..5682da895 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -485,6 +485,7 @@ function renderLibraryStaging(entry) { ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], ['layout', rustString(entry.layout)], + ['files', rustStrings(entry.files)], ], 1, ); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 6212becba..f22e34c00 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -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(), diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 5e3c6f54a..c808ab43c 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -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(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index e6d626a7d..931290eb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -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"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 48f24c023..b334fe265 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -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::>(); + 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, 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::>(); + 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");