diff --git a/.changeset/nervous-pandas-film.md b/.changeset/nervous-pandas-film.md new file mode 100644 index 00000000000..1caf13eb2a1 --- /dev/null +++ b/.changeset/nervous-pandas-film.md @@ -0,0 +1,5 @@ +--- +"app-builder-lib": minor +--- + +feat(mac): ElectronAsarIntegrity in electron@15 diff --git a/packages/app-builder-lib/scheme.json b/packages/app-builder-lib/scheme.json index e0698864005..4bd0561036c 100644 --- a/packages/app-builder-lib/scheme.json +++ b/packages/app-builder-lib/scheme.json @@ -330,11 +330,6 @@ "AsarOptions": { "additionalProperties": false, "properties": { - "externalAllowed": { - "default": false, - "description": "Allows external asar files.", - "type": "boolean" - }, "ordering": { "type": [ "null", diff --git a/packages/app-builder-lib/src/asar/asar.ts b/packages/app-builder-lib/src/asar/asar.ts index a52d38ced36..0b5c12d3e2a 100644 --- a/packages/app-builder-lib/src/asar/asar.ts +++ b/packages/app-builder-lib/src/asar/asar.ts @@ -2,6 +2,20 @@ import { createFromBuffer } from "chromium-pickle-js" import { close, open, read, readFile, Stats } from "fs-extra" import * as path from "path" +/** @internal */ +export interface ReadAsarHeader { + readonly header: string + readonly size: number +} + +/** @internal */ +export interface NodeIntegrity { + algorithm: "SHA256" + hash: string + blockSize: number + blocks: Array +} + /** @internal */ export class Node { // we don't use Map because later it will be stringified @@ -16,6 +30,8 @@ export class Node { executable?: boolean link?: string + + integrity?: NodeIntegrity } /** @internal */ @@ -66,13 +82,14 @@ export class AsarFilesystem { return result } - addFileNode(file: string, dirNode: Node, size: number, unpacked: boolean, stat: Stats): Node { + addFileNode(file: string, dirNode: Node, size: number, unpacked: boolean, stat: Stats, integrity?: NodeIntegrity): Node { if (size > 4294967295) { throw new Error(`${file}: file size cannot be larger than 4.2GB`) } const node = new Node() node.size = size + node.integrity = integrity if (unpacked) { node.unpacked = true } else { @@ -114,7 +131,7 @@ export class AsarFilesystem { } } -export async function readAsar(archive: string): Promise { +export async function readAsarHeader(archive: string): Promise { const fd = await open(archive, "r") let size: number let headerBuf @@ -135,7 +152,11 @@ export async function readAsar(archive: string): Promise { } const headerPickle = createFromBuffer(headerBuf) - const header = headerPickle.createIterator().readString() + return { header: headerPickle.createIterator().readString(), size } +} + +export async function readAsar(archive: string): Promise { + const { header, size } = await readAsarHeader(archive) return new AsarFilesystem(archive, JSON.parse(header), size) } diff --git a/packages/app-builder-lib/src/asar/asarUtil.ts b/packages/app-builder-lib/src/asar/asarUtil.ts index 7f698b4e4b8..c3102ecd7c3 100644 --- a/packages/app-builder-lib/src/asar/asarUtil.ts +++ b/packages/app-builder-lib/src/asar/asarUtil.ts @@ -8,6 +8,7 @@ import { Packager } from "../packager" import { PlatformPackager } from "../platformPackager" import { getDestinationPath, ResolvedFileSet } from "../util/appFileCopier" import { AsarFilesystem, Node } from "./asar" +import { hashFile, hashFileContents } from "./integrity" import { detectUnpackedDirs } from "./unpackDetector" // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -112,9 +113,10 @@ export class AsarPackager { } const dirNode = currentDirNode! - const newData = transformedFiles == null ? null : transformedFiles.get(i) + const newData = transformedFiles == null ? undefined : transformedFiles.get(i) const isUnpacked = dirNode.unpacked || (this.unpackPattern != null && this.unpackPattern(file, stat)) - this.fs.addFileNode(file, dirNode, newData == null ? stat.size : Buffer.byteLength(newData), isUnpacked, stat) + const integrity = newData === undefined ? await hashFile(file) : hashFileContents(newData) + this.fs.addFileNode(file, dirNode, newData == undefined ? stat.size : Buffer.byteLength(newData), isUnpacked, stat, integrity) if (isUnpacked) { if (!dirNode.unpacked && !dirToCreateForUnpackedFiles.has(fileParent)) { dirToCreateForUnpackedFiles.add(fileParent) diff --git a/packages/app-builder-lib/src/asar/integrity.ts b/packages/app-builder-lib/src/asar/integrity.ts index 53344799162..67e78b4d137 100644 --- a/packages/app-builder-lib/src/asar/integrity.ts +++ b/packages/app-builder-lib/src/asar/integrity.ts @@ -3,43 +3,107 @@ import { createHash } from "crypto" import { createReadStream } from "fs" import { readdir } from "fs/promises" import * as path from "path" +import { readAsarHeader, NodeIntegrity } from "./asar" export interface AsarIntegrityOptions { - /** - * Allows external asar files. - * - * @default false - */ - readonly externalAllowed?: boolean + readonly resourcesPath: string + readonly resourcesRelativePath: string } -export interface AsarIntegrity extends AsarIntegrityOptions { - checksums: { [key: string]: string } +export interface HeaderHash { + algorithm: "SHA256" + hash: string } -export async function computeData(resourcesPath: string, options?: AsarIntegrityOptions | null): Promise { +export interface AsarIntegrity { + [key: string]: HeaderHash +} + +export async function computeData({ resourcesPath, resourcesRelativePath }: AsarIntegrityOptions): Promise { // sort to produce constant result const names = (await readdir(resourcesPath)).filter(it => it.endsWith(".asar")).sort() - const checksums = await BluebirdPromise.map(names, it => hashFile(path.join(resourcesPath, it))) + const checksums = await BluebirdPromise.map(names, it => hashHeader(path.join(resourcesPath, it))) - const result: { [key: string]: string } = {} + const result: AsarIntegrity = {} for (let i = 0; i < names.length; i++) { - result[names[i]] = checksums[i] + result[path.join(resourcesRelativePath, names[i])] = checksums[i] + } + return result +} + +async function hashHeader(file: string): Promise { + const hash = createHash("sha256") + const { header } = await readAsarHeader(file) + hash.update(header) + return { + algorithm: "SHA256", + hash: hash.digest("hex"), } - return { checksums: result, ...options } } -function hashFile(file: string, algorithm = "sha512", encoding: "hex" | "base64" | "latin1" = "base64") { - return new Promise((resolve, reject) => { - const hash = createHash(algorithm) - hash.on("error", reject).setEncoding(encoding) +export function hashFile(file: string, blockSize = 4 * 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256") + + const blocks = new Array() + + let blockBytes = 0 + let blockHash = createHash("sha256") + + function updateBlockHash(chunk: Buffer) { + let off = 0 + while (off < chunk.length) { + const toHash = Math.min(blockSize - blockBytes, chunk.length - off) + blockHash.update(chunk.slice(off, off + toHash)) + off += toHash + blockBytes += toHash + + if (blockBytes === blockSize) { + blocks.push(blockHash.digest("hex")) + blockHash = createHash("sha256") + blockBytes = 0 + } + } + } createReadStream(file) + .on("data", it => { + // Note that `it` is a Buffer anyway so this cast is a no-op + updateBlockHash(Buffer.from(it)) + hash.update(it) + }) .on("error", reject) .on("end", () => { - hash.end() - resolve(hash.read() as string) + if (blockBytes !== 0) { + blocks.push(blockHash.digest("hex")) + } + resolve({ + algorithm: "SHA256", + hash: hash.digest("hex"), + blockSize, + blocks, + }) }) - .pipe(hash, { end: false }) }) } + +export function hashFileContents(contents: Buffer | string, blockSize = 4 * 1024 * 1024): NodeIntegrity { + const buffer = Buffer.from(contents) + const hash = createHash("sha256") + hash.update(buffer) + + const blocks = new Array() + + for (let off = 0; off < buffer.length; off += blockSize) { + const blockHash = createHash("sha256") + blockHash.update(buffer.slice(off, off + blockSize)) + blocks.push(blockHash.digest("hex")) + } + + return { + algorithm: "SHA256", + hash: hash.digest("hex"), + blockSize, + blocks, + } +} diff --git a/packages/app-builder-lib/src/electron/electronMac.ts b/packages/app-builder-lib/src/electron/electronMac.ts index ead3aa6b3c2..04f2023826f 100644 --- a/packages/app-builder-lib/src/electron/electronMac.ts +++ b/packages/app-builder-lib/src/electron/electronMac.ts @@ -214,7 +214,7 @@ export async function createMacApp(packager: MacPackager, appOutDir: string, asa } if (asarIntegrity != null) { - appPlist.AsarIntegrity = JSON.stringify(asarIntegrity) + appPlist.ElectronAsarIntegrity = asarIntegrity } const plistDataToWrite: any = { diff --git a/packages/app-builder-lib/src/options/PlatformSpecificBuildOptions.ts b/packages/app-builder-lib/src/options/PlatformSpecificBuildOptions.ts index 1871786c99a..9a95b2a3ffc 100644 --- a/packages/app-builder-lib/src/options/PlatformSpecificBuildOptions.ts +++ b/packages/app-builder-lib/src/options/PlatformSpecificBuildOptions.ts @@ -1,4 +1,3 @@ -import { AsarIntegrityOptions } from "../asar/integrity" import { CompressionLevel, Publish, TargetConfiguration, TargetSpecificOptions } from "../core" import { FileAssociation } from "./FileAssociation" @@ -17,7 +16,7 @@ export interface FileSet { filter?: Array | string } -export interface AsarOptions extends AsarIntegrityOptions { +export interface AsarOptions { /** * Whether to automatically unpack executables files. * @default true diff --git a/packages/app-builder-lib/src/platformPackager.ts b/packages/app-builder-lib/src/platformPackager.ts index 911397bed4d..ab4ebb7c5f6 100644 --- a/packages/app-builder-lib/src/platformPackager.ts +++ b/packages/app-builder-lib/src/platformPackager.ts @@ -280,10 +280,12 @@ export abstract class PlatformPackager } if (framework.beforeCopyExtraFiles != null) { + const resourcesRelativePath = this.platform === Platform.MAC ? "Resources" : isElectronBased(framework) ? "resources" : "" + await framework.beforeCopyExtraFiles({ packager: this, appOutDir, - asarIntegrity: asarOptions == null || disableAsarIntegrity ? null : await computeData(resourcesPath, asarOptions.externalAllowed ? { externalAllowed: true } : null), + asarIntegrity: asarOptions == null || disableAsarIntegrity ? null : await computeData({ resourcesPath, resourcesRelativePath }), platformName, }) } diff --git a/test/src/helpers/packTester.ts b/test/src/helpers/packTester.ts index ea00aa8762a..8173f8f203a 100644 --- a/test/src/helpers/packTester.ts +++ b/test/src/helpers/packTester.ts @@ -325,7 +325,7 @@ async function checkMacResult(packager: Packager, packagerOptions: PackagerOptio }) // checked manually, remove to avoid mismatch on CI server (where TRAVIS_BUILD_NUMBER is defined and different on each test run) - delete info.AsarIntegrity + delete info.ElectronAsarIntegrity delete info.CFBundleVersion delete info.BuildMachineOSBuild delete info.NSHumanReadableCopyright @@ -350,14 +350,12 @@ async function checkMacResult(packager: Packager, packagerOptions: PackagerOptio expect(info).toMatchSnapshot() - const checksumData = info.AsarIntegrity + const checksumData = info.ElectronAsarIntegrity if (checksumData != null) { - const data = JSON.parse(checksumData) - const checksums = data.checksums - for (const name of Object.keys(checksums)) { - checksums[name] = "hash" + for (const name of Object.keys(checksumData)) { + checksumData[name] = { "algorithm": "SHA256", "hash": "hash" } } - info.AsarIntegrity = JSON.stringify(data) + info.ElectronAsarIntegrity = JSON.stringify(checksumData) } if (checkOptions.checkMacApp != null) {