enso/app/ide-desktop/client/tasks/signArchivesMacOs.ts
somebody1234 5faddf52f0
Fix ESLint errors, add some docs (#11339)
- Fix ESLint errors
- Add documentation for *some* functions with blank documentation

# Important Notes
None
2024-10-21 12:56:39 +00:00

378 lines
14 KiB
TypeScript

/**
* @file This script signs the content of all archives that we have for macOS.
* For this to work this needs to run on macOS with `codesign`, and a JDK installed.
* `codesign` is needed to sign the files, while the JDK is needed for correct packing
* and unpacking of java archives.
*
* We require this extra step as our dependencies contain files that require us
* to re-sign jar contents that cannot be opened as pure zip archives,
* but require a java toolchain to extract and re-assemble to preserve manifest information.
* This functionality is not provided by `electron-osx-sign` out of the box.
*
* This code is based on https://github.com/electron/electron-osx-sign/pull/231
* but our use-case is unlikely to be supported by `electron-osx-sign`
* as it adds a java toolchain as additional dependency.
* This script should be removed once the engine is signed.
*/
import * as childProcess from 'node:child_process'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as pathModule from 'node:path'
import glob from 'fast-glob'
// ===============================================
// === Patterns of entities that need signing. ===
// ===============================================
/** Parts of the GraalVM distribution that need to be signed by us in an extra step. */
async function graalSignables(resourcesDir: string): Promise<Signable[]> {
const archivePatterns: ArchivePattern[] = [
['Contents/Home/jmods/java.base.jmod', ['bin/java', 'bin/keytool', 'lib/jspawnhelper']],
['Contents/Home/jmods/java.rmi.jmod', ['bin/rmiregistry']],
['Contents/Home/jmods/java.scripting.jmod', ['bin/jrunscript']],
['Contents/Home/jmods/jdk.compiler.jmod', ['bin/javac', 'bin/serialver']],
['Contents/Home/jmods/jdk.hotspot.agent.jmod', ['bin/jhsdb']],
['Contents/Home/jmods/jdk.httpserver.jmod', ['bin/jwebserver']],
['Contents/Home/jmods/jdk.jartool.jmod', ['bin/jarsigner', 'bin/jar']],
['Contents/Home/jmods/jdk.javadoc.jmod', ['bin/javadoc']],
['Contents/Home/jmods/jdk.javadoc.jmod', ['bin/javadoc']],
['Contents/Home/jmods/jdk.jconsole.jmod', ['bin/jconsole']],
['Contents/Home/jmods/jdk.jdeps.jmod', ['bin/javap', 'bin/jdeprscan', 'bin/jdeps']],
['Contents/Home/jmods/jdk.jdi.jmod', ['bin/jdb']],
['Contents/Home/jmods/jdk.jfr.jmod', ['bin/jfr']],
['Contents/Home/jmods/jdk.jlink.jmod', ['bin/jmod', 'bin/jlink', 'bin/jimage']],
['Contents/Home/jmods/jdk.jshell.jmod', ['bin/jshell']],
[
'Contents/Home/jmods/jdk.jpackage.jmod',
['bin/jpackage', 'classes/jdk/jpackage/internal/resources/jpackageapplauncher'],
],
['Contents/Home/jmods/jdk.jstatd.jmod', ['bin/jstatd']],
[
'Contents/Home/jmods/jdk.jcmd.jmod',
['bin/jstack', 'bin/jcmd', 'bin/jps', 'bin/jmap', 'bin/jstat', 'bin/jinfo'],
],
]
const binariesPatterns = ['Contents/MacOS/libjli.dylib']
// We use `*` for Graal versioned directory to not have to update this script on every GraalVM
// update. Updates might still be needed when the list of binaries to sign changes.
const graalDir = pathModule.join(resourcesDir, 'enso', 'runtime', '*')
const archives = await ArchiveToSign.lookupMany(graalDir, archivePatterns)
const binaries = await BinaryToSign.lookupMany(graalDir, binariesPatterns)
return [...archives, ...binaries]
}
/** Parts of the Enso Engine distribution that need to be signed by us in an extra step. */
async function ensoPackageSignables(resourcesDir: string): Promise<Signable[]> {
// Archives, and their content that need to be signed in an extra step. If a new archive is
// added to the engine dependencies this also needs to be added here. If an archive is not added
// here, it will show up as a failure to notarise the IDE. The offending archive will be named
// in the error message provided by Apple and can then be added here.
const engineDir = `${resourcesDir}/enso/dist/*`
const archivePatterns: ArchivePattern[] = [
[
'/component/runner/runner.jar',
[
'org/sqlite/native/Mac/x86_64/libsqlitejdbc.jnilib',
'org/sqlite/native/Mac/aarch64/libsqlitejdbc.jnilib',
'com/sun/jna/darwin-aarch64/libjnidispatch.jnilib',
'com/sun/jna/darwin-x86-64/libjnidispatch.jnilib',
],
],
[
'component/python-resources-*.jar',
[
'META-INF/resources/darwin/*/lib/graalpy*/*.dylib',
'META-INF/resources/darwin/*/lib/graalpy*/modules/*.so',
],
],
[
'component/truffle-nfi-libffi-*.jar',
['META-INF/resources/nfi-native/libnfi/darwin/*/bin/libtrufflenfi.dylib'],
],
[
'component/truffle-runtime-*.jar',
[
'META-INF/resources/engine/libtruffleattach/darwin/amd64/bin/libtruffleattach.dylib',
'META-INF/resources/engine/libtruffleattach/darwin/aarch64/bin/libtruffleattach.dylib',
],
],
['component/jna-*.jar', ['com/sun/jna/*/libjnidispatch.jnilib']],
[
'lib/Standard/Database/*/polyglot/java/sqlite-jdbc-*.jar',
[
'org/sqlite/native/Mac/aarch64/libsqlitejdbc.jnilib',
'org/sqlite/native/Mac/x86_64/libsqlitejdbc.jnilib',
'org/sqlite/native/Mac/aarch64/libsqlitejdbc.dylib',
'org/sqlite/native/Mac/x86_64/libsqlitejdbc.dylib',
],
],
[
'lib/Standard/Snowflake/*/polyglot/java/snowflake-jdbc-*.jar',
[
'META-INF/native/libconscrypt_openjdk_jni-osx-*.dylib',
'META-INF/native/libio_grpc_netty_shaded_netty_tcnative_osx_*.jnilib',
],
],
[
'lib/Standard/Google_Api/*/polyglot/java/grpc-netty-shaded-*.jar',
['META-INF/native/libio_grpc_netty_shaded_netty_tcnative_osx_*.jnilib'],
],
[
'lib/Standard/Google_Api/*/polyglot/java/conscrypt-openjdk-uber-*.jar',
['META-INF/native/libconscrypt_openjdk_jni-osx-*.dylib'],
],
['lib/Standard/Tableau/*/polyglot/java/jna-*.jar', ['com/sun/jna/*/libjnidispatch.jnilib']],
[
'lib/Standard/Image/*/polyglot/java/opencv-*.jar',
['nu/pattern/opencv/osx/*/libopencv_java*.dylib'],
],
]
return ArchiveToSign.lookupMany(engineDir, archivePatterns)
}
// ================
// === Signing. ===
// ================
/** Information we need to sign a given binary. */
interface SigningContext {
/**
* A digital identity that is stored in a keychain that is on the calling user's keychain
* search list. We rely on this already being set up by the Electron Builder.
*/
readonly identity: string
/** Path to the entitlements file. */
readonly entitlements: string
}
/** An entity that we want to sign. */
interface Signable {
/** Sign this entity. */
readonly sign: (context: SigningContext) => Promise<void>
}
/** Placeholder name for temporary archives. */
const TEMPORARY_ARCHIVE_PATH = 'temporary_archive.zip'
/** Helper to execute a program in a given directory and return the output. */
function run(cmd: string, args: string[], cwd?: string) {
console.log('Running', cmd, args, cwd)
return childProcess.execFileSync(cmd, args, { cwd }).toString()
}
/**
* Archive with some binaries that we want to sign.
*
* Can be either a zip or a jar file.
*/
class ArchiveToSign implements Signable {
/** Looks up for archives to sign using the given path patterns. */
static lookupMany = lookupManyHelper(ArchiveToSign.lookup.bind(this))
/** Create a new instance. */
constructor(
/** An absolute path to the archive. */
public path: string,
/**
* A list of patterns for files to sign inside the archive.
* Relative to the root of the archive.
*/
public binaries: glob.Pattern[],
) {}
/** Looks up for archives to sign using the given path pattern. */
static async lookup(base: string, [pattern, binaries]: ArchivePattern) {
return lookupHelper(path => new ArchiveToSign(path, binaries))(base, pattern)
}
/**
* Sign content of an archive. This function extracts the archive, signs the required files,
* re-packages the archive and replaces the original.
*/
async sign(context: SigningContext) {
console.log(`Signing archive ${this.path}`)
const archiveName = pathModule.basename(this.path)
const workingDir = await getTmpDir()
try {
const isJar = archiveName.endsWith('jar')
if (isJar) {
run('jar', ['xf', this.path], workingDir)
} else {
// We cannot use `unzip` here because of the following issue:
// https://unix.stackexchange.com/questions/115825/
// This started to be an issue with GraalVM 22.3.0 release.
run('7za', ['X', `-o${workingDir}`, this.path])
}
const binariesToSign = await BinaryToSign.lookupMany(workingDir, this.binaries)
for (const binaryToSign of binariesToSign) {
void binaryToSign.sign(context)
}
if (isJar) {
if (archiveName.includes('runner')) {
run('jar', ['-cfm', TEMPORARY_ARCHIVE_PATH, 'META-INF/MANIFEST.MF', '.'], workingDir)
} else {
run('jar', ['-cf', TEMPORARY_ARCHIVE_PATH, '.'], workingDir)
}
} else {
run('zip', ['-rm', TEMPORARY_ARCHIVE_PATH, '.'], workingDir)
}
// We cannot use fs.rename because temp and target might be on different volumes.
console.log(run('/bin/mv', [pathModule.join(workingDir, TEMPORARY_ARCHIVE_PATH), this.path]))
console.log(`Successfully repacked ${this.path} to handle signing inner native dependency.`)
return
} catch (error) {
console.error(
`Could not repackage ${archiveName}. Please check the ${import.meta.url} task to ` +
"ensure that it's working. This jar has to be treated specially " +
"because it has a native library and Apple's codesign does not sign inner " +
'native libraries correctly for jar files.',
)
throw error
} finally {
await rmRf(workingDir)
}
}
}
/** A single code binary file to be signed. */
class BinaryToSign implements Signable {
/** Looks up for binaries to sign using the given path pattern. */
static lookup = lookupHelper(path => new BinaryToSign(path))
/** Looks up for binaries to sign using the given path patterns. */
static lookupMany = lookupManyHelper(BinaryToSign.lookup)
/** Create a new instance. */
constructor(
/** An absolute path to the binary. */
public path: string,
) {}
/** Sign this binary. */
async sign({ entitlements, identity }: SigningContext) {
console.log(`Signing ${this.path}`)
run('codesign', [
'-vvv',
'--entitlements',
entitlements,
'--force',
'--options=runtime',
'--sign',
identity,
this.path,
])
// Async functions should contain await.
await Promise.resolve()
}
}
// ==============================
// === Discovering Signables. ===
// ==============================
/**
* Helper used to concisely define patterns for an archive to sign.
*
* Consists of pattern of the archive path
* and set of patterns for files to sign inside the archive.
*/
type ArchivePattern = [glob.Pattern, glob.Pattern[]]
/** Like `glob` but returns absolute paths by default. */
async function globAbsolute(pattern: glob.Pattern, options?: glob.Options): Promise<string[]> {
const paths = await glob(pattern, { absolute: true, ...options })
return paths
}
/**
* Glob patterns relative to a given base directory. The base directory is allowed to be a pattern
* as well.
*/
async function globAbsoluteIn(
base: glob.Pattern,
pattern: glob.Pattern,
options?: glob.Options,
): Promise<string[]> {
return globAbsolute(pathModule.join(base, pattern), options)
}
/** Generate a lookup function for a given Signable type. */
function lookupHelper<R extends Signable>(mapper: (path: string) => R) {
return async (base: string, pattern: glob.Pattern) => {
const paths = await globAbsoluteIn(base, pattern)
return paths.map(mapper)
}
}
/** Generate a lookup function for a given Signable type. */
function lookupManyHelper<T, R extends Signable>(
lookup: (base: string, pattern: T) => Promise<R[]>,
) {
return async function (base: string, patterns: T[]) {
const results = await Promise.all(
patterns.map(async pattern => {
const ret = await lookup(base, pattern)
if (ret.length === 0) {
console.warn(`No files found for pattern ${String(pattern)} in ${base}`)
}
return ret
}),
)
return results.flat()
}
}
// ==================
// === Utilities. ===
// ==================
/** Remove file recursively. */
async function rmRf(path: string) {
await fs.rm(path, { recursive: true, force: true })
}
/** Get a new temporary directory. Caller is responsible for cleaning up the directory. */
async function getTmpDir(prefix?: string) {
return await fs.mkdtemp(pathModule.join(os.tmpdir(), prefix ?? 'enso-signing-'))
}
// ====================
// === Entry point. ===
// ====================
/** Input for this script. */
interface Input extends SigningContext {
readonly appOutDir: string
readonly productFilename: string
}
/** Entry point, meant to be used from an afterSign Electron Builder's hook. */
export default async function (context: Input) {
console.log('Environment: ', process.env)
const { appOutDir, productFilename } = context
const appDir = pathModule.join(appOutDir, `${productFilename}.app`)
const contentsDir = pathModule.join(appDir, 'Contents')
const resourcesDir = pathModule.join(contentsDir, 'Resources')
// Sign archives.
console.log('Signing GraalVM elemenets...')
for (const signable of await graalSignables(resourcesDir)) await signable.sign(context)
console.log('Signing Engine elements...')
for (const signable of await ensoPackageSignables(resourcesDir)) await signable.sign(context)
// Finally re-sign the top-level enso.
const topLevelExecutable = new BinaryToSign(
pathModule.join(contentsDir, 'MacOS', productFilename),
)
await topLevelExecutable.sign(context)
}