diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93f024b..6a620e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ jobs: test: name: Test strategy: + fail-fast: false matrix: node-version: - '20.5' diff --git a/jest.setup.ts b/jest.setup.ts index 296a148..9fdaeb8 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,10 +1,10 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { appsDir, asarsDir, templateApp } from './test/util'; +import { appsPath, asarsDir, templateApp } from './test/util'; export default async () => { - await fs.remove(appsDir); - await fs.mkdirp(appsDir); + await fs.remove(appsPath); + await fs.mkdirp(appsPath); await templateApp('Arm64Asar.app', 'arm64', async (appPath) => { await fs.copy( path.resolve(asarsDir, 'app.asar'), diff --git a/src/file-utils.ts b/src/file-utils.ts index 7a603a8..08dc1e3 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -45,7 +45,7 @@ export const getAllAppFiles = async (appPath: string): Promise => { throw e; } } - if (p.includes('app.asar')) { + if (p.endsWith('.asar')) { fileType = AppFileType.APP_CODE; } else if (fileOutput.startsWith(MACHO_PREFIX)) { fileType = AppFileType.MACHO; diff --git a/src/index.ts b/src/index.ts index c7492a6..5a1105e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,10 @@ import * as plist from 'plist'; import * as dircompare from 'dir-compare'; import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; -import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './asar-utils'; +import { AsarMode, detectAsarMode, mergeASARs } from './asar-utils'; import { sha } from './sha'; import { d } from './debug'; +import { computeIntegrityData } from './integrity'; /** * Options to pass into the {@link makeUniversalApp} function. @@ -251,9 +252,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = } } - const generatedIntegrity: Record = {}; - let didSplitAsar = false; - /** * If we have an ASAR we just need to check if the two "app.asar" files have the same hash, * if they are, same as above, we can leave one there and call it a day. If they're different @@ -271,8 +269,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = outputAsarPath: output, singleArchFiles: opts.singleArchFiles, }); - - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output); } else if (x64AsarMode === AsarMode.HAS_ASAR) { d('checking if the x64 and arm64 asars are identical'); const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); @@ -281,7 +277,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = ); if (x64AsarSha !== arm64AsarSha) { - didSplitAsar = true; d('x64 and arm64 asars are different'); const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); @@ -329,18 +324,13 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); await asar.createPackage(entryAsar, asarPath); - - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(asarPath); - generatedIntegrity['Resources/app-x64.asar'] = generateAsarIntegrity(x64AsarPath); - generatedIntegrity['Resources/app-arm64.asar'] = generateAsarIntegrity(arm64AsarPath); } else { d('x64 and arm64 asars are the same'); - generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity( - path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), - ); } } + const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents')); + const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); for (const plistFile of plistFiles) { const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); diff --git a/src/integrity.ts b/src/integrity.ts new file mode 100644 index 0000000..51e1508 --- /dev/null +++ b/src/integrity.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import { AppFileType, getAllAppFiles } from './file-utils'; +import { sha } from './sha'; +import { generateAsarIntegrity } from './asar-utils'; + +type IntegrityMap = { + [filepath: string]: string; +}; + +export interface HeaderHash { + algorithm: 'SHA256'; + hash: string; +} + +export interface AsarIntegrity { + [key: string]: HeaderHash; +} + +export async function computeIntegrityData(contentsPath: string): Promise { + const root = await fs.realpath(contentsPath); + + const resourcesRelativePath = 'Resources'; + const resourcesPath = path.resolve(root, resourcesRelativePath); + + const resources = await getAllAppFiles(resourcesPath); + const resourceAsars = resources + .filter((file) => file.type === AppFileType.APP_CODE) + .reduce( + (prev, file) => ({ + ...prev, + [path.join(resourcesRelativePath, file.relativePath)]: path.join( + resourcesPath, + file.relativePath, + ), + }), + {}, + ); + + // sort to produce constant result + const allAsars = Object.entries(resourceAsars).sort(([name1], [name2]) => + name1.localeCompare(name2), + ); + const hashes = await Promise.all(allAsars.map(async ([, from]) => generateAsarIntegrity(from))); + const asarIntegrity: AsarIntegrity = {}; + for (let i = 0; i < allAsars.length; i++) { + const [asar] = allAsars[i]; + asarIntegrity[asar] = hashes[i]; + } + return asarIntegrity; +} diff --git a/test/__snapshots__/index.spec.ts.snap b/test/__snapshots__/index.spec.ts.snap index 2d0341d..895df3c 100644 --- a/test/__snapshots__/index.spec.ts.snap +++ b/test/__snapshots__/index.spec.ts.snap @@ -157,6 +157,137 @@ exports[`makeUniversalApp asar mode should create a shim if asars are different } `; +exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 1`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + ], + "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + }, + "size": 66, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + "private": { + "files": { + "var": { + "files": { + "app": { + "files": { + "file.txt": { + "link": "private/var/file.txt", + }, + }, + }, + "file.txt": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ], + "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + "size": 11, + }, + }, + }, + }, + }, + "var": { + "link": "private/var", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 2`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + ], + "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + }, + "size": 66, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + "private": { + "files": { + "var": { + "files": { + "app": { + "files": { + "file.txt": { + "link": "private/var/file.txt", + }, + }, + }, + "file.txt": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ], + "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + "size": 11, + }, + }, + }, + }, + }, + "var": { + "link": "private/var", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 3`] = ` +{ + "Contents/Info.plist": { + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832", + }, + "Resources/webbapp.asar": { + "algorithm": "SHA256", + "hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832", + }, + }, +} +`; + exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 1`] = ` { "files": { @@ -405,6 +536,11 @@ exports[`makeUniversalApp no asar mode should shim two different app folders 4`] exports[`makeUniversalApp no asar mode should shim two different app folders 5`] = ` { - "Contents/Info.plist": {}, + "Contents/Info.plist": { + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "27433ee3e34b3b0dabb29d18d40646126e80c56dbce8c4bb2adef7278b5a46c0", + }, + }, } `; diff --git a/test/index.spec.ts b/test/index.spec.ts index fffc2b9..70cf7e2 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -2,12 +2,16 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { makeUniversalApp } from '../dist/cjs/index'; -import { createTestApp, templateApp, VERIFY_APP_TIMEOUT, verifyApp } from './util'; +import { + appsPath, + appsOutPath, + createTestApp, + templateApp, + VERIFY_APP_TIMEOUT, + verifyApp, +} from './util'; import { createPackage } from '@electron/asar'; -const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); -const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); - // See `jest.setup.ts` for app fixture setup process describe('makeUniversalApp', () => { afterEach(async () => { @@ -156,6 +160,44 @@ describe('makeUniversalApp', () => { }, VERIFY_APP_TIMEOUT, ); + it( + 'should generate AsarIntegrity for all asars in the application', + async () => { + const { testPath } = await createTestApp('app-2'); + const testAsarPath = path.resolve(appsOutPath, 'app-2.asar'); + await createPackage(testPath, testAsarPath); + + const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => { + await fs.copyFile( + testAsarPath, + path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), + ); + await fs.copyFile( + testAsarPath, + path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'), + ); + }); + const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => { + await fs.copyFile( + testAsarPath, + path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), + ); + await fs.copyFile( + testAsarPath, + path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'), + ); + }); + const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app'); + await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath, + mergeASARs: true, + }); + await verifyApp(outAppPath); + }, + VERIFY_APP_TIMEOUT, + ); }); describe('no asar mode', () => { diff --git a/test/util.ts b/test/util.ts index 03a9d6d..a7c548a 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,3 +1,4 @@ +import { getRawHeader } from '@electron/asar'; import { downloadArtifact } from '@electron/get'; import { spawn } from '@malept/cross-spawn-promise'; import * as zip from 'cross-zip'; @@ -5,7 +6,6 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import plist from 'plist'; import * as fileUtils from '../dist/cjs/file-utils'; -import { getRawHeader } from '@electron/asar'; // We do a LOT of verifications in `verifyApp` 😅 // exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries @@ -13,7 +13,8 @@ import { getRawHeader } from '@electron/asar'; export const VERIFY_APP_TIMEOUT = 80 * 1000; export const asarsDir = path.resolve(__dirname, 'fixtures', 'asars'); -export const appsDir = path.resolve(__dirname, 'fixtures', 'apps'); +export const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); +export const appsOutPath = path.resolve(appsPath, 'out'); export const verifyApp = async (appPath: string) => { await ensureUniversal(appPath); @@ -129,7 +130,7 @@ export const createTestApp = async ( additionalFiles: Record = {}, ) => { const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions - const testPath = path.join(appsDir, outDir); + const testPath = path.join(appsPath, outDir); await fs.remove(testPath); await fs.copy(path.join(asarsDir, 'app'), testPath); @@ -170,9 +171,9 @@ export const templateApp = async ( platform: 'darwin', arch, }); - const appPath = path.resolve(appsDir, name); - zip.unzipSync(electronZip, appsDir); - await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath); + const appPath = path.resolve(appsPath, name); + zip.unzipSync(electronZip, appsPath); + await fs.rename(path.resolve(appsPath, 'Electron.app'), appPath); await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar')); await modify(appPath);