Skip to content

Commit 841a27f

Browse files
committed
migrating packTester to leverage corepack for isolating text/fixture environments and using specific installation methods (+ snapshot verification) of package manager implementations
1 parent 144c5ed commit 841a27f

File tree

180 files changed

+44271
-9108
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

180 files changed

+44271
-9108
lines changed

.github/workflows/test.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
- ArtifactPublisherTest,BuildTest,ExtraBuildTest,RepoSlugTest,binDownloadTest,configurationValidationTest,filenameUtilTest,filesTest,globTest,httpExecutorTest,ignoreTest,macroExpanderTest,mainEntryTest,urlUtilTest,extraMetadataTest,linuxArchiveTest,linuxPackagerTest,HoistedNodeModuleTest,MemoLazyTest,HoistTest,ExtraBuildResourcesTest,utilTest
7171
- snapTest,debTest,fpmTest,protonTest
7272
- winPackagerTest,winCodeSignTest,webInstallerTest
73-
- oneClickInstallerTest,assistedInstallerTest
73+
- oneClickInstallerTest,assistedInstallerTest,packageManagerTest
7474
- concurrentBuildsTest
7575
steps:
7676
- name: Checkout code repository
@@ -188,7 +188,7 @@ jobs:
188188
fail-fast: false
189189
matrix:
190190
testFiles:
191-
- winCodeSignTest,differentialUpdateTest,squirrelWindowsTest
191+
- winCodeSignTest,differentialUpdateTest,squirrelWindowsTest,HoistedNodeModuleTest
192192
- appxTest,msiTest,portableTest,assistedInstallerTest,protonTest
193193
- BuildTest,oneClickInstallerTest,winPackagerTest,nsisUpdaterTest,webInstallerTest
194194
- concurrentBuildsTest

.vscode/launch.json

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
{
22
"version": "0.2.0",
33
"configurations": [
4-
{
5-
"type": "node",
6-
"request": "launch",
7-
"name": "Vitest TEST_FILES",
8-
"runtimeExecutable": "pnpm",
9-
"program": "ci:test",
10-
"console": "integratedTerminal",
11-
"internalConsoleOptions": "openOnFirstSessionStart",
12-
"env": {
13-
"TEST_FILES": "macPackagerTest",
14-
"UPDATE_SNAPSHOT": "false"
15-
}
16-
}
4+
{
5+
"type": "node",
6+
"request": "launch",
7+
"name": "Vitest TEST_FILES",
8+
"runtimeExecutable": "pnpm",
9+
"program": "ci:test",
10+
"console": "integratedTerminal",
11+
"internalConsoleOptions": "openOnFirstSessionStart",
12+
"env": {
13+
"TEST_FILES": "packageManagerTest",
14+
"UPDATE_SNAPSHOT": "false",
15+
"DEBUG": "electron-builder",
16+
"TEST_SEQUENTIAL": "true" // Run tests sequentially to debug issues that may be hidden by concurrency (console log pollution when DEBUG flag set)
17+
},
18+
"skipFiles": [
19+
"<node_internals>/**", // Skip Node’s internal modules
20+
"${workspaceFolder}/**/node_modules/**/*.js", // Skip libraries
21+
"**/*.js" // Optionally skip all compiled JS
22+
]
23+
}
1724
]
1825
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"prettier": "prettier 'packages/**/*.{ts,js}' test/src/**/*.ts --write",
2020
"///": "Please see https://github.com/electron-userland/electron-builder/blob/master/CONTRIBUTING.md#run-test-using-cli how to run particular test instead full (and very slow) run",
2121
"test-all": "pnpm compile && pnpm pretest && pnpm ci:test",
22-
"test-linux": "docker run --rm -e CI=${CI:-1} -e DEBUG=${DEBUG:-} -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -w /project -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder ${ADDITIONAL_DOCKER_ARGS} ${TEST_RUNNER_IMAGE_TAG:-electronuserland/builder:22-wine-mono} /bin/bash -c \"corepack enable && pnpm install && pnpm ci:test\"",
22+
"test-linux": "docker run --rm -e CI=${CI:-true} -e DEBUG=${DEBUG:-} -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -w /project -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder ${ADDITIONAL_DOCKER_ARGS} ${TEST_RUNNER_IMAGE_TAG:-electronuserland/builder:22-wine-mono} /bin/bash -c \"corepack enable && pnpm install && pnpm ci:test\"",
2323
"test-update": "UPDATE_SNAPSHOT=true vitest run",
2424
"test-ui": "vitest --ui",
2525
"docker-images": "docker/build.sh",
@@ -28,7 +28,7 @@
2828
"generate-changeset": "pnpm changeset",
2929
"generate-schema": "typescript-json-schema packages/app-builder-lib/tsconfig-scheme.json Configuration --out packages/app-builder-lib/scheme.json --noExtraProps --useTypeOfKeyword --strictNullChecks --required && node ./scripts/fix-schema.js",
3030
"generate-all": "pnpm generate-schema && pnpm prettier",
31-
"ci:test": "vitest run --no-update",
31+
"ci:test": "vitest run --no-update --allowOnly",
3232
"ci:version": "pnpm i && pnpm changelog && changeset version && node scripts/update-package-version-export.js && pnpm compile && pnpm generate-all && git add .",
3333
"ci:publish": "pnpm i && pnpm compile && pnpm publish -r --tag next && changeset tag",
3434
"docs:prebuild": "docker build -t mkdocs-dockerfile -f mkdocs-dockerfile . ",

packages/app-builder-lib/src/asar/asarUtil.ts

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PlatformPackager } from "../platformPackager"
99
import { ResolvedFileSet, getDestinationPath } from "../util/appFileCopier"
1010
import { detectUnpackedDirs } from "./unpackDetector"
1111
import { Readable } from "stream"
12+
import * as os from "os"
1213

1314
/** @internal */
1415
export class AsarPackager {
@@ -122,7 +123,7 @@ export class AsarPackager {
122123
transformedData: string | Buffer | undefined
123124
isUnpacked: (path: string) => boolean
124125
}): Promise<AsarStreamType> {
125-
const { isUnpacked, transformedData, file, destination, stat, fileSet } = options
126+
const { isUnpacked, transformedData, file, destination, stat } = options
126127
const unpacked = isUnpacked(destination)
127128

128129
if (!stat.isFile() && !stat.isSymbolicLink()) {
@@ -143,13 +144,8 @@ export class AsarPackager {
143144
return { path: destination, streamGenerator, unpacked, type: "file", stat: { mode: stat.mode, size } }
144145
}
145146

146-
const realPathFile = await fs.realpath(file)
147-
const realPathRelative = path.relative(fileSet.src, realPathFile)
148-
const isOutsidePackage = realPathRelative.startsWith("..")
149-
if (isOutsidePackage) {
150-
log.error({ source: log.filePath(file), realPathFile: log.filePath(realPathFile) }, `unable to copy, file is symlinked outside the package`)
151-
throw new Error(`Cannot copy file (${path.basename(file)}) symlinked to file (${path.basename(realPathFile)}) outside the package as that violates asar security integrity`)
152-
}
147+
// verify that the file is not a direct link or symlinked to access/copy a system file
148+
await this.protectSystemAndUnsafePaths(file)
153149

154150
const config = {
155151
path: destination,
@@ -158,14 +154,17 @@ export class AsarPackager {
158154
stat,
159155
}
160156

161-
// not a symlink, stream directly
162-
if (file === realPathFile) {
157+
// file, stream directly
158+
if (!stat.isSymbolicLink()) {
163159
return {
164160
...config,
165161
type: "file",
166162
}
167163
}
168164

165+
// guard against symlink pointing to outside workspace root
166+
await this.protectSystemAndUnsafePaths(file, await this.packager.info.getWorkspaceRoot())
167+
169168
// okay, it must be a symlink. evaluate link to be relative to source file in asar
170169
let link = await readlink(file)
171170
if (path.isAbsolute(link)) {
@@ -230,4 +229,86 @@ export class AsarPackager {
230229
transformedFiles,
231230
}
232231
}
232+
233+
private async getProtectedPaths(): Promise<string[]> {
234+
const systemPaths = [
235+
// Generic *nix
236+
"/usr",
237+
"/lib",
238+
"/bin",
239+
"/sbin",
240+
"/System",
241+
"/Library",
242+
"/private/etc",
243+
"/private/var/db",
244+
"/private/var/root",
245+
"/private/var/log",
246+
"/private/tmp",
247+
248+
// macOS legacy symlinks
249+
"/etc",
250+
"/var",
251+
"/tmp",
252+
253+
// Windows
254+
process.env.SystemRoot,
255+
process.env.WINDIR,
256+
// process.env.ProgramFiles,
257+
// process.env["ProgramFiles(x86)"],
258+
// process.env.ProgramData,
259+
// process.env.CommonProgramFiles,
260+
// process.env["CommonProgramFiles(x86)"],
261+
]
262+
.filter(Boolean)
263+
.map(p => path.resolve(p as string))
264+
265+
// Normalize to real paths to prevent symlink bypasses
266+
const resolvedPaths: string[] = []
267+
for (const p of systemPaths) {
268+
try {
269+
resolvedPaths.push(await fs.realpath(p))
270+
} catch {
271+
resolvedPaths.push(path.resolve(p))
272+
}
273+
}
274+
275+
return resolvedPaths
276+
}
277+
278+
private async protectSystemAndUnsafePaths(file: string, workspaceRoot?: string): Promise<boolean> {
279+
const resolved = await fs.realpath(file).catch(() => path.resolve(file))
280+
281+
const scan = async () => {
282+
if (workspaceRoot) {
283+
const workspace = path.resolve(workspaceRoot)
284+
285+
if (!resolved.startsWith(workspace)) {
286+
return true
287+
}
288+
}
289+
290+
// Allow temp & cache folders
291+
const tmpdir = await fs.realpath(os.tmpdir())
292+
if (resolved.startsWith(tmpdir)) {
293+
return false
294+
}
295+
296+
const blockedSystemPaths = await this.getProtectedPaths()
297+
for (const sys of blockedSystemPaths) {
298+
if (resolved.startsWith(sys)) {
299+
return true
300+
}
301+
}
302+
303+
return false
304+
}
305+
306+
const unsafe = await scan()
307+
308+
if (unsafe) {
309+
log.error({ source: file, realPath: resolved }, `unable to copy, file is from outside the package to a system or unsafe path`)
310+
throw new Error(`Cannot copy file [${file}] symlinked to file [${resolved}] outside the package to a system or unsafe path`)
311+
}
312+
return unsafe
313+
}
233314
}
Lines changed: 119 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,149 @@
11
import { NpmNodeModulesCollector } from "./npmNodeModulesCollector"
22
import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector"
33
import { YarnNodeModulesCollector } from "./yarnNodeModulesCollector"
4-
import { detectPackageManagerByLockfile, detectPackageManagerByEnv, PM, getPackageManagerCommand, detectYarnBerry } from "./packageManager"
4+
import { detectPackageManagerByFile, detectPackageManagerByEnv, PM, getPackageManagerCommand, detectYarnBerry as detectIfYarnBerry } from "./packageManager"
55
import { NodeModuleInfo } from "./types"
66
import { TmpDir } from "temp-file"
7+
import * as path from "path"
8+
import * as fs from "fs-extra"
9+
import { log, spawn } from "builder-util"
10+
import { YarnBerryNodeModulesCollector } from "./yarnBerryNodeModulesCollector"
11+
import { CancellationToken } from "builder-util-runtime"
712

8-
export async function getCollectorByPackageManager(pm: PM, rootDir: string, tempDirManager: TmpDir) {
13+
export { PM, getPackageManagerCommand }
14+
15+
export function getCollectorByPackageManager(pm: PM, rootDir: string, tempDirManager: TmpDir) {
916
switch (pm) {
1017
case PM.PNPM:
11-
if (await PnpmNodeModulesCollector.isPnpmProjectHoisted(rootDir)) {
12-
return new NpmNodeModulesCollector(rootDir, tempDirManager)
13-
}
1418
return new PnpmNodeModulesCollector(rootDir, tempDirManager)
15-
case PM.NPM:
16-
case PM.BUN:
17-
return new NpmNodeModulesCollector(rootDir, tempDirManager)
1819
case PM.YARN:
1920
return new YarnNodeModulesCollector(rootDir, tempDirManager)
21+
case PM.YARN_BERRY:
22+
return new YarnBerryNodeModulesCollector(rootDir, tempDirManager)
23+
case PM.BUN:
24+
case PM.NPM:
2025
default:
2126
return new NpmNodeModulesCollector(rootDir, tempDirManager)
2227
}
2328
}
2429

25-
export async function getNodeModules(pm: PM, rootDir: string, tempDirManager: TmpDir): Promise<NodeModuleInfo[]> {
26-
const collector = await getCollectorByPackageManager(pm, rootDir, tempDirManager)
27-
return collector.getNodeModules()
30+
export function getNodeModules(
31+
pm: PM,
32+
{
33+
rootDir,
34+
tempDirManager,
35+
cancellationToken,
36+
packageName,
37+
packageVersion
38+
}: {
39+
rootDir: string
40+
tempDirManager: TmpDir
41+
cancellationToken: CancellationToken
42+
packageName: string
43+
packageVersion?: string
44+
}
45+
): Promise<NodeModuleInfo[]> {
46+
const collector = getCollectorByPackageManager(pm, rootDir, tempDirManager)
47+
return collector.getNodeModules({ cancellationToken, packageName, packageVersion })
2848
}
2949

30-
export function detectPackageManager(dirs: string[]): PM {
50+
export async function detectPackageManager(searchPaths: string[]): Promise<{ pm: PM; corepackConfig: string | undefined; resolvedDirectory: string | undefined }> {
3151
let pm: PM | null = null
52+
const dedupedPaths = Array.from(new Set(searchPaths)) // reduce file operations, dedupe paths since primary use case has projectDir === appDir
3253

33-
const resolveYarnVersion = (pm: PM) => {
34-
if (pm === PM.YARN) {
35-
return detectYarnBerry()
54+
const resolveIfYarn = (pm: PM, version: string, cwd: string) => (pm === PM.YARN ? detectIfYarnBerry(cwd, version) : pm)
55+
56+
for (const dir of dedupedPaths) {
57+
const packageJsonPath = path.join(dir, "package.json")
58+
const packageManager = fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))?.packageManager : undefined
59+
if (packageManager) {
60+
const [pm, version] = packageManager.split("@")
61+
if (Object.values(PM).includes(pm as PM)) {
62+
const resolvedPackageManager = await resolveIfYarn(pm as PM, version, dir)
63+
log.debug({ resolvedPackageManager, packageManager, cwd: dir }, "packageManager field detected in package.json")
64+
return { pm: resolvedPackageManager, corepackConfig: packageManager, resolvedDirectory: dir }
65+
}
3666
}
37-
return pm
38-
}
3967

40-
for (const dir of dirs) {
41-
pm = detectPackageManagerByLockfile(dir)
68+
pm = await detectPackageManagerByFile(dir)
4269
if (pm) {
43-
return resolveYarnVersion(pm)
70+
const resolvedPackageManager = await resolveIfYarn(pm, "", dir)
71+
log.debug({ resolvedPackageManager, cwd: dir }, "packageManager detected by file")
72+
return { pm: resolvedPackageManager, resolvedDirectory: dir, corepackConfig: undefined }
4473
}
4574
}
4675

47-
pm = detectPackageManagerByEnv()
48-
if (pm) {
49-
return resolveYarnVersion(pm)
76+
pm = detectPackageManagerByEnv() || PM.NPM
77+
const cwd = process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : (process.env.INIT_CWD ?? process.cwd())
78+
const resolvedPackageManager = await resolveIfYarn(pm, "", cwd)
79+
log.debug({ resolvedPackageManager, detected: cwd }, "packageManager not detected by file, falling back to environment detection")
80+
return { pm: resolvedPackageManager, resolvedDirectory: undefined, corepackConfig: undefined }
81+
}
82+
83+
export async function findWorkspaceRoot(pm: PM, cwd: string): Promise<string | undefined> {
84+
let command: { command: string; args: string[] } | undefined
85+
86+
switch (pm) {
87+
case PM.PNPM:
88+
command = { command: "pnpm", args: ["root", "-w"] }
89+
break
90+
91+
case PM.YARN_BERRY:
92+
command = { command: "yarn", args: ["config", "get", "workspaceRoot"] }
93+
break
94+
95+
case PM.YARN: {
96+
command = { command: "yarn", args: ["workspaces", "info", "--silent"] }
97+
break
98+
}
99+
100+
case PM.BUN:
101+
command = { command: "bun", args: ["pm", "ls", "--json"] }
102+
break
103+
104+
case PM.NPM:
105+
default:
106+
command = { command: "npm", args: ["prefix", "-w"] }
107+
break
50108
}
51109

52-
// Default to npm
53-
return PM.NPM
110+
const output = await spawn(command.command, command.args, { cwd, stdio: ["ignore", "pipe", "ignore"] })
111+
.then(it => {
112+
const out = it?.trim()
113+
if (pm === PM.YARN) {
114+
JSON.parse(out) // if JSON valid, workspace detected
115+
return findNearestWithWorkspacesField(cwd)
116+
} else if (pm === PM.BUN) {
117+
const json = JSON.parse(out)
118+
if (Array.isArray(json) && json.length > 0) {
119+
return findNearestWithWorkspacesField(cwd)
120+
}
121+
}
122+
return !out?.length || out === "undefined" ? undefined : out
123+
})
124+
.catch(() => findNearestWithWorkspacesField(cwd))
125+
126+
log.debug({ root: output || cwd }, output ? "workspace root detected" : "workspace root not detected, using project root")
127+
return output
54128
}
55129

56-
export { PM, getPackageManagerCommand }
130+
async function findNearestWithWorkspacesField(dir: string): Promise<string | undefined> {
131+
let current = dir
132+
while (true) {
133+
const pkgPath = path.join(current, "package.json")
134+
try {
135+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"))
136+
if (pkg.workspaces) {
137+
return current
138+
}
139+
} catch {
140+
// ignore
141+
}
142+
const parent = path.dirname(current)
143+
if (parent === current) {
144+
break
145+
}
146+
current = parent
147+
}
148+
return undefined
149+
}

0 commit comments

Comments
 (0)