Files
hermes-agent/apps/desktop/scripts/batch-sign-binaries.test.mjs
2026-10-06 11:04:35 -04:00

345 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { Arch, Packager, Platform, WinPackager } from 'app-builder-lib'
import { afterEach, test } from 'vitest'
import builderConfig from '../electron-builder.config.cjs'
import {
batchSignAppTree,
chunk,
customSign,
getBinaries
} from './batch-sign-binaries.mjs'
const tmpDirs = []
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
function tmpTree() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'batch-sign-test-'))
tmpDirs.push(dir)
return dir
}
test('getBinaries collects .exe and .dll recursively, case-insensitive, sorted', () => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'resources', 'agent-payload', 'tools', 'python', 'bin'), { recursive: true })
fs.mkdirSync(path.join(root, 'resources', 'agent-payload', 'tools', 'chromium-1', 'meep'), { recursive: true })
fs.writeFileSync(path.join(root, 'Hermes.exe'), 'x')
fs.writeFileSync(path.join(root, 'FFMPEG.DLL'), 'x')
fs.writeFileSync(path.join(root, 'resources', 'agent-payload', 'tools', 'python', 'bin', 'node.exe'), 'x')
fs.writeFileSync(path.join(root, 'resources', 'agent-payload', 'tools', 'chromium-1', 'meep', 'chrome.dll'), 'x')
fs.writeFileSync(path.join(root, 'resources', 'README.md'), 'x')
const files = getBinaries(root).map(f => path.relative(root, f))
assert.deepEqual(files, [
path.join('FFMPEG.DLL'),
path.join('Hermes.exe'),
path.join('resources', 'agent-payload', 'tools', 'chromium-1', 'meep', 'chrome.dll'),
path.join('resources', 'agent-payload', 'tools', 'python', 'bin', 'node.exe')
])
})
test('getBinaries skips symlinks and honors the skip predicate (product exe)', () => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'tools'), { recursive: true })
const target = path.join(root, 'tools', 'real.exe')
fs.writeFileSync(target, 'x')
fs.writeFileSync(path.join(root, 'Hermes.exe'), 'x')
const link = path.join(root, 'tools', 'link.exe')
try {
fs.symlinkSync(target, link)
} catch {
// Windows without symlink privilege: the collection contract under test
// is the skip predicate; symlink skipping is covered on the other OS.
}
const exe = path.join(root, 'Hermes.exe')
const files = getBinaries(root, { skip: file => path.resolve(file) === exe })
assert.equal(files.includes(exe), false)
assert.equal(files.includes(target), true)
if (fs.existsSync(link)) {
assert.equal(files.includes(link), false)
}
})
test('getBinaries tolerates a missing directory', () => {
assert.deepEqual(getBinaries(path.join(tmpTree(), 'nope')), [])
})
test('chunk splits into ~100-file batches with no leftovers', () => {
assert.deepEqual(chunk([], 100), [])
const two50 = Array.from({ length: 250 }, (_, i) => `f${i}.exe`)
const batches = chunk(two50)
assert.equal(batches.length, 3)
assert.deepEqual(batches.map(b => b.length), [100, 100, 50])
assert.deepEqual(batches.flat(), two50)
const exact = Array.from({ length: 200 }, (_, i) => `f${i}.exe`)
assert.deepEqual(chunk(exact).map(b => b.length), [100, 100])
})
test('customSign skips Store- submission packages (Partner Center signs)', async () => {
const result = await customSign(
{ path: 'C:/out/Store-HermesBundled-0.28.0-win-x64.msix' },
{ appInfo: { productFilename: 'Hermes' } },
{ signMsix: async () => { throw new Error('must not be called') } }
)
assert.equal(result, true)
})
test('customSign delegates only the msix package and root product exe to the Azure signer', async () => {
/** @type {string[][]} */
const delegated = []
/** @type {Parameters<typeof customSign>[2]} */
const deps = {
signMsix: async configuration => { delegated.push(['msix', configuration.path]) },
azureSignFile: async file => { delegated.push(['exe', file]) }
}
const output = path.join(tmpTree(), '${os}', '${arch}')
const info = new Packager({
projectDir: path.resolve(import.meta.dirname, '..'),
targets: Platform.WINDOWS.createTarget(['msix'], Arch.x64, Arch.arm64),
config: {
...builderConfig,
// validateConfig normalizes these file sets in place.
files: structuredClone(builderConfig.files),
extraResources: structuredClone(builderConfig.extraResources),
directories: { output },
win: { ...builderConfig.win, executableName: 'hermes-collision' }
}
})
await info.validateConfig()
const packager = new WinPackager(info)
const msix = path.join(tmpTree(), 'HermesBundled-0.28.0-win-x64.msix')
assert.equal(await customSign({ path: msix }, packager, deps), true)
assert.deepEqual(delegated, [['msix', msix]])
for (const arch of [Arch.x64, Arch.arm64]) {
const root = packager['computeAppOutDir'](packager.expandMacro(output, Arch[arch]), arch)
const exeName = `${packager.appInfo.productFilename}.exe`
const exe = path.join(root, exeName)
delegated.length = 0
// Same basename is not enough: payload copies wait for the afterPack batch.
for (const file of [path.join(root, 'resources', 'agent-payload', 'bin', exeName), path.join(`${root}-other`, exeName), path.join(root, 'resources', 'Hermes-helper.exe')]) {
assert.equal(await customSign({ path: file }, packager, deps), true)
}
assert.deepEqual(delegated, [])
// Returning true suppresses electron-builder's fallback per-file signer.
assert.equal(await customSign({ path: exe }, packager, deps), true)
assert.equal(await customSign({ path: exe.toUpperCase() }, packager, deps), true)
assert.deepEqual(delegated, [['exe', exe], ['exe', exe.toUpperCase()]])
}
})
test('batchSignAppTree is a no-op (skipped=true) without the Azure env, and signs via chunked argv-array invocations when set', async () => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'tools'), { recursive: true })
const exe = path.join(root, 'Hermes.exe')
fs.writeFileSync(exe, 'x')
fs.writeFileSync(path.join(root, 'tools', 'node.exe'), 'x')
fs.writeFileSync(path.join(root, 'tools', 'FFMPEG.DLL'), 'x')
// The shipped uv-cache holds inert sdist/archive artifacts — the arch
// audit exempts it and batch-sign must never sign it (wasted Azure
// round-trips; locally it can even reference files since removed).
fs.mkdirSync(path.join(root, 'uv-cache', 'archive-v0', 'setuptools'), { recursive: true })
fs.writeFileSync(path.join(root, 'uv-cache', 'archive-v0', 'setuptools', 'cli.exe'), 'x')
// Unsigned lane: loud no-op, nothing invoked.
for (const env of [{}, { AZURE_SIGN_ENDPOINT: 'https://test.invalid' }, { AZURE_SIGN_ENDPOINT: 'https://test.invalid', AZURE_SIGN_ACCOUNT: 'account' }]) {
assert.deepEqual(await batchSignAppTree(root, exe, { env }), { signed: 0, chunks: 0, skipped: true })
}
// Signed lane: product exe excluded, chunked argv arrays. Two passes per
// chunk: 'sign' (Azure, no timestamp) then 'timestamp' (RFC3161, no dlib).
const invocations = []
const fakeExec = (tool, args) => {
invocations.push({ tool, args })
return Buffer.from('')
}
const result = await batchSignAppTree(root, exe, {
env: {
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
AZURE_SIGN_ACCOUNT: 'codesign2',
AZURE_SIGN_PROFILE: 'hermesagent'
},
exec: fakeExec,
mkdtemp: () => root,
signtool: 'signtool.exe',
dlib: 'azure.codesigning.dlib.dll'
})
assert.deepEqual(result, { signed: 2, chunks: 1, skipped: false })
assert.equal(invocations.length, 2, 'one sign + one timestamp pass for a single chunk')
const [signInv, tsInv] = invocations
assert.equal(signInv.tool, 'signtool.exe')
assert.ok(Array.isArray(signInv.args), 'argv array, never a shell string')
assert.equal(signInv.args[0], 'sign')
assert.equal(tsInv.args[0], 'timestamp')
const args = signInv.args
assert.ok(!args.some(arg => typeof arg === 'string' && arg.includes(' ')))
assert.equal(args.includes(exe), false, 'product exe excluded — signed per-file after rcedit')
assert.equal(args.includes(path.join(root, 'tools', 'node.exe')), true)
assert.equal(args.includes(path.join(root, 'tools', 'FFMPEG.DLL')), true)
assert.equal(args[args.indexOf('/dlib') + 1], 'azure.codesigning.dlib.dll')
assert.ok(args[args.indexOf('/dmdf') + 1].endsWith('batch-sign.json'))
assert.equal(args[args.indexOf('/fd') + 1], 'SHA256')
// Sign pass carries NO timestamp flags — timestamping is the separate pass.
assert.equal(args.includes('/tr'), false)
assert.equal(args.includes('/td'), false)
// Timestamp pass carries /tr + /td but NO dlib / dmdf.
const tsArgs = tsInv.args
assert.equal(tsArgs[tsArgs.indexOf('/tr') + 1], 'http://timestamp.digicert.com')
assert.equal(tsArgs[tsArgs.indexOf('/td') + 1], 'SHA256')
assert.equal(tsArgs.includes('/dlib'), false)
assert.equal(tsArgs.includes('/dmdf'), false)
assert.ok(tsArgs.includes(path.join(root, 'tools', 'node.exe')), 'timestamp pass covers the same files')
assert.ok(tsArgs.includes(path.join(root, 'tools', 'FFMPEG.DLL')))
})
test('batchSignAppTree chunks large trees into ~100-file signtool invocations, sign + timestamp passes', async () => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'tools'), { recursive: true })
for (let i = 0; i < 250; i += 1) {
fs.writeFileSync(path.join(root, 'tools', `bin${i}.exe`), 'x')
}
const invocations = []
const result = await batchSignAppTree(root, path.join(root, 'Hermes.exe'), {
env: {
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
AZURE_SIGN_ACCOUNT: 'codesign2',
AZURE_SIGN_PROFILE: 'hermesagent'
},
exec: (tool, args) => {
invocations.push(args)
return Buffer.from('')
},
mkdtemp: () => root,
signtool: 'signtool.exe',
dlib: 'azure.codesigning.dlib.dll'
})
assert.deepEqual(result, { signed: 250, chunks: 3, skipped: false })
// 3 chunks × 2 passes (sign + timestamp).
assert.equal(invocations.length, 6)
const signBatches = invocations.filter(a => a[0] === 'sign')
const tsBatches = invocations.filter(a => a[0] === 'timestamp')
assert.equal(signBatches.length, 3)
assert.equal(tsBatches.length, 3)
assert.deepEqual(
signBatches.map(batch => batch.filter(arg => arg.endsWith('.exe')).length).sort((a, b) => a - b),
[50, 100, 100]
)
const flat = signBatches.flat().filter(arg => arg.endsWith('.exe'))
assert.equal(new Set(flat).size, 250, 'every binary signed exactly once')
const tsFlat = tsBatches.flat().filter(arg => arg.endsWith('.exe'))
assert.equal(new Set(tsFlat).size, 250, 'every binary timestamped exactly once')
assert.deepEqual(new Set(tsFlat), new Set(flat), 'timestamp pass covers the same files as the sign pass')
})
test('sign chunks run concurrently, capped at the configured concurrency', async () => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'tools'), { recursive: true })
for (let i = 0; i < 12; i += 1) {
fs.writeFileSync(path.join(root, 'tools', `bin${i}.exe`), 'x')
}
// 12 files at chunk size 1 → 12 chunks, each a sign + timestamp pass. The
// fake holds its "child" open across an await so the pool's concurrency is
// actually observable (a sync fake would report max 1 by construction).
let inFlight = 0
let maxInFlight = 0
const fakeExec = async () => {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise(resolve => setTimeout(resolve, 5))
inFlight -= 1
}
await batchSignAppTree(root, path.join(root, 'Hermes.exe'), {
env: {
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
AZURE_SIGN_ACCOUNT: 'codesign2',
AZURE_SIGN_PROFILE: 'hermesagent'
},
exec: fakeExec,
mkdtemp: () => root,
signtool: 'signtool.exe',
dlib: 'azure.codesigning.dlib.dll',
chunkSize: 1,
concurrency: 3
})
assert.equal(maxInFlight, 3, 'never more than the configured concurrency in flight')
})
test.each([false, true])('timestamp retry is bounded; permanent failure=%s', async permanent => {
const root = tmpTree()
fs.mkdirSync(path.join(root, 'tools'), { recursive: true })
fs.writeFileSync(path.join(root, 'tools', 'node.exe'), 'x')
let calls = 0
const fakeExec = (tool, args) => {
if (args[0] === 'sign') return
calls += 1
if (permanent || calls < 3) throw new Error('Invalid Time Stamp Request Length:-1')
}
const signing = batchSignAppTree(root, path.join(root, 'Hermes.exe'), {
env: {
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
AZURE_SIGN_ACCOUNT: 'codesign2',
AZURE_SIGN_PROFILE: 'hermesagent'
},
exec: fakeExec,
mkdtemp: () => root,
signtool: 'signtool.exe',
dlib: 'azure.codesigning.dlib.dll',
timestampRetryDelayMs: 0
})
if (permanent) await assert.rejects(signing, /Invalid Time Stamp/); else await signing
assert.equal(calls, 3, 'timestamp pass retried until the server succeeded')
})
test('batch signing passes its paired toolchain and runtime to both child phases', async () => {
const root = tmpTree()
fs.writeFileSync(path.join(root, 'node.exe'), 'x')
const tools = {
signtool: path.join(root, 'SDK with spaces', 'signtool.exe'),
dlib: path.join(root, 'ATS with spaces', 'Azure.CodeSigning.Dlib.dll'),
dotnetRoot: path.join(root, 'dotnet'),
}
const invocations = []
const result = await batchSignAppTree(root, path.join(root, 'Hermes.exe'), {
...tools,
env: {
AZURE_SIGN_ENDPOINT: 'https://test.invalid',
AZURE_SIGN_ACCOUNT: 'account', AZURE_SIGN_PROFILE: 'profile',
DOTNET_ROOT: 'unrelated machine runtime', TEMP: root,
},
exec: (tool, args, options) => { invocations.push({ tool, args, options }) },
cache: null,
})
assert.equal(result.signed, 1)
assert.deepEqual(invocations.map(call => call.args[0]), ['sign', 'timestamp'])
for (const call of invocations) {
assert.equal(call.tool, tools.signtool)
assert.equal(call.options.env.DOTNET_ROOT, tools.dotnetRoot)
}
const [sign, timestamp] = invocations
assert.equal(sign.args[sign.args.indexOf('/dlib') + 1], tools.dlib)
assert.equal(timestamp.args.includes('/dlib'), false)
})