122 lines
6.8 KiB
TypeScript
122 lines
6.8 KiB
TypeScript
/** Integrity and source binding for notarized Computer Use applications consumed by npm packaging. */
|
|
import { createHash } from 'node:crypto'
|
|
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { z } from 'zod'
|
|
|
|
/** Architectures carried by each published macOS provider package. */
|
|
export const CUA_ARCHITECTURES = ['arm64', 'x64'] as const
|
|
/** Native application name shared with the provider receipt. */
|
|
export const CUA_APPLICATION = 'DSH Computer Use.app'
|
|
/** Completion record written only after both applications pass distribution checks. */
|
|
export const CUA_MANIFEST = 'cua-helper.json'
|
|
const Digest = z.string().regex(/^[a-f0-9]{64}$/u)
|
|
const Manifest = z.object({
|
|
schemaVersion: z.literal(1), version: z.string().min(1), commit: z.string().regex(/^[a-f0-9]{40}$/u),
|
|
teamId: z.string().regex(/^[A-Z0-9]{10}$/u),
|
|
applications: z.array(z.object({ architecture: z.enum(CUA_ARCHITECTURES), bundle: Digest }).strict()).length(2),
|
|
}).strict()
|
|
/** Verified build identity and application generations. */
|
|
export type CuaHelperManifest = z.infer<typeof Manifest>
|
|
|
|
/**
|
|
* Hash application-relative file names, bytes and executable bits, matching build_app.py.
|
|
* @param application - Real application directory; links and special files are rejected.
|
|
* @returns SHA-256 of the complete application, including its notarization ticket.
|
|
*/
|
|
export function cuaApplicationDigest(application: string): string {
|
|
const root = lstatSync(application)
|
|
if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('CUA application must be a real directory')
|
|
const files: string[] = []
|
|
const visit = (directory: string): void => {
|
|
for (const name of readdirSync(join(application, directory))) {
|
|
const path = directory ? `${directory}/${name}` : name
|
|
const stat = lstatSync(join(application, path))
|
|
if (stat.isDirectory()) visit(path)
|
|
else if (stat.isFile()) files.push(path)
|
|
else throw new Error(`CUA application contains a link or special file: ${path}`)
|
|
}
|
|
}
|
|
visit('')
|
|
const digest = createHash('sha256')
|
|
for (const path of files.sort()) {
|
|
const file = join(application, path)
|
|
digest.update(`${path}\0`).update(createHash('sha256').update(readFileSync(file)).digest())
|
|
.update(Buffer.from([(lstatSync(file).mode & 0o111) === 0 ? 0 : 1]))
|
|
}
|
|
return digest.digest('hex')
|
|
}
|
|
|
|
/**
|
|
* Select the complete signed application and validate its immutable receipt.
|
|
* @param directory - One architecture's native/bin directory.
|
|
* @returns Selected application path and its content digest.
|
|
*/
|
|
export function readCuaApplication(directory: string): { application: string; bundle: string } {
|
|
if (!lstatSync(directory).isDirectory() || !lstatSync(join(directory, 'current.json')).isFile()) throw new Error('CUA receipt requires regular files and directories')
|
|
const { bundle } = z.object({ bundle: Digest }).strict().parse(JSON.parse(readFileSync(join(directory, 'current.json'), 'utf8')))
|
|
if (!lstatSync(join(directory, bundle)).isDirectory()) throw new Error('CUA generation must be a real directory')
|
|
const application = join(directory, bundle, CUA_APPLICATION)
|
|
for (const path of ['Contents/Info.plist', 'Contents/MacOS/dsh-cua-native', 'Contents/Resources/DSHComputerUse.icns', 'Contents/_CodeSignature/CodeResources']) {
|
|
if (!lstatSync(join(application, path)).isFile()) throw new Error(`CUA application omits ${path}`)
|
|
}
|
|
if ((lstatSync(join(application, 'Contents/MacOS/dsh-cua-native')).mode & 0o111) === 0) throw new Error('CUA helper is not executable')
|
|
if (cuaApplicationDigest(application) !== bundle) throw new Error('CUA application digest does not match current.json')
|
|
if (readdirSync(directory).sort().join(',') !== [bundle, 'current.json'].sort().join(',')) throw new Error('CUA release contains unselected application generations')
|
|
return { application, bundle }
|
|
}
|
|
|
|
/**
|
|
* Reselect a build after notarization has added its ticket, before distribution.
|
|
* @param directory - Fresh, build-owned architecture directory.
|
|
* @param application - Application just notarized and verified by the producer.
|
|
* @returns Final content digest; old build receipts are replaced.
|
|
*/
|
|
export function sealCuaApplication(directory: string, application: string): string {
|
|
const bundle = cuaApplicationDigest(application)
|
|
const destination = join(directory, bundle, CUA_APPLICATION)
|
|
if (destination !== application) {
|
|
mkdirSync(join(directory, bundle))
|
|
renameSync(application, destination)
|
|
for (const name of readdirSync(directory)) {
|
|
if (name !== bundle && name !== 'current.json') rmSync(join(directory, name), { recursive: true, force: true })
|
|
}
|
|
}
|
|
writeFileSync(join(directory, 'current.json'), `${JSON.stringify({ bundle })}\n`)
|
|
readCuaApplication(directory)
|
|
return bundle
|
|
}
|
|
|
|
/**
|
|
* Verify the producer record and every final application before copying or packing.
|
|
* @param root - Extracted helper artifact containing native/bin and cua-helper.json.
|
|
* @param expected - Source commit and product version of the consuming checkout.
|
|
* @returns Validated producer metadata.
|
|
*/
|
|
export function verifyCuaHelperPayload(root: string, expected: { version: string; commit: string }): CuaHelperManifest {
|
|
const manifest = Manifest.parse(JSON.parse(readFileSync(join(root, CUA_MANIFEST), 'utf8')))
|
|
if (manifest.version !== expected.version || manifest.commit !== expected.commit) throw new Error('CUA artifact does not match the release source and version')
|
|
if (new Set(manifest.applications.map(entry => entry.architecture)).size !== 2) throw new Error('CUA artifact must contain arm64 and x64')
|
|
const bin = join(root, 'native/bin')
|
|
if (readdirSync(bin).sort().join(',') !== 'darwin-arm64,darwin-x64') throw new Error('CUA artifact has unexpected architecture directories')
|
|
for (const entry of manifest.applications) {
|
|
const { bundle } = readCuaApplication(join(bin, `darwin-${entry.architecture}`))
|
|
if (bundle !== entry.bundle) throw new Error('CUA application differs from the producer manifest')
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
/**
|
|
* Stage verified native applications for the provider's ordinary pnpm pack command.
|
|
* @param root - Extracted helper artifact.
|
|
* @param packageRoot - Provider package to receive native/bin.
|
|
* @param expected - Source identity required by the release.
|
|
*/
|
|
export function stageCuaHelperPayload(root: string, packageRoot: string, expected: { version: string; commit: string }): void {
|
|
verifyCuaHelperPayload(root, expected)
|
|
const destination = join(packageRoot, 'native/bin')
|
|
if (existsSync(destination)) throw new Error('CUA staging requires an empty native/bin; use a clean release checkout')
|
|
cpSync(join(root, 'native/bin'), destination, { recursive: true })
|
|
cpSync(join(root, CUA_MANIFEST), join(packageRoot, CUA_MANIFEST))
|
|
}
|