Skip to content

Commit 1c94529

Browse files
authored
fix: post-process parent directories of unpacked files (#9419)
1 parent caf2cb2 commit 1c94529

4 files changed

Lines changed: 129 additions & 94 deletions

File tree

.changeset/light-flies-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
fix: post-process parent directories of unpacked files to avoid overeager unpacking

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

Lines changed: 124 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createPackageFromStreams, AsarStreamType, AsarDirectory } from "@electron/asar"
2-
import { log } from "builder-util"
3-
import { exists, Filter } from "builder-util/out/fs"
2+
import { isEmptyOrSpaces, log } from "builder-util"
3+
import { exists, Filter, FilterStats } from "builder-util/out/fs"
44
import * as fs from "fs-extra"
55
import { readlink } from "fs-extra"
66
import * as path from "path"
@@ -91,26 +91,39 @@ export class AsarPackager {
9191
}
9292
}
9393

94-
const results: AsarStreamType[] = []
95-
const resultsPaths = new Set<string>()
94+
const resultsMap = new Map<string, AsarStreamType>()
95+
const streamOrdering: string[] = []
96+
const normalizedUnpackedPaths = Array.from(unpackedPaths).map(p => path.normalize(p))
97+
98+
// Check whether a file or directory should be unpacked, using pre-normalized unpacked paths and early returns
99+
const isUnpacked = (dir: string, file?: string, stat?: FilterStats): boolean => {
100+
const normalizedDir = path.normalize(dir)
101+
102+
// Check file pattern first (most specific)
103+
if (!isEmptyOrSpaces(file) && stat && this.config.unpackPattern?.(file, stat)) {
104+
return true
105+
}
106+
107+
// Check if path is within any unpacked directory
108+
for (const unpackedPath of normalizedUnpackedPaths) {
109+
if (normalizedDir === unpackedPath || normalizedDir.startsWith(unpackedPath + path.sep)) {
110+
return true
111+
}
112+
}
113+
114+
return false
115+
}
116+
117+
// First pass: process all files in order, ensuring parent directories exist
96118
for (const fileSet of fileSets) {
97119
// Don't use Promise.all, we need to retain order of execution/iteration through the already-ordered fileset
98120
for (const [index, file] of fileSet.files.entries()) {
99121
const transformedData = fileSet.transformedFiles?.get(index)
100122
const stat = fileSet.metadata.get(file)!
101123
const destination = path.relative(this.config.defaultDestination, getDestinationPath(file, fileSet))
102124

103-
const paths = Array.from(unpackedPaths).map(p => path.normalize(p))
104-
105-
const isChildDirectory = (fileOrDirPath: string) =>
106-
paths.includes(path.normalize(fileOrDirPath)) || paths.some(unpackedPath => path.normalize(fileOrDirPath).startsWith(unpackedPath + path.sep))
107-
const isUnpacked = (dir: string) => {
108-
const isChild = isChildDirectory(dir)
109-
const isFileUnpacked = this.config.unpackPattern?.(file, stat) ?? false
110-
return isChild || isFileUnpacked
111-
}
112-
113-
this.processParentDirectories(isUnpacked, destination, results, resultsPaths)
125+
// Ensure parent directories exist before processing file
126+
this.ensureParentDirectories(destination, resultsMap, streamOrdering)
114127

115128
const result = await this.processFileOrSymlink({
116129
file,
@@ -120,31 +133,64 @@ export class AsarPackager {
120133
stat,
121134
isUnpacked,
122135
})
123-
if (result != null) {
124-
results.push(result)
125-
resultsPaths.add(result.path)
136+
137+
if (result && !resultsMap.has(result.path)) {
138+
resultsMap.set(result.path, result)
139+
streamOrdering.push(result.path)
126140
}
127141
}
128142
}
129-
return results
130-
}
131143

132-
private processParentDirectories(isUnpacked: (path: string) => boolean, destination: string, results: AsarStreamType[], resultsPaths: Set<string>) {
133-
// process parent directories
134-
let superDir = path.dirname(path.normalize(destination))
135-
while (superDir !== ".") {
136-
const dir: AsarDirectory = {
137-
type: "directory",
138-
path: superDir,
139-
unpacked: isUnpacked(superDir),
144+
// Second pass: propagate unpacked flag to parent directories
145+
for (const entry of resultsMap.values()) {
146+
if (entry.unpacked) {
147+
this.markParentDirectoriesAsUnpacked(entry.path, resultsMap, isUnpacked)
140148
}
141-
// add to results if not already present
142-
if (!resultsPaths.has(dir.path)) {
143-
results.push(dir)
144-
resultsPaths.add(dir.path)
149+
}
150+
151+
// Build final results array maintaining processing order
152+
return streamOrdering.reduce<AsarStreamType[]>((streams, path) => {
153+
const stream = resultsMap.has(path) ? resultsMap.get(path) : null
154+
if (stream != null) {
155+
streams.push(stream)
145156
}
157+
return streams
158+
}, [])
159+
}
160+
161+
private ensureParentDirectories(destination: string, resultsMap: Map<string, AsarStreamType>, streamOrdering: string[]): void {
162+
const parents: string[] = []
163+
let current = path.dirname(path.normalize(destination))
146164

147-
superDir = path.dirname(superDir)
165+
// Collect all parent directories from deepest to root
166+
while (current !== ".") {
167+
parents.unshift(current)
168+
current = path.dirname(current)
169+
}
170+
171+
// Add parent directories in order (root to deepest)
172+
for (const parentPath of parents) {
173+
if (!resultsMap.has(parentPath)) {
174+
const dir: AsarDirectory = {
175+
type: "directory",
176+
path: parentPath,
177+
unpacked: false, // Updated in second pass if needed
178+
}
179+
resultsMap.set(parentPath, dir)
180+
streamOrdering.push(parentPath)
181+
}
182+
}
183+
}
184+
185+
private markParentDirectoriesAsUnpacked(destination: string, resultsMap: Map<string, AsarStreamType>, isUnpacked: (path: string) => boolean): void {
186+
let current = path.dirname(path.normalize(destination))
187+
188+
while (current !== ".") {
189+
const entry = resultsMap.get(current)
190+
if (entry && isUnpacked(current)) {
191+
entry.unpacked = true
192+
}
193+
current = path.dirname(current)
148194
}
149195
}
150196

@@ -154,54 +200,57 @@ export class AsarPackager {
154200
stat: fs.Stats
155201
fileSet: ResolvedFileSet
156202
transformedData: string | Buffer | undefined
157-
isUnpacked: (path: string) => boolean
203+
isUnpacked: (dir: string, file?: string, stat?: FilterStats) => boolean
158204
}): Promise<AsarStreamType> {
159205
const { isUnpacked, transformedData, file, destination, stat } = options
160-
const unpacked = isUnpacked(destination)
206+
const unpacked = isUnpacked(destination, file, stat)
161207

208+
// Handle directories
162209
if (!stat.isFile() && !stat.isSymbolicLink()) {
163210
return { path: destination, unpacked, type: "directory" }
164211
}
165212

166-
// write any data if provided, skip symlink check
213+
// Handle transformed data (pre-processed content)
167214
if (transformedData != null) {
168-
const streamGenerator = () => {
169-
return new Readable({
170-
read() {
171-
this.push(transformedData)
172-
this.push(null)
173-
},
174-
})
175-
}
176215
const size = Buffer.byteLength(transformedData)
177-
return { path: destination, streamGenerator, unpacked, type: "file", stat: { mode: stat.mode, size } }
216+
return {
217+
path: destination,
218+
streamGenerator: () =>
219+
new Readable({
220+
read() {
221+
this.push(transformedData)
222+
this.push(null)
223+
},
224+
}),
225+
unpacked,
226+
type: "file",
227+
stat: { mode: stat.mode, size },
228+
}
178229
}
179230

180231
// verify that the file is not a direct link or symlinked to access/copy a system file
181232
await this.protectSystemAndUnsafePaths(file, await this.packager.info.getWorkspaceRoot())
182233

183-
const config = {
234+
const baseConfig = {
184235
path: destination,
185236
streamGenerator: () => fs.createReadStream(file),
186237
unpacked,
187238
stat,
188239
}
189240

190-
// file, stream directly
241+
// Handle regular files
191242
if (!stat.isSymbolicLink()) {
192-
return {
193-
...config,
194-
type: "file",
195-
}
243+
return { ...baseConfig, type: "file" }
196244
}
197245

198-
// okay, it must be a symlink. evaluate link to be relative to source file in asar
246+
// Handle symlinks - make relative to source location
199247
let link = await readlink(file)
200248
if (path.isAbsolute(link)) {
201249
link = path.relative(path.dirname(file), link)
202250
}
251+
203252
return {
204-
...config,
253+
...baseConfig,
205254
type: "link",
206255
symlink: link,
207256
}
@@ -241,31 +290,29 @@ export class AsarPackager {
241290
for (const [oldIndex, value] of fileSet.transformedFiles) {
242291
const newIndex = indexMap.get(oldIndex)
243292
if (newIndex === undefined) {
244-
const file = fileSet.files[oldIndex]
245-
throw new Error(`Internal error: ${file} was lost while ordering asar`)
293+
throw new Error(`Internal error: ${fileSet.files[oldIndex]} was lost while ordering asar`)
246294
}
247-
248295
transformedFiles.set(newIndex, value)
249296
}
250297
}
251298

252-
const { src, destination, metadata } = fileSet
253-
254299
return {
255-
src,
256-
destination,
257-
metadata,
300+
src: fileSet.src,
301+
destination: fileSet.destination,
302+
metadata: fileSet.metadata,
258303
files: sortedFileEntries.map(([, file]) => file),
259304
transformedFiles,
260305
}
261306
}
262307

263308
private async checkAgainstRoots(target: string, allowRoots: string[]): Promise<boolean> {
264309
const resolved = await resolvePath(target)
310+
if (resolved == null || isEmptyOrSpaces(resolved)) {
311+
return false
312+
}
265313

266314
for (const root of allowRoots) {
267-
const resolvedRoot = root
268-
if (resolved === resolvedRoot || resolved?.startsWith(resolvedRoot + path.sep)) {
315+
if (resolved === root || resolved.startsWith(root + path.sep)) {
269316
return true
270317
}
271318
}
@@ -276,33 +323,25 @@ export class AsarPackager {
276323
const resolved = await resolvePath(file)
277324
const logFields = { source: file, realPath: resolved }
278325

279-
const isUnsafe = async () => {
280-
const workspace = await resolvePath(workspaceRoot)
281-
282-
if (workspace && resolved?.startsWith(workspace)) {
283-
// if in workspace, always safe
284-
return false
285-
}
286-
287-
const allowed = await this.checkAgainstRoots(file, await ALLOWLIST)
288-
if (allowed) {
289-
return false // allowlist is priority
290-
}
326+
const workspace = await resolvePath(workspaceRoot)
291327

292-
const denied = await this.checkAgainstRoots(file, await DENYLIST)
293-
if (denied) {
294-
log.error(logFields, `denied access to system or unsafe path`)
295-
return true
296-
}
297-
// default
298-
log.debug(logFields, `path is outside of explicit safe paths, defaulting to safe`)
299-
return false
328+
// If in workspace, always safe
329+
if (workspace && resolved?.startsWith(workspace)) {
330+
return
300331
}
301332

302-
const unsafe = await isUnsafe()
333+
// Check allowlist (priority)
334+
if (await this.checkAgainstRoots(file, await ALLOWLIST)) {
335+
return
336+
}
303337

304-
if (unsafe) {
338+
// Check denylist
339+
if (await this.checkAgainstRoots(file, await DENYLIST)) {
340+
log.error(logFields, `denied access to system or unsafe path`)
305341
throw new Error(`Cannot copy file [${file}] symlinked to file [${resolved}] outside the package to a system or unsafe path`)
306342
}
343+
344+
// Default: outside explicit paths but not explicitly denied
345+
log.debug(logFields, `path is outside of explicit safe paths, defaulting to safe`)
307346
}
308347
}

test/snapshots/HoistedNodeModuleTest.js.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125138,7 +125138,6 @@ exports[`yarn several workspaces and asarUnpack 2`] = `
125138125138
"unpacked": true,
125139125139
},
125140125140
},
125141-
"unpacked": true,
125142125141
},
125143125142
},
125144125143
},

test/snapshots/globTest.js.snap

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,8 @@ exports[`asarUnpack node_modules 2`] = `
6161
"unpacked": true,
6262
},
6363
},
64-
"unpacked": true,
6564
},
6665
},
67-
"unpacked": true,
6866
}
6967
`;
7068

@@ -28941,10 +28939,8 @@ exports[`unpackDir 2`] = `
2894128939
"unpacked": true,
2894228940
},
2894328941
},
28944-
"unpacked": true,
2894528942
},
2894628943
},
28947-
"unpacked": true,
2894828944
},
2894928945
"b2": {
2895028946
"files": {
@@ -28953,7 +28949,6 @@ exports[`unpackDir 2`] = `
2895328949
"unpacked": true,
2895428950
},
2895528951
},
28956-
"unpacked": true,
2895728952
},
2895828953
"do-not-unpack-dir": {
2895928954
"files": {
@@ -29060,10 +29055,8 @@ exports[`unpackDir one 2`] = `
2906029055
"unpacked": true,
2906129056
},
2906229057
},
29063-
"unpacked": true,
2906429058
},
2906529059
},
29066-
"unpacked": true,
2906729060
},
2906829061
"b2": {
2906929062
"files": {
@@ -29072,7 +29065,6 @@ exports[`unpackDir one 2`] = `
2907229065
"unpacked": true,
2907329066
},
2907429067
},
29075-
"unpacked": true,
2907629068
},
2907729069
"do-not-unpack-dir": {
2907829070
"files": {

0 commit comments

Comments
 (0)