Skip to content

Commit

Permalink
feat(mac): ElectronAsarIntegrity in electron@15
Browse files Browse the repository at this point in the history
  • Loading branch information
indutny-signal committed Jan 5, 2022
1 parent 67b84ad commit ccc2e47
Show file tree
Hide file tree
Showing 10 changed files with 133 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/nervous-pandas-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"app-builder-lib": minor
---

feat(mac): ElectronAsarIntegrity in electron@15
5 changes: 0 additions & 5 deletions packages/app-builder-lib/scheme.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,6 @@
"AsarOptions": {
"additionalProperties": false,
"properties": {
"externalAllowed": {
"default": false,
"description": "Allows external asar files.",
"type": "boolean"
},
"ordering": {
"type": [
"null",
Expand Down
27 changes: 24 additions & 3 deletions packages/app-builder-lib/src/asar/asar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}

/** @internal */
export class Node {
// we don't use Map because later it will be stringified
Expand All @@ -16,6 +30,8 @@ export class Node {
executable?: boolean

link?: string

integrity?: NodeIntegrity
}

/** @internal */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -114,7 +131,7 @@ export class AsarFilesystem {
}
}

export async function readAsar(archive: string): Promise<AsarFilesystem> {
export async function readAsarHeader(archive: string): Promise<ReadAsarHeader> {
const fd = await open(archive, "r")
let size: number
let headerBuf
Expand All @@ -135,7 +152,11 @@ export async function readAsar(archive: string): Promise<AsarFilesystem> {
}

const headerPickle = createFromBuffer(headerBuf)
const header = headerPickle.createIterator().readString()
return { header: headerPickle.createIterator().readString(), size }
}

export async function readAsar(archive: string): Promise<AsarFilesystem> {
const { header, size } = await readAsarHeader(archive)
return new AsarFilesystem(archive, JSON.parse(header), size)
}

Expand Down
6 changes: 4 additions & 2 deletions packages/app-builder-lib/src/asar/asarUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
104 changes: 84 additions & 20 deletions packages/app-builder-lib/src/asar/integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AsarIntegrity> {
export interface AsarIntegrity {
[key: string]: HeaderHash
}

export async function computeData({ resourcesPath, resourcesRelativePath }: AsarIntegrityOptions): Promise<AsarIntegrity> {
// 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<HeaderHash> {
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<string>((resolve, reject) => {
const hash = createHash(algorithm)
hash.on("error", reject).setEncoding(encoding)
export function hashFile(file: string, blockSize = 4 * 1024 * 1024): Promise<NodeIntegrity> {
return new Promise<NodeIntegrity>((resolve, reject) => {
const hash = createHash("sha256")

const blocks = new Array<string>()

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<string>()

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,
}
}
2 changes: 1 addition & 1 deletion packages/app-builder-lib/src/electron/electronMac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AsarIntegrityOptions } from "../asar/integrity"
import { CompressionLevel, Publish, TargetConfiguration, TargetSpecificOptions } from "../core"
import { FileAssociation } from "./FileAssociation"

Expand All @@ -17,7 +16,7 @@ export interface FileSet {
filter?: Array<string> | string
}

export interface AsarOptions extends AsarIntegrityOptions {
export interface AsarOptions {
/**
* Whether to automatically unpack executables files.
* @default true
Expand Down
4 changes: 3 additions & 1 deletion packages/app-builder-lib/src/platformPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,12 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
}

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,
})
}
Expand Down
8 changes: 6 additions & 2 deletions test/src/BuildTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,14 @@ export function removeUnstableProperties(data: any) {
JSON.stringify(data, (name, value) => {
if (name === "offset") {
return undefined
} else if (name.endsWith(".node") && value.size != null) {
}
if (name.endsWith(".node") && value.size != null) {
// size differs on various OS
value.size = "<size>"
return value
}
// Keep existing test coverage
if (value.integrity) {
delete value.integrity
}
return value
})
Expand Down
12 changes: 5 additions & 7 deletions test/src/helpers/packTester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down

0 comments on commit ccc2e47

Please sign in to comment.