Browse Source

test: add tests for electron fuses (#42148)

test: add tests for electron fuses (#42129)

* spec: add tests for electron fuses

* spec: fix tests for windows

* spec: handle weird crash codes on win32

* spec: disable fuse tests on arm64 windows
Samuel Attard 11 months ago
parent
commit
1872411ae8

+ 1 - 1
shell/common/asar/archive.cc

@@ -115,7 +115,7 @@ bool FillFileInfoWithNode(Archive::FileInfo* info,
     info->executable = *executable;
   }
 
-#if BUILDFLAG(IS_MAC)
+#if BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN)
   if (load_integrity &&
       electron::fuses::IsEmbeddedAsarIntegrityValidationEnabled()) {
     if (const base::Value::Dict* integrity = node->FindDict("integrity")) {

+ 9 - 8
spec/api-autoupdater-darwin-spec.ts

@@ -7,9 +7,10 @@ import * as path from 'node:path';
 import * as psList from 'ps-list';
 import { AddressInfo } from 'node:net';
 import { ifdescribe, ifit } from './lib/spec-helpers';
-import { copyApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn, withTempDirectory } from './lib/codesign-helpers';
+import { copyMacOSFixtureApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn } from './lib/codesign-helpers';
 import * as uuid from 'uuid';
 import { autoUpdater, systemPreferences } from 'electron';
+import { withTempDirectory } from './lib/fs-helpers';
 
 // We can only test the auto updater on darwin non-component builds
 ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
@@ -65,7 +66,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
     if (!cachedZips[key]) {
       let updateZipPath: string;
       await withTempDirectory(async (dir) => {
-        const secondAppPath = await copyApp(dir, fixture);
+        const secondAppPath = await copyMacOSFixtureApp(dir, fixture);
         const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
         await fs.writeFile(
           appPJPath,
@@ -98,7 +99,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
   // On arm64 builds the built app is self-signed by default so the setFeedURL call always works
   ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
     await withTempDirectory(async (dir) => {
-      const appPath = await copyApp(dir);
+      const appPath = await copyMacOSFixtureApp(dir);
       const launchResult = await launchApp(appPath, ['http://myupdate']);
       console.log(launchResult);
       expect(launchResult.code).to.equal(1);
@@ -108,7 +109,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
 
   it('should cleanly set the feed URL when the app is signed', async () => {
     await withTempDirectory(async (dir) => {
-      const appPath = await copyApp(dir);
+      const appPath = await copyMacOSFixtureApp(dir);
       await signApp(appPath, identity);
       const launchResult = await launchApp(appPath, ['http://myupdate']);
       expect(launchResult.code).to.equal(0);
@@ -149,7 +150,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
 
     it('should hit the update endpoint when checkForUpdates is called', async () => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir, 'check');
+        const appPath = await copyMacOSFixtureApp(dir, 'check');
         await signApp(appPath, identity);
         server.get('/update-check', (req, res) => {
           res.status(204).send();
@@ -166,7 +167,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
 
     it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir, 'check-with-headers');
+        const appPath = await copyMacOSFixtureApp(dir, 'check-with-headers');
         await signApp(appPath, identity);
         server.get('/update-check', (req, res) => {
           res.status(204).send();
@@ -183,7 +184,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
 
     it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir, 'update');
+        const appPath = await copyMacOSFixtureApp(dir, 'update');
         await signApp(appPath, identity);
         server.get('/update-file', (req, res) => {
           res.status(500).send('This is not a file');
@@ -217,7 +218,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
       mutateAppPostSign?: Mutation;
     }, fn: (appPath: string, zipPath: string) => Promise<void>) => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir, opts.startFixture);
+        const appPath = await copyMacOSFixtureApp(dir, opts.startFixture);
         await opts.mutateAppPreSign?.mutate(appPath);
         const infoPath = path.resolve(appPath, 'Contents', 'Info.plist');
         await fs.writeFile(

+ 189 - 0
spec/asar-integrity-spec.ts

@@ -0,0 +1,189 @@
+import { expect } from 'chai';
+import * as cp from 'node:child_process';
+import * as nodeCrypto from 'node:crypto';
+import * as originalFs from 'node:original-fs';
+import * as fs from 'fs-extra';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { ifdescribe } from './lib/spec-helpers';
+
+import { getRawHeader } from '@electron/asar';
+import { resedit } from '@electron/packager/dist/resedit';
+import { flipFuses, FuseV1Config, FuseV1Options, FuseVersion } from '@electron/fuses';
+import { copyApp } from './lib/fs-helpers';
+
+const bufferReplace = (haystack: Buffer, needle: string, replacement: string, throwOnMissing = true): Buffer => {
+  const needleBuffer = Buffer.from(needle);
+  const idx = haystack.indexOf(needleBuffer);
+  if (idx === -1) {
+    if (throwOnMissing) throw new Error(`Needle ${needle} not found in haystack`);
+    return haystack;
+  }
+
+  const before = haystack.slice(0, idx);
+  const after = bufferReplace(haystack.slice(idx + needleBuffer.length), needle, replacement, false);
+  const len = idx + replacement.length + after.length;
+  return Buffer.concat([before, Buffer.from(replacement), after], len);
+};
+
+type SpawnResult = { code: number | null, out: string, signal: NodeJS.Signals | null };
+function spawn (cmd: string, args: string[], opts: any = {}) {
+  let out = '';
+  const child = cp.spawn(cmd, args, opts);
+  child.stdout.on('data', (chunk: Buffer) => {
+    out += chunk.toString();
+  });
+  child.stderr.on('data', (chunk: Buffer) => {
+    out += chunk.toString();
+  });
+  return new Promise<SpawnResult>((resolve) => {
+    child.on('exit', (code, signal) => {
+      resolve({
+        code,
+        signal,
+        out
+      });
+    });
+  });
+};
+
+const expectToHaveCrashed = (res: SpawnResult) => {
+  if (process.platform === 'win32') {
+    expect(res.code).to.not.equal(0);
+    expect(res.code).to.not.equal(null);
+    expect(res.signal).to.equal(null);
+  } else {
+    expect(res.code).to.equal(null);
+    expect(res.signal).to.equal('SIGTRAP');
+  }
+};
+
+describe('fuses', function () {
+  this.timeout(120000);
+
+  let tmpDir: string;
+  let appPath: string;
+
+  const launchApp = (args: string[] = []) => {
+    if (process.platform === 'darwin') {
+      return spawn(path.resolve(appPath, 'Contents/MacOS/Electron'), args);
+    }
+    return spawn(appPath, args);
+  };
+
+  const ensureFusesBeforeEach = (fuses: Omit<FuseV1Config<boolean>, 'version' | 'strictlyRequireAllFuses' | 'resetAdhocDarwinSignature'>) => {
+    beforeEach(async () => {
+      await flipFuses(appPath, {
+        version: FuseVersion.V1,
+        resetAdHocDarwinSignature: true,
+        ...fuses
+      });
+    });
+  };
+
+  beforeEach(async () => {
+    tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-asar-integrity-spec-'));
+    appPath = await copyApp(tmpDir);
+  });
+
+  afterEach(async () => {
+    for (let attempt = 0; attempt <= 3; attempt++) {
+      // Somtimes windows holds on to a DLL during the crash for a little bit, so we try a few times to delete it
+      if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
+      try {
+        await originalFs.promises.rm(tmpDir, { recursive: true });
+        break;
+      } catch {}
+    }
+  });
+
+  ifdescribe((process.platform === 'win32' && process.arch !== 'arm64') || process.platform === 'darwin')('ASAR Integrity', () => {
+    let pathToAsar: string;
+
+    beforeEach(async () => {
+      if (process.platform === 'darwin') {
+        pathToAsar = path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar');
+      } else {
+        pathToAsar = path.resolve(path.dirname(appPath), 'resources', 'default_app.asar');
+      }
+
+      if (process.platform === 'win32') {
+        await resedit(appPath, {
+          asarIntegrity: {
+            'resources\\default_app.asar': {
+              algorithm: 'SHA256',
+              hash: nodeCrypto.createHash('sha256').update(getRawHeader(pathToAsar).headerString).digest('hex')
+            }
+          }
+        });
+      }
+    });
+
+    describe('when enabled', () => {
+      ensureFusesBeforeEach({
+        [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true
+      });
+
+      it('opens normally when unmodified', async () => {
+        const res = await launchApp([path.resolve(__dirname, 'fixtures/apps/hello/hello.js')]);
+        expect(res.code).to.equal(0);
+        expect(res.signal).to.equal(null);
+        expect(res.out).to.include('alive');
+      });
+
+      it('fatals if the integrity header does not match', async () => {
+        const asar = await originalFs.promises.readFile(pathToAsar);
+        // Ensure that the header stil starts with the same thing, if build system
+        // things result in the header changing we should update this test
+        expect(asar.toString()).to.contain('{"files":{"default_app.js"');
+        await originalFs.promises.writeFile(pathToAsar, bufferReplace(asar, '{"files":{"default_app.js"', '{"files":{"default_oop.js"'));
+
+        const res = await launchApp(['--version']);
+        expectToHaveCrashed(res);
+        expect(res.out).to.include('Integrity check failed for asar archive');
+      });
+
+      it('fatals if a loaded main process JS file does not match', async () => {
+        const asar = await originalFs.promises.readFile(pathToAsar);
+        // Ensure that the header stil starts with the same thing, if build system
+        // things result in the header changing we should update this test
+        expect(asar.toString()).to.contain('Invalid Usage');
+        await originalFs.promises.writeFile(pathToAsar, bufferReplace(asar, 'Invalid Usage', 'VVValid Usage'));
+
+        const res = await launchApp(['--version']);
+        expect(res.code).to.equal(1);
+        expect(res.signal).to.equal(null);
+        expect(res.out).to.include('ASAR Integrity Violation: got a hash mismatch');
+      });
+
+      it('fatals if a renderer content file does not match', async () => {
+        const asar = await originalFs.promises.readFile(pathToAsar);
+        // Ensure that the header stil starts with the same thing, if build system
+        // things result in the header changing we should update this test
+        expect(asar.toString()).to.contain('require-trusted-types-for');
+        await originalFs.promises.writeFile(pathToAsar, bufferReplace(asar, 'require-trusted-types-for', 'require-trusted-types-not'));
+
+        const res = await launchApp();
+        expectToHaveCrashed(res);
+        expect(res.out).to.include('Failed to validate block while ending ASAR file stream');
+      });
+    });
+
+    describe('when disabled', () => {
+      ensureFusesBeforeEach({
+        [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false
+      });
+
+      it('does nothing if the integrity header does not match', async () => {
+        const asar = await originalFs.promises.readFile(pathToAsar);
+        // Ensure that the header stil starts with the same thing, if build system
+        // things result in the header changing we should update this test
+        expect(asar.toString()).to.contain('{"files":{"default_app.js"');
+        await originalFs.promises.writeFile(pathToAsar, bufferReplace(asar, '{"files":{"default_app.js"', '{"files":{"default_oop.js"'));
+
+        const res = await launchApp(['--version']);
+        expect(res.code).to.equal(0);
+      });
+    });
+  });
+});

+ 2 - 0
spec/fixtures/apps/hello/hello.js

@@ -0,0 +1,2 @@
+console.log('alive');
+process.exit(0);

+ 1 - 13
spec/lib/codesign-helpers.ts

@@ -1,6 +1,5 @@
 import * as cp from 'node:child_process';
 import * as fs from 'fs-extra';
-import * as os from 'node:os';
 import * as path from 'node:path';
 import { expect } from 'chai';
 
@@ -32,7 +31,7 @@ export function getCodesignIdentity () {
   return identity;
 }
 
-export async function copyApp (newDir: string, fixture: string | null = 'initial') {
+export async function copyMacOSFixtureApp (newDir: string, fixture: string | null = 'initial') {
   const appBundlePath = path.resolve(process.execPath, '../../..');
   const newPath = path.resolve(newDir, 'Electron.app');
   cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
@@ -86,14 +85,3 @@ export function spawn (cmd: string, args: string[], opts: any = {}) {
 export function signApp (appPath: string, identity: string) {
   return spawn('codesign', ['-s', identity, '--deep', '--force', appPath]);
 };
-
-export async function withTempDirectory (fn: (dir: string) => Promise<void>, autoCleanUp = true) {
-  const dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
-  try {
-    await fn(dir);
-  } finally {
-    if (autoCleanUp) {
-      cp.spawnSync('rm', ['-r', dir]);
-    }
-  }
-};

+ 40 - 0
spec/lib/fs-helpers.ts

@@ -0,0 +1,40 @@
+import * as cp from 'node:child_process';
+import * as fs from 'original-fs';
+import * as fsExtra from 'fs-extra';
+import * as os from 'node:os';
+import * as path from 'node:path';
+
+export async function copyApp (targetDir: string): Promise<string> {
+  // On macOS we can just copy the app bundle, easier too because of symlinks
+  if (process.platform === 'darwin') {
+    const appBundlePath = path.resolve(process.execPath, '../../..');
+    const newPath = path.resolve(targetDir, 'Electron.app');
+    cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
+    return newPath;
+  }
+
+  // On windows and linux we should read the zip manifest files and then copy each of those files
+  // one by one
+  const baseDir = path.dirname(process.execPath);
+  const zipManifestPath = path.resolve(__dirname, '..', '..', 'script', 'zip_manifests', `dist_zip.${process.platform === 'win32' ? 'win' : 'linux'}.${process.arch}.manifest`);
+  const filesToCopy = (fs.readFileSync(zipManifestPath, 'utf-8')).split('\n').filter(f => f !== 'LICENSE' && f !== 'LICENSES.chromium.html' && f !== 'version' && f.trim());
+  await Promise.all(
+    filesToCopy.map(async rel => {
+      await fsExtra.mkdirp(path.dirname(path.resolve(targetDir, rel)));
+      fs.copyFileSync(path.resolve(baseDir, rel), path.resolve(targetDir, rel));
+    })
+  );
+
+  return path.resolve(targetDir, path.basename(process.execPath));
+}
+
+export async function withTempDirectory (fn: (dir: string) => Promise<void>, autoCleanUp = true) {
+  const dir = await fsExtra.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
+  try {
+    await fn(dir);
+  } finally {
+    if (autoCleanUp) {
+      cp.spawnSync('rm', ['-r', dir]);
+    }
+  }
+};

+ 5 - 4
spec/node-spec.ts

@@ -4,10 +4,11 @@ import * as fs from 'fs-extra';
 import * as path from 'node:path';
 import * as util from 'node:util';
 import { getRemoteContext, ifdescribe, ifit, itremote, useRemoteContext } from './lib/spec-helpers';
-import { copyApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn, withTempDirectory } from './lib/codesign-helpers';
+import { copyMacOSFixtureApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn } from './lib/codesign-helpers';
 import { webContents } from 'electron/main';
 import { EventEmitter } from 'node:stream';
 import { once } from 'node:events';
+import { withTempDirectory } from './lib/fs-helpers';
 
 const mainFixturesPath = path.resolve(__dirname, 'fixtures');
 
@@ -683,7 +684,7 @@ describe('node feature', () => {
 
     it('is disabled when invoked by other apps in ELECTRON_RUN_AS_NODE mode', async () => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir);
+        const appPath = await copyMacOSFixtureApp(dir);
         await signApp(appPath, identity);
         // Invoke Electron by using the system node binary as middle layer, so
         // the check of NODE_OPTIONS will think the process is started by other
@@ -696,7 +697,7 @@ describe('node feature', () => {
 
     it('is disabled when invoked by alien binary in app bundle in ELECTRON_RUN_AS_NODE mode', async function () {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir);
+        const appPath = await copyMacOSFixtureApp(dir);
         await signApp(appPath, identity);
         // Find system node and copy it to app bundle.
         const nodePath = process.env.PATH?.split(path.delimiter).find(dir => fs.existsSync(path.join(dir, 'node')));
@@ -715,7 +716,7 @@ describe('node feature', () => {
 
     it('is respected when invoked from self', async () => {
       await withTempDirectory(async (dir) => {
-        const appPath = await copyApp(dir, null);
+        const appPath = await copyMacOSFixtureApp(dir, null);
         await signApp(appPath, identity);
         const appExePath = path.join(appPath, 'Contents/MacOS/Electron');
         const { code, out } = await spawn(appExePath, [script, appExePath]);

+ 2 - 0
spec/package.json

@@ -10,6 +10,8 @@
     "@electron-ci/echo": "file:./fixtures/native-addon/echo",
     "@electron-ci/is-valid-window": "file:./is-valid-window",
     "@electron-ci/uv-dlopen": "file:./fixtures/native-addon/uv-dlopen/",
+    "@electron/fuses": "^1.8.0",
+    "@electron/packager": "^18.3.2",
     "@marshallofsound/mocha-appveyor-reporter": "^0.4.3",
     "@types/sinon": "^9.0.4",
     "@types/ws": "^7.2.0",

File diff suppressed because it is too large
+ 665 - 12
spec/yarn.lock


Some files were not shown because too many files changed in this diff