From 410ce751ba5c8d4c0098f582436b93e55fb7df00 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 18:24:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=9E=84=E5=BB=BA=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE=E5=B9=B6=E7=A6=81=E6=AD=A2=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../releasearchive/source_boundary_test.go | 1 + build/README.md | 17 +- build/build-release.ps1 | 35 +++- build/toolchain.mjs | 179 ++++++++++++++++++ build/toolchain.test.mjs | 164 ++++++++++++++++ doc/Scert整改任务执行清单.md | 2 +- doc/基础整改任务规格.md | 8 +- doc/构建版本与数据库支持矩阵.md | 21 +- frontend/.npmrc | 3 + frontend/package-lock.json | 10 +- frontend/package.json | 25 ++- 12 files changed, 445 insertions(+), 22 deletions(-) create mode 100644 build/toolchain.mjs create mode 100644 build/toolchain.test.mjs create mode 100644 frontend/.npmrc diff --git a/.gitignore b/.gitignore index 292463e..a853d30 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ frontend/dist-release-*/ build/* !build/build-release.ps1 !build/README.md +!build/toolchain.mjs +!build/toolchain.test.mjs codex-*.patch desktop/frontend/dist/ desktop/*.exe diff --git a/backend/internal/releasearchive/source_boundary_test.go b/backend/internal/releasearchive/source_boundary_test.go index 78e2fda..49026d7 100644 --- a/backend/internal/releasearchive/source_boundary_test.go +++ b/backend/internal/releasearchive/source_boundary_test.go @@ -41,6 +41,7 @@ func TestRepositoryIgnoreBoundaries(t *testing.T) { "frontend/src/views/Login/Login.vue", "frontend/src/views/Login/Login.test.js", "deploy/install-linux.sh", "deploy/install-windows.ps1", "deploy/tests/install-linux-test.py", "desktop/main.go", "build/README.md", "build/build-release.ps1", + "build/toolchain.mjs", "build/toolchain.test.mjs", "frontend/.npmrc", "release/README.md", "artifacts/README.md", "tests/README.md", "scripts/README.md", "tests/example_test.go", "scripts/example.go", ".env.example", "nested/.env.sample", } diff --git a/build/README.md b/build/README.md index 74c5fc7..c51d552 100644 --- a/build/README.md +++ b/build/README.md @@ -56,7 +56,22 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -OutputDirect 脚本仅创建新的发布目录。发布目录或对应归档已存在时,在构建前拒绝执行,不覆盖旧包。前端直接构建到本次发布目录中的 `web-build/`,不清空或覆盖已有 `frontend/dist/`。失败产生的目录保留供排查,再次构建请使用新的输出目录。 -`manifest.json` 记录源码提交、已跟踪文件是否未提交、实际 Go 工具链、目标平台和文件及归档的 SHA256。`delivery_scope` 固定为 `web`、`server`,兼容字段 `desktop_requested` 固定为 `false`。`source_dirty=true` 的包仅用于开发,正式发布必须从已复审提交构建。未跟踪本地产物不计入源码脏状态,也不会自动打包。 +`manifest.json` 记录源码提交、源码是否未提交、实际 Go 工具链、目标平台和文件及归档的 SHA256。`delivery_scope` 固定为 `web`、`server`,兼容字段 `desktop_requested` 固定为 `false`。`source_dirty=true` 的包仅用于开发,正式发布必须从已复审提交构建。未忽略的未跟踪文件也计入源码脏状态;已忽略的本地产物不计入,也不会自动打包。 + +## 固定工具链与安装策略 + +`toolchain.mjs` 是正式发布入口调用的构建检查器,`toolchain.test.mjs` 是纳入前端测试命令的正式回归,不是临时验收脚本。 + +1. Go 精确版本以 `backend/go.mod` 为唯一来源;实际工具链不符则失败。`go.sum`、`go.mod` 固定 Go 依赖和数据库驱动,构建及归档使用 `-mod=readonly`;禁用外部 Go 工作区及 `GOFLAGS` 覆盖。 +2. Node/npm 精确版本由 `frontend/package.json` 的 `engines` 声明,`devEngines`、`packageManager` 和锁文件必须一致。直接依赖禁止范围版本,锁文件负责传递依赖。 +3. `frontend/.npmrc` 明确配置 `ignore-scripts=true`、`engine-strict=true`、`save-exact=true`。不使用脚本白名单或全量放行开关;当前 npm 的未审查警告不能证明脚本被禁止。 +4. 发布脚本在创建输出目录前校验版本、执行 `go mod verify`,并自动执行 `npm ci --ignore-scripts --engine-strict --include=dev --include=optional --no-audit --no-fund`。这会按锁文件重建前端依赖,不复用未知的旧安装,也不删除数据库、证据或既有发布包。 +5. esbuild 使用锁文件中对应构建机的平台可选包,不运行其安装脚本,不允许 `ESBUILD_BINARY_PATH` 外部覆盖。缺少平台包或二进制不可用必须失败,不允许另行下载绕过锁文件。 +6. Vite 构建仍显式执行,但禁用隐式前后置脚本。最终再校验版本和输入 SHA256;构建中改变版本文件不生成发布清单。 + +`build_environment` 记录 Node/npm/Go/PowerShell、直接前端依赖、安装策略、构建机系统名称/版本/架构,以及五份依赖和策略文件的 SHA256;不记录用户名、主机名或本地绝对路径。构建机目前允许 Windows/Linux amd64,实际已验证系统和未验证边界见版本矩阵;Windows 交叉编译 Linux 包不等于 Linux 原生构建或运行验收。 + +从 `npm test` 等父进程启动检查器时,npm 可能将用户级 `allow-scripts` 导出到环境变量,而项目级安装拒绝这种传递。本入口仅从子进程环境移除该白名单变量,不改用户配置;`--ignore-scripts` 仍强制生效。正式测试覆盖嵌套调用和主动尝试放行脚本的环境设置,不能通过开启安装脚本解决此兼容问题。 ## 本机路径保护 diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 297af7c..9ad7d22 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -62,7 +62,7 @@ function Invoke-GoBuild { $env:GOOS = $OperatingSystem $env:GOARCH = $Architecture $env:CGO_ENABLED = "0" - & go build -trimpath -o $Destination $Package + & go build -trimpath -mod=readonly -o $Destination $Package if ($LASTEXITCODE -ne 0) { throw "构建 $OperatingSystem/$Architecture 的 $Package 失败。" } @@ -136,6 +136,7 @@ function Invoke-ReleaseArchive { $arguments = @( "run", "-trimpath", + "-mod=readonly", "./cmd/scert-release", "--source", $Source, "--output", $Destination, @@ -174,7 +175,7 @@ function Test-ReleasePaths { [string[]]$ForbiddenPaths ) - $arguments = @("run", "-trimpath", "./cmd/scert-release", "--verify-only", "--source", $Source) + $arguments = @("run", "-trimpath", "-mod=readonly", "./cmd/scert-release", "--verify-only", "--source", $Source) foreach ($binary in $Binaries) { $arguments += @("--binary", $binary) } @@ -215,6 +216,9 @@ $npmCommand = if ($env:OS -eq "Windows_NT") { "npm.cmd" } else { "npm" } if (-not (Get-Command $npmCommand -ErrorAction SilentlyContinue)) { throw "未找到 npm 工具链,请先安装 Node.js 并加入 PATH。" } +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw "未找到 Node.js 工具链,请先安装 Node.js 并加入 PATH。" +} if (-not (Test-Path -LiteralPath (Join-Path $frontend "package.json") -PathType Leaf)) { throw "前端目录中没有找到 package.json:$frontend" } @@ -258,21 +262,29 @@ $sourceCommit = (& git -C $backend rev-parse HEAD) if ($LASTEXITCODE -ne 0) { throw "无法取得源码提交,不能生成可追溯发布包。" } -$sourceChanges = @(& git -C $backend status --porcelain --untracked-files=no) +$sourceChanges = @(& git -C $backend status --porcelain --untracked-files=normal) if ($LASTEXITCODE -ne 0) { throw "无法检查源码工作区状态。" } $sourceDirty = $sourceChanges.Count -gt 0 if ($sourceDirty) { - Write-Warning "存在未提交的已跟踪文件改动,清单将标记 source_dirty=true,不能作为正式发布包。" + Write-Warning "存在未提交的源码改动,清单将标记 source_dirty=true,不能作为正式发布包。" } + +Write-Host "正在校验构建版本并按锁文件安装前端依赖……" +$toolchainJSON = & node (Join-Path $scriptDirectory "toolchain.mjs") --backend $backend --frontend $frontend --install +if ($LASTEXITCODE -ne 0) { + throw "构建版本或依赖安装检查失败,尚未创建发布目录。" +} +$toolchain = ($toolchainJSON -join "`n") | ConvertFrom-Json +$toolchain | Add-Member -NotePropertyName powershell -NotePropertyValue $PSVersionTable.PSVersion.ToString() New-Item -ItemType Directory -Path $releaseDirectory | Out-Null $frontendDistribution = Join-Path $releaseDirectory "web-build" Write-Host "正在构建 Scert Web 前端……" Push-Location $frontend try { - & $npmCommand run build -- --outDir $frontendDistribution --emptyOutDir false + & $npmCommand --ignore-scripts run build -- --outDir $frontendDistribution --emptyOutDir false if ($LASTEXITCODE -ne 0) { throw "构建 Scert Web 前端失败。" } @@ -287,6 +299,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $frontendDistribution "index.html") $oldGoOS = $env:GOOS $oldGoArch = $env:GOARCH $oldCGOEnabled = $env:CGO_ENABLED +$oldGoWork = $env:GOWORK +$env:GOWORK = "off" $manifestArtifacts = [System.Collections.Generic.List[object]]::new() Push-Location $backend @@ -385,6 +399,16 @@ finally { $env:GOOS = $oldGoOS $env:GOARCH = $oldGoArch $env:CGO_ENABLED = $oldCGOEnabled + $env:GOWORK = $oldGoWork +} + +$verifiedToolchainJSON = & node (Join-Path $scriptDirectory "toolchain.mjs") --backend $backend --frontend $frontend +if ($LASTEXITCODE -ne 0) { + throw "构建后的工具链复核失败,不生成发布清单。" +} +$verifiedToolchain = ($verifiedToolchainJSON -join "`n") | ConvertFrom-Json +if (($toolchain.input_sha256 | ConvertTo-Json -Compress) -ne ($verifiedToolchain.input_sha256 | ConvertTo-Json -Compress)) { + throw "构建期间版本文件发生变化,不生成发布清单。" } $manifest = [ordered]@{ @@ -394,6 +418,7 @@ $manifest = [ordered]@{ source_commit = $sourceCommit.Trim() source_dirty = $sourceDirty go_toolchain = $goVersion.Trim() + build_environment = $toolchain trimpath = $true local_path_check = "passed" targets = @($Target) diff --git a/build/toolchain.mjs b/build/toolchain.mjs new file mode 100644 index 0000000..5500c9b --- /dev/null +++ b/build/toolchain.mjs @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { arch, platform, release, version } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const exactVersion = /^\d+\.\d+\.\d+$/; +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function requireCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +export function validateFrontend(pkg, lock, npmrc, actual) { + for (const name of ['node', 'npm']) { + const expected = pkg.engines?.[name]; + requireCondition(exactVersion.test(expected ?? ''), `${name} 必须固定精确版本。`); + requireCondition(actual[name] === expected, `${name} 版本不匹配,需要 ${expected}。`); + const engine = pkg.devEngines?.[name === 'node' ? 'runtime' : 'packageManager']; + requireCondition( + engine?.name === name && engine.version === expected && engine.onFail === 'error', + `${name} 的开发工具链拒绝策略不一致。`, + ); + requireCondition(lock.packages?.['']?.engines?.[name] === expected, `${name} 锁文件版本声明不一致。`); + } + requireCondition(pkg.packageManager === `npm@${pkg.engines.npm}`, '包管理器声明不一致。'); + requireCondition(lock.lockfileVersion === 3, '前端必须使用第三版锁文件。'); + requireCondition(pkg.name === lock.name && pkg.version === lock.version, '前端锁文件项目标识不一致。'); + + for (const section of ['dependencies', 'devDependencies']) { + const declared = pkg[section] ?? {}; + const locked = lock.packages[''][section] ?? {}; + requireCondition( + JSON.stringify(Object.keys(declared).sort()) === JSON.stringify(Object.keys(locked).sort()), + `前端 ${section} 与锁文件不一致。`, + ); + for (const [name, expected] of Object.entries(declared)) { + requireCondition(exactVersion.test(expected), `依赖 ${name} 必须固定精确版本。`); + requireCondition( + locked[name] === expected && lock.packages[`node_modules/${name}`]?.version === expected, + `依赖 ${name} 与锁文件不一致。`, + ); + } + } + + const settings = new Map(); + for (const line of npmrc.split(/\r?\n/)) { + const value = line.trim(); + if (!value || value.startsWith('#') || value.startsWith(';')) continue; + const match = /^([a-z-]+)=(.*)$/.exec(value); + requireCondition(match && !settings.has(match[1]), '前端 npm 配置包含无效项或重复项。'); + settings.set(match[1], match[2]); + } + for (const name of ['engine-strict', 'ignore-scripts', 'save-exact']) { + requireCondition(settings.get(name) === 'true', `前端 npm 配置必须启用 ${name}。`); + } + requireCondition(settings.size === 3, '前端 npm 配置存在未审查的额外设置。'); + requireCondition(!pkg.allowScripts && !pkg.dangerouslyAllowAllScripts, '禁止额外放行依赖安装脚本。'); +} + +export function validateGoVersion(goMod, actual) { + const expected = /^go (\d+\.\d+\.\d+)\s*$/m.exec(goMod)?.[1]; + requireCondition(expected, '后端模块必须声明精确 Go 版本。'); + requireCondition(actual === `go${expected}`, `实际 Go 工具链不匹配,需要 go${expected}。`); + return expected; +} + +export function validateEnvironment(env, host) { + requireCondition(['win32', 'linux'].includes(host.platform) && host.arch === 'x64', '构建机仅支持 Windows/Linux amd64。'); + requireCondition(!env.ESBUILD_BINARY_PATH, '禁止使用锁文件之外的 esbuild 可执行文件。'); + requireCondition(!env.GOWORK || env.GOWORK === 'off', '正式构建禁止使用外部 Go 工作区。'); + requireCondition(!env.GOFLAGS, '正式构建禁止通过 GOFLAGS 覆盖依赖或编译策略。'); +} + +export function npmEnvironment(env = process.env) { + // npm run 会把用户级白名单导出为环境变量;子项目安装却拒绝该变量。 + // 仅移除这项不适用于项目安装的继承策略,安装命令仍强制禁止全部脚本。 + return Object.fromEntries(Object.entries(env).filter(([name]) => name.toLowerCase() !== 'npm_config_allow_scripts')); +} + +function execute(command, args, cwd, env = process.env) { + const useShell = process.platform === 'win32' && command === npmCommand; + if (useShell) { + requireCondition(args.every((arg) => /^[a-zA-Z0-9=_./-]+$/.test(arg)), 'npm 参数含有不允许的命令解释器字符。'); + } + const options = { + cwd, + env: command === npmCommand ? npmEnvironment(env) : env, + encoding: 'utf8', + windowsHide: true, + // Windows 的 npm.cmd 需要命令解释器;参数仅来自本文件的固定值。 + shell: useShell, + maxBuffer: 16 * 1024 * 1024, + }; + const result = useShell + ? spawnSync(`${command} ${args.join(' ')}`, options) + : spawnSync(command, args, options); + if (result.error || result.status !== 0) { + throw new Error(`${command} 执行失败。\n${result.stderr ?? ''}`); + } + return result; +} + +export function inputHashes(backend, frontend) { + const files = { + 'backend/go.mod': join(backend, 'go.mod'), + 'backend/go.sum': join(backend, 'go.sum'), + 'frontend/package.json': join(frontend, 'package.json'), + 'frontend/package-lock.json': join(frontend, 'package-lock.json'), + 'frontend/.npmrc': join(frontend, '.npmrc'), + }; + return Object.fromEntries(Object.entries(files).map(([name, path]) => [ + name, + createHash('sha256').update(readFileSync(path)).digest('hex'), + ])); +} + +export function installDependencies(frontend) { + // 不信任使用者的 npm 全局/用户设置,命令行参数显式覆盖;保留构建所需的可选平台包。 + const result = execute(npmCommand, [ + 'ci', '--ignore-scripts', '--engine-strict', '--include=dev', '--include=optional', + '--no-audit', '--no-fund', + ], frontend); + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); +} + +export function inspectToolchain(backend, frontend, install = false) { + const host = { platform: platform(), arch: arch(), release: release(), version: version() }; + validateEnvironment(process.env, host); + const pkg = JSON.parse(readFileSync(join(frontend, 'package.json'), 'utf8')); + const lock = JSON.parse(readFileSync(join(frontend, 'package-lock.json'), 'utf8')); + const npmrc = readFileSync(join(frontend, '.npmrc'), 'utf8'); + const actual = { node: process.versions.node, npm: execute(npmCommand, ['--version'], frontend).stdout.trim() }; + validateFrontend(pkg, lock, npmrc, actual); + const goEnv = { ...process.env, GOWORK: 'off' }; + const goVersion = execute('go', ['env', 'GOVERSION'], backend, goEnv).stdout.trim(); + validateGoVersion(readFileSync(join(backend, 'go.mod'), 'utf8'), goVersion); + const hashes = inputHashes(backend, frontend); + execute('go', ['mod', 'verify'], backend, goEnv); + if (install) installDependencies(frontend); + requireCondition(JSON.stringify(hashes) === JSON.stringify(inputHashes(backend, frontend)), '依赖安装改变了锁文件或版本策略,拒绝构建。'); + return { + node: actual.node, + npm: actual.npm, + go: goVersion, + host, + frontend_dependencies: { ...pkg.dependencies, ...pkg.devDependencies }, + dependency_scripts: 'disabled', + dependency_install: install ? 'npm-ci' : 'not-run', + input_sha256: hashes, + }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values } = parseArgs({ + options: { + backend: { type: 'string' }, + frontend: { type: 'string' }, + install: { type: 'boolean', default: false }, + }, + }); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const report = inspectToolchain( + resolve(values.backend ?? join(root, 'backend')), + resolve(values.frontend ?? join(root, 'frontend')), + values.install, + ); + process.stdout.write(`${JSON.stringify(report)}\n`); + } catch (error) { + console.error(`构建工具链检查失败:${error.message}`); + process.exitCode = 1; + } +} diff --git a/build/toolchain.test.mjs b/build/toolchain.test.mjs new file mode 100644 index 0000000..67c54f1 --- /dev/null +++ b/build/toolchain.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { installDependencies, npmEnvironment, validateEnvironment, validateFrontend, validateGoVersion } from './toolchain.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function fixture() { + const frontend = join(root, 'frontend'); + const pkg = JSON.parse(readFileSync(join(frontend, 'package.json'), 'utf8')); + return { + pkg, + lock: JSON.parse(readFileSync(join(frontend, 'package-lock.json'), 'utf8')), + npmrc: readFileSync(join(frontend, '.npmrc'), 'utf8'), + actual: { ...pkg.engines }, + }; +} + +function validate(value) { + validateFrontend(value.pkg, value.lock, value.npmrc, value.actual); +} + +test('仓库版本声明、锁文件和安装策略一致', () => { + validate(fixture()); +}); + +for (const [name, mutate] of Object.entries({ + '拒绝错误 Node 版本': (f) => { f.actual.node = '24.0.0'; }, + '拒绝错误 npm 版本': (f) => { f.actual.npm = '10.0.0'; }, + '拒绝工具链范围版本': (f) => { f.pkg.engines.node = '^24.19.0'; }, + '拒绝开发工具链警告降级': (f) => { f.pkg.devEngines.runtime.onFail = 'warn'; }, + '拒绝包管理器声明漂移': (f) => { f.pkg.packageManager = 'npm@10.0.0'; }, + '拒绝旧版锁文件': (f) => { f.lock.lockfileVersion = 2; }, + '拒绝锁文件工具链漂移': (f) => { f.lock.packages[''].engines.node = '24.0.0'; }, + '拒绝依赖范围版本': (f) => { f.pkg.dependencies.vue = '^3.5.41'; }, + '拒绝依赖锁定漂移': (f) => { f.lock.packages['node_modules/vue'].version = '3.5.0'; }, + '拒绝锁文件多余直接依赖': (f) => { f.lock.packages[''].dependencies.extra = '1.0.0'; }, + '拒绝前端标识漂移': (f) => { f.lock.name = 'other-project'; }, + '拒绝放行安装脚本': (f) => { f.npmrc = f.npmrc.replace('ignore-scripts=true', 'ignore-scripts=false'); }, + '拒绝重复安装策略': (f) => { f.npmrc += '\nignore-scripts=false\n'; }, + '拒绝关闭版本限制': (f) => { f.npmrc = f.npmrc.replace('engine-strict=true', 'engine-strict=false'); }, + '拒绝额外 npm 配置': (f) => { f.npmrc += '\nforce=true\n'; }, + '拒绝脚本白名单绕行': (f) => { f.pkg.allowScripts = { esbuild: true }; }, + '拒绝全量脚本放行': (f) => { f.pkg.dangerouslyAllowAllScripts = true; }, +})) { + test(name, () => { + const value = fixture(); + mutate(value); + assert.throws(() => validate(value)); + }); +} + +test('Go 工具链必须精确匹配模块基线', () => { + assert.equal(validateGoVersion('module test\n\ngo 1.26.6\n', 'go1.26.6'), '1.26.6'); + for (const version of ['go1.26.2', 'go1.26.7', 'devel go1.27', '']) { + assert.throws(() => validateGoVersion('go 1.26.6\n', version)); + } + assert.throws(() => validateGoVersion('go 1.26\n', 'go1.26.6')); +}); + +test('拒绝外部构建覆盖和未支持的构建机', () => { + const host = { platform: 'win32', arch: 'x64' }; + validateEnvironment({}, host); + validateEnvironment({ GOWORK: 'off' }, { ...host, platform: 'linux' }); + for (const env of [{ ESBUILD_BINARY_PATH: 'external' }, { GOWORK: 'go.work' }, { GOFLAGS: '-mod=mod' }]) { + assert.throws(() => validateEnvironment(env, host)); + } + assert.throws(() => validateEnvironment({}, { ...host, platform: 'darwin' })); + assert.throws(() => validateEnvironment({}, { ...host, arch: 'arm64' })); +}); + +test('发布入口在创建目录前检查并安装依赖,构建后复核版本文件', () => { + const script = readFileSync(join(root, 'build', 'build-release.ps1'), 'utf8'); + const install = script.indexOf('--frontend $frontend --install'); + assert.ok(install > 0 && install < script.indexOf('New-Item -ItemType Directory -Path $releaseDirectory')); + assert.match(script, /--ignore-scripts run build/); + assert.match(script, /go build -trimpath -mod=readonly/); + assert.match(script, /build_environment = \$toolchain/); + assert.match(script, /\$verifiedToolchain\.input_sha256/); +}); + +test('工具链命令实际拒绝外部二进制覆盖', () => { + const result = spawnSync(process.execPath, [join(root, 'build', 'toolchain.mjs'), '--install'], { + cwd: root, encoding: 'utf8', windowsHide: true, + env: { ...process.env, ESBUILD_BINARY_PATH: 'unreviewed-binary' }, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /禁止使用锁文件之外的 esbuild/); + assert.equal(result.stdout, ''); +}); + +test('嵌套 npm 安装不继承项目安装禁止的用户脚本白名单', () => { + const original = { npm_config_allow_scripts: 'fixture', NPM_CONFIG_ALLOW_SCRIPTS: 'fixture', PATH: 'retained' }; + assert.deepEqual(npmEnvironment(original), { PATH: 'retained' }); + assert.equal(original.npm_config_allow_scripts, 'fixture'); +}); + +test('发布脚本实际在工具链拒绝后不创建产物', { skip: process.platform !== 'win32' }, (t) => { + const directory = mkdtempSync(join(tmpdir(), 'scert-release-preflight-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const output = join(directory, 'release'); + const result = spawnSync('powershell.exe', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'build', 'build-release.ps1'), + '-OutputDirectory', output, + ], { + cwd: root, encoding: 'utf8', windowsHide: true, + env: { ...process.env, ESBUILD_BINARY_PATH: 'unreviewed-binary' }, + }); + assert.equal(result.status, 1, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /esbuild/); + assert.ok(!existsSync(output)); +}); + +test('真实离线安装禁止根项目及依赖脚本,命令行策略覆盖环境绕行', { timeout: 120000 }, (t) => { + const directory = mkdtempSync(join(tmpdir(), 'scert-install-policy-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const dependency = join(directory, 'dependency'); + mkdirSync(dependency); + const script = 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'yes\')"'; + writeFileSync(join(dependency, 'package.json'), JSON.stringify({ + name: 'scert-policy-fixture', version: '1.0.0', scripts: { postinstall: script }, + })); + writeFileSync(join(directory, 'package.json'), JSON.stringify({ + name: 'scert-policy-test', version: '1.0.0', private: true, + dependencies: { 'scert-policy-fixture': 'file:./dependency' }, + scripts: { postinstall: script }, + })); + writeFileSync(join(directory, '.npmrc'), 'ignore-scripts=true\n'); + const run = (args) => { + const options = { cwd: directory, encoding: 'utf8', windowsHide: true, env: npmEnvironment() }; + const result = process.platform === 'win32' + ? spawnSync(`${npmCommand} ${args.join(' ')}`, { ...options, shell: true }) + : spawnSync(npmCommand, args, options); + assert.equal(result.status, 0, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`); + }; + run(['install', '--package-lock-only', '--ignore-scripts', '--offline', '--no-audit', '--no-fund']); + const lock = readFileSync(join(directory, 'package-lock.json')); + const envKeys = ['npm_config_ignore_scripts', 'npm_config_dangerously_allow_all_scripts', 'npm_config_offline']; + const previous = envKeys.map((key) => process.env[key]); + try { + process.env.npm_config_ignore_scripts = 'false'; + process.env.npm_config_dangerously_allow_all_scripts = 'true'; + process.env.npm_config_offline = 'true'; + installDependencies(directory); + } finally { + envKeys.forEach((key, i) => { + if (previous[i] === undefined) delete process.env[key]; + else process.env[key] = previous[i]; + }); + } + assert.ok(existsSync(join(directory, 'node_modules', 'scert-policy-fixture', 'package.json'))); + assert.ok(!existsSync(join(directory, 'lifecycle-ran'))); + assert.ok(!existsSync(join(dependency, 'lifecycle-ran'))); + assert.deepEqual(readFileSync(join(directory, 'package-lock.json')), lock); + // 正向对照证明夹具确实会执行脚本,避免用未触发的脚本证明禁止策略。 + run(['ci', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--offline', '--no-audit', '--no-fund']); + assert.ok(existsSync(join(directory, 'lifecycle-ran'))); + assert.ok(existsSync(join(dependency, 'lifecycle-ran'))); +}); diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 0ded39e..6307552 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -38,7 +38,7 @@ | P0-01 | [x] | 明确产品范围 | 正式发布仅含 Web/服务端,桌面源码保留;CLI 已收敛且实际 Windows 产物验证通过 | 等待最终复审;Windows/Linux 实机安装仍归对应验收项 | 双平台归档及 SHA256、独立克隆、命令树及实际退出码 | | P0-02 | [x] | 建立完整任务清单 | 44 项逐项规格、原始编号名称、字段、六阶段、引用和汇总状态核对通过 | 等待最终复审,随实现同步状态和证据 | 四份规格、统一验收约定及 `fab98d1` 独立克隆 | | P0-03 | [x] | 整理源码、构建产物和部署产物 | 全仓库及子目录规则、目录说明、正式回归和独立克隆构建校验通过 | 等待最终复审,后续提交继续执行边界检查 | 正式 Go 回归、完整索引、双平台 33 项清单及解包校验 | -| P0-04 | [~] | 统一开发和构建版本 | 模块实际使用 Go `1.26.6`,不存在此前误报的版本阻塞;历史文档仍需统一 | 统一模块、构建说明、锁文件和验收机版本记录 | 版本矩阵、构建日志 | +| P0-04 | [~] | 统一开发和构建版本 | 精确工具链及直接依赖、禁止安装脚本、发布前后校验和构建机清单已实现,25 项专用回归及完整自动化通过 | 独立克隆复测;Linux 原生构建与双平台运行环境仍需记录 | 版本矩阵、175 项前端测试、后端完整回归、开发态双平台构建 | | P0-05 | [!] | 确认数据库正式支持矩阵 | 三种数据库均有代码,但 README、文档和实机证据口径不完全一致 | 明确 SQLite、MySQL、PostgreSQL 的正式级别、版本和迁移回滚要求 | 三数据库迁移、备份恢复和连接测试 | | P0-06 | [~] | 建立统一领域模型 | 已有域名、证书、任务、服务器等实体,项目和部署关联仍需审查 | 补齐资源关系图、外键/归属和任务关联 | 模型文档、迁移检查、接口测试 | | P0-07 | [~] | 增加项目级权限和资源隔离 | 用户组和角色已有实现,项目级资源边界未闭环 | 为证书、DNS、服务器、任务和审计补齐项目归属及越权测试 | 权限矩阵、接口越权测试 | diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 8cd5644..51f98cc 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -48,14 +48,14 @@ - 任务编号:P0-04;任务名称:统一开发和构建版本;优先级:P0。 - 当前状态:`[~] 进行中`。 -- 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[ ]`;最终验收 `[ ]`。 -- 问题说明:已有模块和锁文件版本,但文档、安装脚本批准策略、操作系统与构建机记录需统一。 -- 涉及文件:`backend/go.mod`、`backend/go.sum`、`frontend/package.json`、`frontend/package-lock.json`、`build/build-release.ps1`、《构建版本与数据库支持矩阵.md》。 +- 六阶段:需求确认 `[x]`;代码实现 `[x]`;自动化测试 `[x]`;隔离环境测试 `[~]`;生产部署测试 `[ ]`;最终验收 `[ ]`。 +- 问题说明:已有模块和锁文件版本;本批补齐精确前端工具链、禁用安装脚本、发布前后校验和构建机记录,仍需独立克隆与运行环境证据。 +- 涉及文件:`backend/go.mod`、`backend/go.sum`、`frontend/package.json`、`frontend/package-lock.json`、`frontend/.npmrc`、`build/build-release.ps1`、`build/toolchain.mjs`、`build/toolchain.test.mjs`、《构建版本与数据库支持矩阵.md》。 - 依赖任务:P0-01、P0-03。 - 整改内容:记录并固定 Go、Node、npm、Vue、驱动和依赖版本、系统版本、编译目标;检查锁文件安装及构建环境差异。模块内实际 Go 版本优先,不按根目录工具版本降级模块。 - 验证命令:V02、V03、V04;模块内 `go version`、`go env GOVERSION GOTOOLCHAIN`;`node --version`、`npm.cmd --version`。 - 实机验收步骤:全新构建环境按固定版本安装依赖;验证锁文件不漂移;在双平台验收机记录系统版本和运行包版本,比较 manifest。 -- 验收证据:已有 Go 1.26.6 与前端锁文件基线、独立克隆构建;安装脚本策略和完整系统矩阵尚缺。 +- 验收证据:Go 1.26.6、Node 24.19.0、npm 11.17.0 精确检查;前端 175 项通过,其中 25 项工具链回归;真实离线安装正反对照证明脚本策略生效;后端全量测试、vet、模块校验通过,开发态双平台构建通过,独立克隆结果待补。 - 复审结果:未完成,不以一次构建通过代表可重复发布。 ## P0-05 确认数据库正式支持矩阵 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index 0d3fd5e..63d4870 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -8,9 +8,9 @@ |---|---|---|---| | Go | `backend/go.mod`、`backend/go.sum` | 模块内 `go1.26.6 windows/amd64` | 在模块内记录实际版本,不使用仓库根目录的版本推断 | | 工具链选择 | `go env GOTOOLCHAIN` | `auto` | 自动选择仍必须与模块要求一致,不得降低版本绕过失败 | -| Node.js | 本机 `node --version` | `v24.19.0` | 当前验证基线;后续升级需重跑测试和构建 | -| npm | 本机 `npm.cmd --version` | `11.17.0` | 使用锁文件安装;Windows 使用 `npm.cmd` | -| Vue | `frontend/package-lock.json` | `3.5.41` | 发布以锁文件为准,不以 package.json 的范围猜测实际版本 | +| Node.js | `frontend/package.json` 的 `engines`、`devEngines`,锁文件 | `v24.19.0` | 精确匹配,升级需同步声明并重跑测试和构建 | +| npm | 同上,另有 `packageManager` | `11.17.0` | 精确匹配,使用锁文件安装;Windows 使用 `npm.cmd` | +| Vue | `frontend/package.json`、`frontend/package-lock.json` | `3.5.41` | 直接依赖固定精确版本,声明及锁文件必须一致 | | Vite | 同上 | `6.4.3` | 同上 | | Vue 构建插件 | 同上 | `5.2.4` | 同上 | | SQLite Go 驱动 | `backend/go.mod` | `modernc.org/sqlite v1.37.0` | 驱动版本不等于数据库引擎版本 | @@ -35,7 +35,20 @@ npm.cmd test npm.cmd run build ``` -本轮未重新执行 `npm.cmd ci`;已执行现有依赖下的前端测试、构建和生产依赖审计。发布前仍需在独立干净环境按锁文件安装,记录操作系统实际版本和发布清单。 +历史基线只验证过现有依赖;随后 P0-03 已在 `fab98d1` 独立克隆重新执行 `npm ci`、150 项前端测试和双平台候选构建。该次安装仍有脚本批准策略警告,不能证明安装脚本被禁止。P0-04 改为明确禁用依赖安装脚本,执行证据持续记录在《Scert整改任务执行清单.md》,不能将旧安装结果当作新策略验证。 + +### 构建机与安装策略 + +| 项目 | 固定基线或约束 | 验收边界 | +|---|---|---| +| 当前 Windows 构建机 | Windows 11 Home China,系统 `10.0.26200`,amd64;Windows PowerShell 5.1 | 记录实际值,不把系统显示名称误写成内核版本;系统更新后需重新验收 | +| Linux 原生构建机 | 脚本允许 Linux amd64,但尚无本批指定发行版、内核、PowerShell 版本的原生构建证据 | 不宣称 Linux 原生可重复构建已通过;Linux 候选包由 Windows 交叉编译 | +| 目标平台 | `windows/amd64`、`linux/amd64`,`CGO_ENABLED=0`、`-trimpath`、`-mod=readonly` | 构建机版本与生产运行系统分别记录 | +| 安装脚本 | `.npmrc` 固定 `ignore-scripts=true`,正式入口再显式传参 | npm 未审查警告不是安全保证,禁止白名单和全量放行 | +| 可选平台包 | `npm ci` 显式包含开发和可选依赖 | esbuild 不执行安装脚本,使用锁文件中的平台包;外部二进制覆盖被拒绝 | +| 发布追溯 | `manifest.json` 的 `build_environment` | 包含实际工具链、系统、直接前端依赖、安装策略和五份输入文件 SHA256;Go 驱动及依赖由模块文件哈希追溯 | + +正式发布入口自动校验工具链、核验 Go 模块缓存、按锁文件干净安装,并在构建结束后复核输入文件。环境不符时尽早失败;构建中版本文件漂移则不生成发布清单。`toolchain.mjs` 和配套测试属于正式构建代码,不作为替代真实部署的辅助验收脚本。具体流程见 `build/README.md`。 ## 二、数据库支持矩阵 diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..dee2451 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,3 @@ +engine-strict=true +ignore-scripts=true +save-exact=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bf49b75..acad7e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,15 @@ "name": "scert-frontend", "version": "0.1.0", "dependencies": { - "vue": "^3.5.0" + "vue": "3.5.41" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.0", - "vite": "^6.0.0" + "@vitejs/plugin-vue": "5.2.4", + "vite": "6.4.3" + }, + "engines": { + "node": "24.19.0", + "npm": "11.17.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/frontend/package.json b/frontend/package.json index 4476223..b78870c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,17 +3,34 @@ "private": true, "version": "0.1.0", "type": "module", + "packageManager": "npm@11.17.0", + "engines": { + "node": "24.19.0", + "npm": "11.17.0" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": "24.19.0", + "onFail": "error" + }, + "packageManager": { + "name": "npm", + "version": "11.17.0", + "onFail": "error" + } + }, "scripts": { - "test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js", + "test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js", "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { - "vue": "^3.5.0" + "vue": "3.5.41" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.0", - "vite": "^6.0.0" + "@vitejs/plugin-vue": "5.2.4", + "vite": "6.4.3" } }