test(desktop): qualify signing concurrency with release files
This commit is contained in:
1 parent
4db4345865
commit
f5caf31fb5
10 files changed
+298
-48
No files matched your search
@@ -0,0 +1,52 @@
|
||||
/** Snapshot operator-selected release files into unsigned, hash-pinned experiment inputs. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, readFile } from 'node:fs/promises'
|
||||
import { extname, isAbsolute, join } from 'node:path'
|
||||
import { inspectWindowsRuntimeSignature } from '../../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
/** One immutable unsigned input; its source is never modified or executed. */
|
||||
export interface SigningCorpusFile {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a nonempty JSON array of absolute paths and SHA-256 digests, then strip signatures only on copies.
|
||||
* @param manifestPath Operator-owned JSON manifest; every source must be a regular file with the pinned digest.
|
||||
* @param directory New private directory for retained unsigned templates.
|
||||
* @param removeSignature Public SignTool removal operation; never receives an original source path.
|
||||
* @param signal Stops preparation before the next file when aborted.
|
||||
* @returns Unsigned template digests and sizes in manifest order; rejects invalid signatures or changed inputs.
|
||||
*/
|
||||
export async function prepareSigningCorpus(
|
||||
manifestPath: string, directory: string, removeSignature: (path: string) => Promise<void>, signal: AbortSignal,
|
||||
): Promise<SigningCorpusFile[]> {
|
||||
const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('Signing corpus must be a nonempty JSON array')
|
||||
const inputs = manifest.map((entry: unknown) => {
|
||||
if (typeof entry !== 'object' || entry === null || !('path' in entry) || typeof entry.path !== 'string'
|
||||
|| !isAbsolute(entry.path) || !('sha256' in entry) || typeof entry.sha256 !== 'string'
|
||||
|| !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error('Signing corpus requires absolute paths and lowercase SHA-256 digests')
|
||||
return { path: entry.path, sha256: entry.sha256 }
|
||||
})
|
||||
await mkdir(directory)
|
||||
const result: SigningCorpusFile[] = []
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
signal.throwIfAborted()
|
||||
const stat = await lstat(input.path)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Signing corpus source must be a regular file')
|
||||
const path = join(directory, `${index}${extname(input.path) || '.exe'}`)
|
||||
await copyFile(input.path, path)
|
||||
if (createHash('sha256').update(await readFile(path)).digest('hex') !== input.sha256) {
|
||||
throw new Error('Signing corpus source differs from its pinned digest')
|
||||
}
|
||||
const before = await inspectWindowsRuntimeSignature(path)
|
||||
if (before.status === 'Valid') await removeSignature(path)
|
||||
else if (before.status !== 'NotSigned') throw new Error('Signing corpus source has an invalid signature')
|
||||
if ((await inspectWindowsRuntimeSignature(path)).status !== 'NotSigned') throw new Error('Signing corpus copy must be unsigned')
|
||||
const content = await readFile(path)
|
||||
result.push({ path, sha256: createHash('sha256').update(content).digest('hex'), bytes: content.length })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
/** Operations owned by one experiment, with independently bounded completion and hardware work. */
|
||||
export interface SigningExperimentOptions<T> {
|
||||
readonly jobs: 1 | 2
|
||||
readonly hardwareConcurrency: 1 | 2
|
||||
readonly jobs: 1 | 2 | 4
|
||||
readonly hardwareConcurrency: 1 | 2 | 4
|
||||
readonly signal?: AbortSignal
|
||||
/** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */
|
||||
readonly complete: (item: T, sign: (path: string) => Promise<void>) => Promise<void>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Operator-only comparison of serial signing, post-sign overlap and two concurrent hardware requests. */
|
||||
/** Operator-only comparison of serial signing and bounded concurrent signature completion. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash, X509Certificate } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir, cpus, totalmem } from 'node:os'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { homedir, cpus, totalmem, freemem } from 'node:os'
|
||||
import { extname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { parseArgs, promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
@@ -16,6 +16,7 @@ import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsS
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs'
|
||||
import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const MODES = [
|
||||
@@ -23,27 +24,39 @@ const MODES = [
|
||||
{ name: 'pipeline', jobs: 2, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
] as const
|
||||
const CORPUS_MODES = [
|
||||
{ name: 'serial', jobs: 1, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
{ name: 'hardware4', jobs: 4, hardwareConcurrency: 4 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Run an explicitly opted-in hardware experiment, retaining all generated probes and audit records.
|
||||
* Run an explicitly opted-in hardware experiment, retaining unsigned inputs, signed copies and audit records.
|
||||
* @param args CLI arguments; omission uses the current process arguments.
|
||||
* @returns Resolves after all requested batches verify; any failure retains the batch interlock and stops later modes.
|
||||
* @returns Resolves after all requested batches verify; a failure during a batch retains its interlock and stops later modes.
|
||||
*/
|
||||
export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise<void> {
|
||||
const { values } = parseArgs({ args, options: {
|
||||
execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' },
|
||||
corpus: { type: 'string' }, 'pipeline-control': { type: 'boolean' }, 'strip-tool': { type: 'string' },
|
||||
} })
|
||||
if (!values.execute) {
|
||||
console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-machine-packaging-directory> [--rounds 1|2]')
|
||||
console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-machine-packaging-directory> [--rounds 1|2] [--corpus <absolute-manifest.json>] [--strip-tool <absolute-signtool.exe>] [--pipeline-control]')
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root'])
|
||||
|| !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2')
|
||||
if (values.corpus !== undefined && !isAbsolute(values.corpus)) throw new Error('Signing corpus manifest must be an absolute path')
|
||||
if (values['pipeline-control'] && !values.corpus) throw new Error('Pipeline control requires a real-file corpus')
|
||||
if (values['strip-tool'] !== undefined && (!values.corpus || !isAbsolute(values['strip-tool']))) throw new Error('Signature stripping override requires a corpus and an absolute SignTool path')
|
||||
const modes = values['pipeline-control'] ? [{ name: 'pipeline4', jobs: 4, hardwareConcurrency: 1 } as const]
|
||||
: values.corpus ? CORPUS_MODES : MODES
|
||||
const environment = loadDesktopPackageEnvironment('win32', {
|
||||
...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'],
|
||||
})
|
||||
const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE!
|
||||
const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!
|
||||
const stripTool = values['strip-tool'] ?? signTool
|
||||
const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN!
|
||||
const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim()
|
||||
// Construction validates the same signing inputs; the production serial hook is never invoked here.
|
||||
@@ -60,7 +73,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv
|
||||
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), {
|
||||
kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'),
|
||||
node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(),
|
||||
rounds: Number(values.rounds), resourceBytes: [0, 1024 * 1024, 16 * 1024 * 1024], filesPerBatch: 6,
|
||||
rounds: Number(values.rounds), corpus: values.corpus ?? null, stripTool, modes,
|
||||
cache: 'disabled', probeExecuted: false,
|
||||
}, { parallel: true, secrets: [tokenPin, keyContainer] })
|
||||
console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`)
|
||||
@@ -78,31 +91,43 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv
|
||||
const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment })
|
||||
record({ type: 'experiment-public-identity', result: identity, pinSupplied: false })
|
||||
if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity))
|
||||
const templates: string[] = []
|
||||
const source = join(run.directory, 'probe.cs')
|
||||
await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' })
|
||||
for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) {
|
||||
controller.signal.throwIfAborted()
|
||||
const resource = join(run.directory, `resource-${size}.bin`)
|
||||
if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' })
|
||||
const output = join(run.directory, `unsigned-${size}.exe`)
|
||||
await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'),
|
||||
['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 })
|
||||
if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned')
|
||||
templates.push(output)
|
||||
let templates: string[] = []
|
||||
if (values.corpus) {
|
||||
let removalIndex = 0
|
||||
const corpus = await prepareSigningCorpus(values.corpus, join(run.directory, 'unsigned'), async (path) => {
|
||||
await run.run(`remove-signature-${removalIndex++}`, stripTool, ['remove', '/s', path],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 60_000 })
|
||||
}, controller.signal)
|
||||
await writeFile(join(run.directory, 'corpus.json'), `${JSON.stringify(corpus, null, 2)}\n`, { flag: 'wx' })
|
||||
templates = corpus.map(file => file.path)
|
||||
record({ type: 'experiment-corpus', files: corpus.length, bytes: corpus.reduce((sum, file) => sum + file.bytes, 0) })
|
||||
} else {
|
||||
const source = join(run.directory, 'probe.cs')
|
||||
await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' })
|
||||
for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) {
|
||||
controller.signal.throwIfAborted()
|
||||
const resource = join(run.directory, `resource-${size}.bin`)
|
||||
if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' })
|
||||
const output = join(run.directory, `unsigned-${size}.exe`)
|
||||
await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'),
|
||||
['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 })
|
||||
if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned')
|
||||
templates.push(output, output)
|
||||
}
|
||||
}
|
||||
const results: { round: number; mode: string; elapsedMs: number; files: number }[] = []
|
||||
for (let round = 1; round <= Number(values.rounds); round++) {
|
||||
const order = round % 2 ? MODES : [...MODES].reverse()
|
||||
const order = round % 2 ? modes : [...modes].reverse()
|
||||
for (const mode of order) {
|
||||
controller.signal.throwIfAborted()
|
||||
const directory = join(run.directory, `round-${round}-${mode.name}`)
|
||||
await mkdir(directory)
|
||||
const files: string[] = []
|
||||
for (let index = 0; index < templates.length * 2; index++) {
|
||||
const file = join(directory, `probe-${index}.exe`)
|
||||
await copyFile(templates[Math.floor(index / 2)]!, file)
|
||||
for (const [index, template] of templates.entries()) {
|
||||
const file = join(directory, `probe-${index}${extname(template)}`)
|
||||
await copyFile(template, file)
|
||||
if (Buffer.compare(await readFile(template), await readFile(file)) !== 0) throw new Error('Signing experiment batch copy differs from its template')
|
||||
files.push(file)
|
||||
}
|
||||
record({ type: 'experiment-batch-plan', round, ...mode, files })
|
||||
@@ -110,6 +135,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv
|
||||
let commandIndex = 0
|
||||
const started = performance.now()
|
||||
try {
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'start', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
await runSigningExperimentBatch(files, {
|
||||
...mode, signal: controller.signal,
|
||||
sign: async (_file, path) => {
|
||||
@@ -132,6 +158,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv
|
||||
record({ type: 'experiment-hardware-verified', stage, path })
|
||||
},
|
||||
complete: async (path, sign) => {
|
||||
const fileStarted = performance.now()
|
||||
await completeWindowsSignature(path, {
|
||||
thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record,
|
||||
normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment),
|
||||
@@ -145,12 +172,14 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
|
||||
throw new Error('Published probe signature verification failed')
|
||||
}
|
||||
record({ type: 'experiment-file-verified', path, ...signature,
|
||||
record({ type: 'experiment-file-verified', path, ...signature, elapsedMs: performance.now() - fileStarted,
|
||||
freeMemoryBytes: freemem(), processMemory: process.memoryUsage(),
|
||||
sha256: createHash('sha256').update(await readFile(path)).digest('hex') })
|
||||
},
|
||||
})
|
||||
controller.signal.throwIfAborted()
|
||||
const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length }
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'end', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
results.push(result)
|
||||
record({ type: 'experiment-batch-complete', ...result })
|
||||
await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true })
|
||||
|
||||
@@ -9,12 +9,14 @@ import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-enviro
|
||||
import { createPackagingRun } from '../scripts/packaging-run.mjs'
|
||||
import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs'
|
||||
import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
root: '', environment: {} as NodeJS.ProcessEnv,
|
||||
identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit',
|
||||
signed: new Set<string>(), completed: new Set<string>(), stages: [] as string[],
|
||||
events: [] as object[], successes: 0, failures: 0,
|
||||
removals: [] as { executable: string; args: readonly string[]; environment: NodeJS.ProcessEnv }[],
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
@@ -40,6 +42,17 @@ vi.mock('../scripts/windows-token-identity.ts', () => ({
|
||||
inspectWindowsTokenIdentity: vi.fn(async () => state.identity),
|
||||
windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`,
|
||||
}))
|
||||
vi.mock('./helpers/windows-signing-corpus.ts', () => ({
|
||||
prepareSigningCorpus: vi.fn(async () => {
|
||||
const files = []
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const path = join(state.root, `real-${index}.dll`)
|
||||
await writeFile(path, 'inert real file')
|
||||
files.push({ path, bytes: 15, sha256: 'a'.repeat(64) })
|
||||
}
|
||||
return files
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
@@ -47,10 +60,12 @@ vi.mock('../scripts/packaging-run.mjs', () => ({
|
||||
createPackagingRun: vi.fn(() => ({
|
||||
directory: join(state.root, 'run'),
|
||||
finish: vi.fn(),
|
||||
run: vi.fn(async (stage: string, _executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
run: vi.fn(async (stage: string, executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
state.stages.push(stage)
|
||||
if (stage.startsWith('compile-')) {
|
||||
await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe')
|
||||
} else if (stage.startsWith('remove-signature-')) {
|
||||
state.removals.push({ executable, args, environment: options.env })
|
||||
} else {
|
||||
state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!)
|
||||
}
|
||||
@@ -102,7 +117,7 @@ beforeEach(async () => {
|
||||
state.root = await mkdtemp(join(tmpdir(), 'signing-driver-'))
|
||||
mkdirSync(join(state.root, 'run'))
|
||||
state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0
|
||||
state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []
|
||||
state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []; state.removals = []
|
||||
state.environment = {
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'),
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
|
||||
@@ -166,6 +181,48 @@ it('completes all three modes and releases each default-account batch only after
|
||||
expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u)
|
||||
})
|
||||
|
||||
it('uses fresh real-file copies for 1, 2, 4 workers and reverses the second round', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])
|
||||
expect(state.successes).toBe(6)
|
||||
expect(state.signed.size).toBe(36)
|
||||
expect(state.stages.some(stage => stage.startsWith('compile-'))).toBe(false)
|
||||
expect(prepareSigningCorpus).toHaveBeenCalledOnce()
|
||||
expect(state.events.filter(event => 'type' in event && event.type === 'experiment-batch-plan')).toEqual([
|
||||
expect.objectContaining({ round: 1, jobs: 1, hardwareConcurrency: 1 }),
|
||||
expect.objectContaining({ round: 1, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 1, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 2, jobs: 1, hardwareConcurrency: 1 }),
|
||||
])
|
||||
expect(existsSync(lock())).toBe(false)
|
||||
})
|
||||
|
||||
it('offers a separate four-job control with only one hardware request', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1', '--corpus', join(state.root, 'manifest.json'), '--pipeline-control'])
|
||||
expect(state.successes).toBe(1)
|
||||
expect(state.events).toContainEqual(expect.objectContaining({ type: 'experiment-batch-plan', jobs: 4, hardwareConcurrency: 1 }))
|
||||
})
|
||||
|
||||
it('refuses corpus preparation failure before beginning a hardware attempt', async () => {
|
||||
vi.mocked(prepareSigningCorpus).mockRejectedValueOnce(new Error('changed input'))
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])).rejects.toThrow('changed input')
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
expect(state.signed.size).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the stripping override only for public removal on a corpus copy', async () => {
|
||||
const copy = join(state.root, 'copy.exe')
|
||||
const stripTool = join(state.root, 'sdk-signtool.exe')
|
||||
vi.mocked(prepareSigningCorpus).mockImplementationOnce(async (_manifest, _directory, remove) => {
|
||||
await remove(copy)
|
||||
throw new Error('stop before hardware')
|
||||
})
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json'), '--strip-tool', stripTool])).rejects.toThrow('stop before hardware')
|
||||
expect(state.removals).toEqual([{ executable: stripTool, args: ['remove', '/s', copy], environment: {} }])
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => {
|
||||
state.failure = failure
|
||||
await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed')
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Corpus preparation touches only private copies; public signature inspection is simulated. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ inspectWindowsRuntimeSignature: vi.fn() }))
|
||||
let root: string
|
||||
let source: string
|
||||
let manifest: string
|
||||
const digest = (data: Buffer | string) => createHash('sha256').update(data).digest('hex')
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks()
|
||||
root = await mkdtemp(join(tmpdir(), 'signing-corpus-'))
|
||||
source = join(root, 'original.dll')
|
||||
manifest = join(root, 'manifest.json')
|
||||
await writeFile(source, 'signed original')
|
||||
await writeFile(manifest, JSON.stringify([{ path: source, sha256: digest('signed original') }]))
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'NotSigned', timestamped: false, thumbprint: null })
|
||||
})
|
||||
afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }) })
|
||||
|
||||
it('strips only a pinned private copy and preserves the original bytes', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
const remove = vi.fn(async (path: string) => { expect(path).not.toBe(source); await writeFile(path, 'unsigned copy') })
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(files).toEqual([{ path: join(root, 'copies', '0.dll'), sha256: digest('unsigned copy'), bytes: 13 }])
|
||||
expect(await readFile(source, 'utf8')).toBe('signed original')
|
||||
})
|
||||
|
||||
it('accepts already unsigned input without invoking removal', async () => {
|
||||
const remove = vi.fn()
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(files[0]?.sha256).toBe(digest('signed original'))
|
||||
})
|
||||
|
||||
it.each([[], {}, [{ path: 'relative', sha256: 'a'.repeat(64) }], [{ path: 'ignored', sha256: 'invalid' }]])('rejects malformed manifest %j', async (value) => {
|
||||
await writeFile(manifest, JSON.stringify(value))
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('Signing corpus')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects source content changed since the manifest was created', async () => {
|
||||
await writeFile(source, 'changed source')
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('pinned digest')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['HashMismatch', 'NotTrusted'])('rejects invalid source signature %s without removal', async (status) => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status, timestamped: false, thumbprint: null })
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('invalid signature')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a copy whose signature removal did not finish', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('must be unsigned')
|
||||
})
|
||||
|
||||
it('stops before copying after cancellation', async () => {
|
||||
const controller = new AbortController(); controller.abort(new Error('cancelled'))
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), controller.signal)).rejects.toThrow('cancelled')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -9,6 +9,36 @@ const observe = (operation: Promise<void>) => operation.then(
|
||||
() => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
it('drains four issued hardware calls after failure without dispatching a fifth', async () => {
|
||||
const allStarted = barrier()
|
||||
const release = barrier()
|
||||
const failed = barrier()
|
||||
const started: number[] = []
|
||||
const finished: number[] = []
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3, 4, 5, 6], {
|
||||
jobs: 4, hardwareConcurrency: 4,
|
||||
sign: async (item) => {
|
||||
started.push(item)
|
||||
if (started.length === 4) allStarted.resolve()
|
||||
await allStarted.promise
|
||||
if (item === 1) { failed.resolve(); throw new Error('hardware failure') }
|
||||
await release.promise
|
||||
finished.push(item)
|
||||
},
|
||||
complete: async (item, sign) => { await sign(`staged-${item}`) },
|
||||
}))
|
||||
let settled = false
|
||||
void outcome.then(() => { settled = true })
|
||||
try {
|
||||
await failed.promise
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
expect(settled).toBe(false)
|
||||
} finally { release.resolve() }
|
||||
expect(await outcome).toEqual({ ok: false, error: new Error('hardware failure') })
|
||||
expect(finished.sort()).toEqual([2, 3, 4])
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('finishes each item before beginning the next in serial mode', async () => {
|
||||
const events: string[] = []
|
||||
await runSigningExperimentBatch([1, 2], {
|
||||
@@ -66,28 +96,28 @@ it('overlaps completion with the next signature while keeping hardware concurren
|
||||
expect(started).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('allows two hardware requests but does not start a third item while both jobs are occupied', async () => {
|
||||
it.each([2, 4] as const)('allows %i hardware requests but keeps later items queued until a job completes', async (limit) => {
|
||||
const bothSigning = barrier()
|
||||
const release = barrier()
|
||||
const started: number[] = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 2,
|
||||
const outcome = observe(runSigningExperimentBatch(Array.from({ length: limit + 1 }, (_, index) => index + 1), {
|
||||
jobs: limit, hardwareConcurrency: limit,
|
||||
sign: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
if (active === 2) bothSigning.resolve()
|
||||
if (active === limit) bothSigning.resolve()
|
||||
try { await release.promise } finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) },
|
||||
}))
|
||||
try {
|
||||
await bothSigning.promise
|
||||
expect(started).toEqual([1, 2])
|
||||
expect(active).toBe(2)
|
||||
expect(started).toEqual(Array.from({ length: limit }, (_, index) => index + 1))
|
||||
expect(active).toBe(limit)
|
||||
} finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) }
|
||||
expect(peak).toBe(2)
|
||||
expect(peak).toBe(limit)
|
||||
expect(active).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 6e8208d19ad510e5
|
||||
zh: eee42e8938c0f603
|
||||
/windows-signing-and-runner-operations/verification-limits:
|
||||
en: e04b54cec106c2b7
|
||||
zh: 1941e4b0f0249c20
|
||||
en: cd4f2e91bb174352
|
||||
zh: 02b9db618b5aba74
|
||||
/windows-signing-and-runner-operations/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -106,7 +106,11 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st
|
||||
|
||||
Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy.
|
||||
|
||||
For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-packaging-directory> --rounds 2`. Without `--execute`, it only prints usage. The diagnostic compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests, using fresh copies of six synthetic PE files per batch; the second round reverses mode order. It uses the machine test preset, reads public identity first, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. It stops dispatch on the first failure and drains issued work; up to two hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Records and probes remain under `.desktop-build/qualification/signing-concurrency`; probes are never executed and no release cache is used. Timings exclude probe preparation, lock waiting and package assembly. This experiment does not enable production concurrency or establish full-release performance.
|
||||
For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-packaging-directory> --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order.
|
||||
|
||||
Add `--corpus <absolute-manifest.json>` to compare 1, 2 and 4 workers on real release files. The manifest is a nonempty JSON array of objects with an absolute `path` and its lowercase hexadecimal `sha256`. Preparation copies each pinned file, verifies its signature and removes any valid signature from the copy; invalid signatures or changed bytes stop preparation. Signature removal requires SignTool with `remove /s`; `--strip-tool <absolute-signtool.exe>` overrides only that public preparation tool when the configured version lacks support. Hardware signing and timestamping still use the machine preset. Every batch uses fresh, byte-checked copies of the unsigned templates, preserving manifest order. `--pipeline-control` with a corpus instead runs four completion workers with one hardware request. Original files and release caches remain unchanged.
|
||||
|
||||
The experiment uses the machine test preset, checks public identity, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. The first failure stops dispatch and drains issued work; up to four hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Files are never executed. Records remain under `.desktop-build/qualification/signing-concurrency`, including batch and per-file elapsed times, batch endpoint host CPU counters, free memory and driver-process memory samples. These memory samples exclude child processes and do not establish peak memory. Timings exclude input preparation, lock waiting and package assembly. Replaying ready-made artifacts also excludes build dependencies between them; the experiment does not enable production concurrency or establish full-release performance.
|
||||
|
||||
## Dev Note
|
||||
|
||||
|
||||
@@ -106,7 +106,11 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK
|
||||
|
||||
真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。
|
||||
|
||||
经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。诊断比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名;每批使用六个合成 PE 文件的全新副本,第二轮反转模式顺序。实验读取机器 test preset,先核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有两条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。记录与探针保留在 `.desktop-build/qualification/signing-concurrency`,探针从不执行,也不使用发布签名缓存。计时不含探针准备、等锁及包组装,实验不启用生产并发,也不代表完整发布性能。
|
||||
经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。
|
||||
|
||||
加上 `--corpus <清单 JSON 的绝对路径>` 后,改用真实发布文件比较 1、2、4 个并发任务。清单是非空 JSON 数组,每项包含绝对路径 `path` 及对应的小写十六进制 `sha256`。准备阶段复制哈希固定的文件,验证签名并仅移除副本上的有效签名;签名无效或字节变化会中止准备。移除签名要求 SignTool 支持 `remove /s`;配置中的版本不支持时,可用 `--strip-tool <signtool.exe 的绝对路径>` 仅覆盖这个公开准备工具。硬件签名和时间戳仍使用机器 preset。每批使用经过字节比对的全新无签名模板副本,保持清单顺序。配合语料使用 `--pipeline-control` 时,改为四个完成任务共用一个硬件请求。原文件和发布缓存保持不变。
|
||||
|
||||
实验读取机器 test preset,核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有四条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。文件从不执行。记录保留在 `.desktop-build/qualification/signing-concurrency`,包含整批及单文件耗时、批次起止时的主机 CPU 计数、可用内存和驱动进程内存采样。内存采样不含子进程,不能用于确定峰值内存。计时不含输入准备、等锁和包组装。重放现成产物还排除了它们之间的构建依赖;实验不启用生产并发,也不代表完整发布性能。
|
||||
|
||||
## 开发备注
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { parse, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
@@ -202,35 +202,38 @@ it('scopes the optional temporary directory to installer building without changi
|
||||
it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-'))
|
||||
try {
|
||||
const workspace = resolve(directory, 'runner', 'repo')
|
||||
const packageCache = resolve(directory, 'tool-cache', 'desktop')
|
||||
const store = resolve(parse(directory).root, '.pnpm-store')
|
||||
const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')!
|
||||
expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies'))
|
||||
const script = resolve(directory, 'store.ps1')
|
||||
writeFileSync(script, step.run!)
|
||||
writeFileSync(script, "$ErrorActionPreference = 'Stop'\n" + step.run!)
|
||||
const outputs = ['first', 'second'].map((run) => {
|
||||
const environmentFile = resolve(directory, run + '.env')
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo',
|
||||
PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop',
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache,
|
||||
PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(environmentFile, 'utf8').trim()
|
||||
})
|
||||
expect(outputs.map(output => output.split(/\r?\n/u))).toEqual(Array.from({ length: 2 }, () => [
|
||||
'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store',
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${packageCache}`, `PNPM_CONFIG_STORE_DIR=${store}`,
|
||||
]))
|
||||
const runNamespace = (namespace: string) => {
|
||||
const output = resolve(directory, 'namespace.env')
|
||||
rmSync(output, { force: true })
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo',
|
||||
PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop', GITHUB_ENV: output },
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache, GITHUB_ENV: output },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(output, 'utf8').trim().split(/\r?\n/u)
|
||||
}
|
||||
expect(runNamespace('perf-1')).toEqual([
|
||||
'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop\\perf-1', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store\\perf-1',
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${resolve(packageCache, 'perf-1')}`, `PNPM_CONFIG_STORE_DIR=${resolve(store, 'perf-1')}`,
|
||||
])
|
||||
for (const invalid of ['../escape', 'Upper', 'has space', 'con', 'perf\n', 'a'.repeat(65)]) {
|
||||
expect(() => runNamespace(invalid)).toThrow()
|
||||
|
||||
Reference in new issue
Block a user