diff --git a/src/common.ts b/src/common.ts index 3a052532..063f96f3 100644 --- a/src/common.ts +++ b/src/common.ts @@ -99,6 +99,34 @@ export function normalizePath(pathToNormalize: string) { return pathToNormalize.replace(/\\/g, '/'); } +/** + * Resolves a path and canonicalizes it via `realpath`, so that two paths referring to the same + * location compare as equal even when one of them goes through a symlink (e.g. `/var` vs + * `/private/var` on macOS). Falls back to the resolved path if it does not exist. + */ +function canonicalizePath(pathToCanonicalize: string): string { + const resolvedPath = path.resolve(pathToCanonicalize); + try { + return fs.realpathSync(resolvedPath); + } catch { + return resolvedPath; + } +} + +/** + * Whether `childPath` is equal to or contained within `parentPath`. Both paths must already be + * canonicalized. Uses `path.relative` so that, on Windows, the comparison is case-insensitive. + */ +function isPathInside(childPath: string, parentPath: string): boolean { + const relativePath = path.relative(parentPath, childPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +function findOutDirContaining(fullPath: string, ignoredOutDirs: string[]): string | undefined { + const canonicalFullPath = canonicalizePath(fullPath); + return ignoredOutDirs.find((outDir) => isPathInside(canonicalFullPath, canonicalizePath(outDir))); +} + /** * Validates that the application directory contains a package.json file, and that there exists an * appropriate main entry point file, per the rules of the "main" field in package.json. @@ -107,13 +135,28 @@ export function normalizePath(pathToNormalize: string) { * * @param appDir - the directory specified by the user * @param bundledAppDir - the directory where the appDir is copied to in the bundled Electron app + * @param ignoredOutDirs - resolved out directories that are excluded from the bundled app */ -export async function validateElectronApp(appDir: string, bundledAppDir: string) { +export async function validateElectronApp( + appDir: string, + bundledAppDir: string, + ignoredOutDirs: string[] = [], +) { debug('Validating bundled Electron app'); debug('Checking for a package.json file'); const bundledPackageJSONPath = path.join(bundledAppDir, 'package.json'); if (!fs.existsSync(bundledPackageJSONPath)) { + const canonicalAppDir = canonicalizePath(appDir); + if ( + ignoredOutDirs.some( + (outDir) => path.relative(canonicalizePath(outDir), canonicalAppDir) === '', + ) + ) { + throw new Error( + `The out directory (${path.resolve(appDir)}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + } const originalPackageJSONPath = path.join(appDir, 'package.json'); throw new Error( `Application manifest was not found. Make sure "${originalPackageJSONPath}" exists and does not get ignored by your ignore option`, @@ -126,6 +169,12 @@ export async function validateElectronApp(appDir: string, bundledAppDir: string) const mainScript = path.resolve(bundledAppDir, mainScriptBasename); if (!fs.existsSync(mainScript)) { const originalMainScript = path.join(appDir, mainScriptBasename); + const outDir = findOutDirContaining(path.resolve(appDir, mainScriptBasename), ignoredOutDirs); + if (outDir) { + throw new Error( + `The out directory (${outDir}) is inside your app directory and contains your app's main entry point (${originalMainScript}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + } throw new Error( `The main entry point to your app was not found. Make sure "${originalMainScript}" exists and does not get ignored by your ignore option`, ); diff --git a/src/platform.ts b/src/platform.ts index 1c4a91f0..5284c686 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -15,7 +15,7 @@ import { validateElectronApp, warning, } from './common.js'; -import { userPathFilter } from './copy-filter.js'; +import { generateIgnoredOutDirs, userPathFilter } from './copy-filter.js'; import { runHooks } from './hooks.js'; import crypto from 'node:crypto'; import type { ProcessedOptionsWithSinglePlatformArch } from './types.js'; @@ -176,7 +176,11 @@ export class App { async buildApp() { await this.copyTemplate(); - await validateElectronApp(this.opts.dir, this.originalResourcesAppDir); + await validateElectronApp( + this.opts.dir, + this.originalResourcesAppDir, + generateIgnoredOutDirs(this.opts), + ); await this.asarApp(); } diff --git a/test/common.spec.ts b/test/common.spec.ts index a0525439..ff24b434 100644 --- a/test/common.spec.ts +++ b/test/common.spec.ts @@ -104,6 +104,26 @@ describe('validateElectronApp', () => { `The main entry point to your app was not found. Make sure "${path.join('original-dir', 'main.js')}" exists and does not get ignored by your ignore option`, ); }); + + it('fails on an Electron app whose app directory is the out directory', async () => { + const fixture = path.join(__dirname, 'fixtures', 'validate-failure-without-package-json'); + + await expect( + validateElectronApp('original-dir', fixture, [path.resolve('original-dir')]), + ).rejects.toThrow( + `The out directory (${path.resolve('original-dir')}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + }); + + it('fails on an Electron app whose main entry point is inside the out directory', async () => { + const fixture = path.join(__dirname, 'fixtures', 'validate-failure-main-in-out-dir'); + + await expect( + validateElectronApp('original-dir', fixture, [path.resolve('original-dir', 'dist')]), + ).rejects.toThrow( + `The out directory (${path.resolve('original-dir', 'dist')}) is inside your app directory and contains your app's main entry point (${path.join('original-dir', 'dist', 'main.js')}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + }); }); describe('createAsarOpts', () => { diff --git a/test/fixtures/validate-failure-main-in-out-dir/package.json b/test/fixtures/validate-failure-main-in-out-dir/package.json new file mode 100644 index 00000000..5c29fa61 --- /dev/null +++ b/test/fixtures/validate-failure-main-in-out-dir/package.json @@ -0,0 +1,3 @@ +{ + "main": "dist/main.js" +} diff --git a/test/packager.spec.ts b/test/packager.spec.ts index 63c57de0..b4d8ac22 100644 --- a/test/packager.spec.ts +++ b/test/packager.spec.ts @@ -505,6 +505,49 @@ describe('packager', () => { expect(fs.existsSync(path.join(resourcesPath, 'app', path.basename(opts.out)))).toBe(false); }); + it('should fail with a descriptive error if the out dir is the app dir', async ({ baseOpts }) => { + const opts = { + ...baseOpts, + out: baseOpts.dir, + }; + + await expect(packager(opts)).rejects.toThrowError( + `The out directory (${path.resolve(opts.dir)}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + }); + + it('should fail with a descriptive error if the out dir contains the main entry point', async ({ + baseOpts, + }) => { + const fixture = path.join(__dirname, 'fixtures', 'basic'); + const appDir = path.join(baseOpts.out, 'app'); + await fs.promises.cp(fixture, appDir, { + dereference: true, + filter: (file) => path.basename(file) !== 'node_modules', + recursive: true, + }); + const packageJSON = JSON.parse( + await fs.promises.readFile(path.join(appDir, 'package.json'), 'utf8'), + ); + packageJSON.main = 'dist/main.js'; + await fs.promises.writeFile( + path.join(appDir, 'package.json'), + JSON.stringify(packageJSON, null, 2), + ); + await fs.promises.mkdir(path.join(appDir, 'dist'), { recursive: true }); + await fs.promises.rename(path.join(appDir, 'main.js'), path.join(appDir, 'dist', 'main.js')); + + const opts = { + ...baseOpts, + dir: appDir, + out: path.join(appDir, 'dist'), + }; + + await expect(packager(opts)).rejects.toThrowError( + `The out directory (${path.join(appDir, 'dist')}) is inside your app directory and contains your app's main entry point (${path.join(appDir, 'dist', 'main.js')}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + }); + describe('hooks', () => { it.for([ {