Merge remote-tracking branch 'origin/master' into fix/windows-mandatory-update-overlay

This commit is contained in:
winewill committed 2026-09-20 15:59:42 +08:00
commit 1e20aecef5
20 files changed
+220 -22

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md
2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9
2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad
2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d
2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2
@@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex
Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses.
The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
## Alternatives considered
@@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
## Alternatives considered
+3
View File
@@ -20,6 +20,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1.
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Optional upload configuration; packaging does not require these credentials.
# DOWNLOAD_TEST_COS_BUCKET=
# DOWNLOAD_TEST_COS_SECRET_ID=
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 4c7087d4f7bdcf7ef039837113a4f2aebabc5843
README.zh.md: 5812c7f4cf526ebb97b1c66553ee7275526dcfeb
README.md: d70f8895c54623812b175ec2f6b316915fe29514
README.zh.md: 78f132baa394020376ad43a1c8ac54482a65c476
+3 -1
View File
@@ -232,7 +232,9 @@ Runtime signing shares complete signed files across the current Windows account'
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately.
Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required.
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time.
Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from <absolute-old-cache>` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory <absolute-cache>` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly.
+3 -1
View File
@@ -232,7 +232,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。
缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。
使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from <absolute-old-cache>` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory <absolute-cache>` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。
@@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
@@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options =
resolveDesktopAppId(environment)
resolveDesktopPolicyEnvironment(environment)
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
else resolveWindowsPackageSettings(environment)
if (options.unsigned) return
if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
if (target.platform === 'win32') {
+1
View File
@@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
+4 -1
View File
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
@@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
async function main(): Promise<void> {
if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64')
const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env)
const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run')
await readFile(join(runDirectory, 'run.json'))
@@ -67,7 +69,8 @@ async function main(): Promise<void> {
await prepareWindowsSignatureCacheDirectory(cacheRoot)
const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint,
sign, inspect: inspectWindowsRuntimeSignature, record })
const options = { thumbprint, sign: cachedSign, record }
const options = { thumbprint, sign: cachedSign, record,
cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } }
record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity })
try {
if (process.argv.includes('--dsh')) {
@@ -0,0 +1,6 @@
/**
* Validate the file-owned Windows cache worker limit before packaging starts.
* @param environment Windows packaging settings loaded from .env.windows.
* @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw.
*/
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number }
@@ -0,0 +1,14 @@
/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */
/**
* Validate the cache worker limit without accessing signing hardware.
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
* @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted.
*/
export function resolveWindowsPackageSettings(environment) {
const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4'
if (!/^[1-8]$/u.test(value)) {
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8')
}
return { signatureCacheConcurrency: Number(value) }
}
@@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions {
sign: ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>
inspect?: typeof inspectWindowsRuntimeSignature
record: (event: object) => void
/** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */
cache?: {
restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore']
concurrency: number
}
}
/**
@@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise<string[]>
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param root Materialized directory to sign.
* @param options Signer, certificate identity and audit sink.
* @returns Resolves after verified sequential signing; rejects without retries.
* @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries.
*/
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
@@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) {
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param {string} root - Owned final runtime directory.
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
* @returns {Promise<void>} Resolves only after sequential signatures and verification; no retries.
* @returns {Promise<void>} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting.
*/
export async function signWindowsCode(root, options) {
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
@@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) {
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
}
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
for (const path of unsigned) {
await options.sign({ path, hash: 'sha256', isNest: false })
async function verifySigned(path) {
const signature = await inspect(path)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error(`Windows code: signing verification failed: ${path}`)
}
options.record({ type: 'windows-code-signature-verified', path, ...signature })
}
const restored = new Set()
if (options.cache) {
let next = 0
let failure
const worker = async () => {
while (failure === undefined && next < unsigned.length) {
const path = unsigned[next++]
try {
if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) {
await verifySigned(path)
restored.add(path)
}
} catch (error) { failure ??= { error } }
}
}
await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker))
if (failure !== undefined) throw failure.error
}
for (const path of unsigned) {
if (restored.has(path)) continue
await options.sign({ path, hash: 'sha256', isNest: false })
await verifySigned(path)
}
}
/**
@@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions {
*/
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */
/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */
export interface WindowsSignatureCacheSummary {
root: string
identity: string
@@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary {
signingMs: number
}
/** Serial cache callback with a snapshot of this instance's completed and failed operations. */
/** Serial signing callback with a separate hardware-free restore operation and shared counters. */
export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> & {
/**
* Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock.
* @param configuration Unsigned target owned exclusively by the caller.
* @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure.
*/
restore(configuration: Parameters<ReturnType<typeof createWindowsTokenSigner>>[0]): Promise<boolean>
/** @returns Current counters and elapsed times; does not wait for queued operations. */
summary(): WindowsSignatureCacheSummary
}
@@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> &
/**
* Reuse complete signed files only after input, integrity, identity and timestamp checks.
* @param options Cache policy, supervised signer, public-key verifier and audit sink.
* @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries.
* @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired.
*/
export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner
@@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) {
/**
* Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy.
* @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp.
*/
export function createCachedSigner(options) {
let pending = Promise.resolve()
@@ -142,7 +142,7 @@ export function createCachedSigner(options) {
if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record')
return record
}
async function sign(configuration) {
async function sign(configuration, restoreOnly = false) {
if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported')
const inputDigest = digest(await regularFile(configuration.path))
const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity }))
@@ -173,8 +173,9 @@ export function createCachedSigner(options) {
} finally { statistics.restoreMs += performance.now() - start }
statistics.hits++
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
return
return true
}
if (restoreOnly) return false
statistics.misses++
options.record({ type: 'signature-cache-miss', key, path: configuration.path })
const start = performance.now()
@@ -210,9 +211,10 @@ export function createCachedSigner(options) {
} finally { await rm(staging, { recursive: true, force: true }) }
}
const signer = configuration => {
pending = pending.then(() => sign(configuration))
pending = pending.then(async () => { await sign(configuration) })
return pending
}
signer.restore = configuration => sign(configuration, true)
signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits })
return signer
}
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs'
const WINDOWS = { platform: 'win32', arch: 'x64' } as const
const MACOS = { platform: 'darwin', arch: 'arm64' } as const
@@ -21,6 +22,30 @@ async function withDirectory(action: (directory: string) => Promise<void>): Prom
}
describe('Desktop local packaging configuration', () => {
it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => {
await withDirectory(async (directory) => {
const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' }
await writeFile(join(directory, '.env.windows'), '')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4)
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2)
})
})
it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => {
const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })
expect(settings.signatureCacheConcurrency).toBe(Number(value))
})
it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => {
expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8')
for (const options of [{ unsigned: true }, { prepareOnly: true }]) {
expect(() => {
validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options)
}).toThrow('integer from 1 to 8')
}
})
it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => {
await withDirectory(async (directory) => {
await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n')
@@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() })
const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' }
function supervisor(failure?: string) {
vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs')
@@ -56,6 +57,10 @@ it('requires one signing preflight before building, then records only the comple
for (const call of run.run.mock.calls) {
if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) {
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY')
}
if (call[0].startsWith('run sign:primary-runtime')) {
expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2')
}
}
expect(writeFileSync).toHaveBeenCalledOnce()
@@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise<string
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const signed = new Set<string>()
const verified = new Set<string>()
let active = 0
let peak = 0
let hits = 0
const sign = vi.fn(async ({ path }: { path: string }) => {
expect(active).toBe(0)
expect(hits).toBe(2)
expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true)
signed.add(path)
})
const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign,
inspect: async (path) => {
if (!signed.has(path)) return unsigned
verified.add(path)
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
active++
peak = Math.max(peak, active)
if (active === 2) entered.resolve(undefined)
await release.promise
active--
if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true }
return false
} },
})
try {
await Promise.race([entered.promise, operation])
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(peak).toBe(2)
expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name)))
})
it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const failed = Promise.withResolvers<undefined>()
const restored = new Set<string>()
const calls: string[] = []
const sign = vi.fn(async () => {})
let settled = false
const operation = signWindowsCode(root, { thumbprint, sign,
record: (event) => {
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') {
failed.resolve(undefined)
throw new Error('audit failed')
}
},
inspect: async (path) => {
if (!restored.has(path)) return unsigned
if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') }
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
calls.push(path)
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false }
await entered.promise
if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') }
restored.add(path)
return true
} },
}).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
try {
await Promise.race([failed.promise, operation])
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(await operation).toEqual(new Error(`${failure} failed`))
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
expect(sign).not.toHaveBeenCalled()
})
it('selects real PE code, including .node, without signing foreign native modules or data', async () => {
const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary'])
await mkdir(join(root, 'nested'))
@@ -23,6 +23,22 @@ async function fixture(t: TestContext) {
return { root, path, cache, state, options, request }
}
test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => {
const f = await fixture(t)
const signer = createCachedSigner(f.options)
assert.equal(await signer.restore(f.request), false)
assert.equal(f.state.hardwareCalls, 0)
assert.equal(signer.summary().misses, 0)
assert.equal(await readFile(f.path, 'utf8'), 'original')
await signer(f.request)
const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')]
await Promise.all(targets.map(path => writeFile(path, 'original')))
assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true])
assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original'])
assert.equal(f.state.hardwareCalls, 1)
assert.equal(signer.summary().hits, 2)
})
test('unchanged bytes restored in another file avoid another hardware call', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
@@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => {
assert.equal(f.state.hardwareCalls, 3)
})
for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => {
for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
@@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops
assert(entry)
await writeFile(join(f.cache, entry, corrupt), 'corrupt')
const signer = createCachedSigner(f.options)
await assert.rejects(signer(f.request))
await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request))
assert.equal(signer.summary().validationFailures, 1)
assert.equal(signer.summary().avoidedSigningCalls, 0)
assert.equal(f.state.hardwareCalls, 1)