Skip to content

Commit 645b7db

Browse files
authored
fix: Respect unpack minimatch for symlinks within previously unpacked directories (#341)
* fix: Respecting unpack configuration when considering symlinks within previously unpacked directories. This directly fixes unpacking static `.framework` modules on Mac, as otherwise codesigning will fail due to symlink files/directories not being reflected in the app.asar.unpacked directory. Added unit test with Hello.framework, generated from tutorial https://jano.dev/apple/mach-o/2024/06/28/Hello-Static-Framework.html Fixes: electron-userland/electron-builder#8655 * adding unit test by programmatically create symlinks during test case (same approach as has been taken for filesystem UT already) * cleanup changes post-merging `main`
1 parent 044fb5f commit 645b7db

7 files changed

+123
-45
lines changed

src/asar.ts

+32-17
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ export async function createPackageFromFiles(
8080
});
8181

8282
const filesystem = new Filesystem(src);
83-
const files: { filename: string; unpack: boolean }[] = [];
83+
const files: disk.BasicFilesArray = [];
84+
const links: disk.BasicFilesArray = [];
8485
const unpackDirs: string[] = [];
8586

8687
let filenamesSorted: string[] = [];
@@ -140,37 +141,51 @@ export async function createPackageFromFiles(
140141
}
141142
const file = metadata[filename];
142143

144+
const shouldUnpackPath = function (
145+
relativePath: string,
146+
unpack: string | undefined,
147+
unpackDir: string | undefined,
148+
) {
149+
let shouldUnpack = false;
150+
if (unpack) {
151+
shouldUnpack = minimatch(filename, unpack, { matchBase: true });
152+
}
153+
if (!shouldUnpack && unpackDir) {
154+
shouldUnpack = isUnpackedDir(relativePath, unpackDir, unpackDirs);
155+
}
156+
return shouldUnpack;
157+
};
158+
143159
let shouldUnpack: boolean;
144160
switch (file.type) {
145161
case 'directory':
146-
if (options.unpackDir) {
147-
shouldUnpack = isUnpackedDir(path.relative(src, filename), options.unpackDir, unpackDirs);
148-
} else {
149-
shouldUnpack = false;
150-
}
162+
shouldUnpack = shouldUnpackPath(path.relative(src, filename), undefined, options.unpackDir);
151163
filesystem.insertDirectory(filename, shouldUnpack);
152164
break;
153165
case 'file':
154-
shouldUnpack = false;
155-
if (options.unpack) {
156-
shouldUnpack = minimatch(filename, options.unpack, { matchBase: true });
157-
}
158-
if (!shouldUnpack && options.unpackDir) {
159-
const dirName = path.relative(src, path.dirname(filename));
160-
shouldUnpack = isUnpackedDir(dirName, options.unpackDir, unpackDirs);
161-
}
162-
files.push({ filename: filename, unpack: shouldUnpack });
166+
shouldUnpack = shouldUnpackPath(
167+
path.relative(src, path.dirname(filename)),
168+
options.unpack,
169+
options.unpackDir,
170+
);
171+
files.push({ filename, unpack: shouldUnpack });
163172
return filesystem.insertFile(filename, shouldUnpack, file, options);
164173
case 'link':
165-
filesystem.insertLink(filename);
174+
shouldUnpack = shouldUnpackPath(
175+
path.relative(src, filename),
176+
options.unpack,
177+
options.unpackDir,
178+
);
179+
links.push({ filename, unpack: shouldUnpack });
180+
filesystem.insertLink(filename, shouldUnpack);
166181
break;
167182
}
168183
return Promise.resolve();
169184
};
170185

171186
const insertsDone = async function () {
172187
await fs.mkdirp(path.dirname(dest));
173-
return disk.writeFilesystem(dest, filesystem, files, metadata);
188+
return disk.writeFilesystem(dest, filesystem, { files, links }, metadata);
174189
};
175190

176191
const names = filenamesSorted.slice();

src/disk.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,17 @@ export type InputMetadata = {
3737

3838
export type BasicFilesArray = { filename: string; unpack: boolean }[];
3939

40+
export type FilesystemFilesAndLinks = { files: BasicFilesArray; links: BasicFilesArray };
41+
4042
const writeFileListToStream = async function (
4143
dest: string,
4244
filesystem: Filesystem,
4345
out: NodeJS.WritableStream,
44-
fileList: BasicFilesArray,
46+
lists: FilesystemFilesAndLinks,
4547
metadata: InputMetadata,
4648
) {
47-
for (const file of fileList) {
49+
const { files, links } = lists;
50+
for (const file of files) {
4851
if (file.unpack) {
4952
// the file should not be packed into archive
5053
const filename = path.relative(filesystem.getRootPath(), file.filename);
@@ -53,13 +56,30 @@ const writeFileListToStream = async function (
5356
await streamTransformedFile(file.filename, out, metadata[file.filename].transformed);
5457
}
5558
}
59+
const unpackedSymlinks = links.filter((f) => f.unpack);
60+
for (const file of unpackedSymlinks) {
61+
// the symlink needs to be recreated outside in .unpacked
62+
const filename = path.relative(filesystem.getRootPath(), file.filename);
63+
const link = await fs.readlink(file.filename);
64+
// if symlink is within subdirectories, then we need to recreate dir structure
65+
await fs.mkdirp(path.join(`${dest}.unpacked`, path.dirname(filename)));
66+
// create symlink within unpacked dir
67+
await fs.symlink(link, path.join(`${dest}.unpacked`, filename)).catch(async (error) => {
68+
if (error.code === 'EPERM' && error.syscall === 'symlink') {
69+
throw new Error(
70+
'Could not create symlinks for unpacked assets. On Windows, consider activating Developer Mode to allow non-admin users to create symlinks by following the instructions at https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development.',
71+
);
72+
}
73+
throw error;
74+
});
75+
}
5676
return out.end();
5777
};
5878

5979
export async function writeFilesystem(
6080
dest: string,
6181
filesystem: Filesystem,
62-
fileList: BasicFilesArray,
82+
lists: FilesystemFilesAndLinks,
6383
metadata: InputMetadata,
6484
) {
6585
const headerPickle = Pickle.createEmpty();
@@ -76,7 +96,7 @@ export async function writeFilesystem(
7696
out.write(sizeBuf);
7797
return out.write(headerBuf, () => resolve());
7898
});
79-
return writeFileListToStream(dest, filesystem, out, fileList, metadata);
99+
return writeFileListToStream(dest, filesystem, out, lists, metadata);
80100
}
81101

82102
export interface FileRecord extends FilesystemFileEntry {

src/filesystem.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export class Filesystem {
156156
this.offset += BigInt(size);
157157
}
158158

159-
insertLink(p: string) {
159+
insertLink(p: string, shouldUnpack: boolean) {
160160
const symlink = fs.readlinkSync(p);
161161
// /var => /private/var
162162
const parentPath = fs.realpathSync(path.dirname(p));
@@ -165,6 +165,10 @@ export class Filesystem {
165165
throw new Error(`${p}: file "${link}" links out of the package`);
166166
}
167167
const node = this.searchNodeFromPath(p) as FilesystemLinkEntry;
168+
const dirNode = this.searchNodeFromPath(path.dirname(p)) as FilesystemDirectoryEntry;
169+
if (shouldUnpack || dirNode.unpacked) {
170+
node.unpacked = true;
171+
}
168172
node.link = link;
169173
return link;
170174
}

src/wrapped-fs.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
const fs = 'electron' in process.versions ? require('original-fs') : require('fs');
22

3-
const promisifiedMethods = ['lstat', 'mkdtemp', 'readFile', 'stat', 'writeFile'];
3+
const promisifiedMethods = [
4+
'lstat',
5+
'mkdtemp',
6+
'readFile',
7+
'stat',
8+
'writeFile',
9+
'symlink',
10+
'readlink',
11+
];
412

513
type AsarFS = typeof import('fs') & {
614
mkdirp(dir: string): Promise<void>;
@@ -10,6 +18,8 @@ type AsarFS = typeof import('fs') & {
1018
readFile: (typeof import('fs'))['promises']['readFile'];
1119
stat: (typeof import('fs'))['promises']['stat'];
1220
writeFile: (typeof import('fs'))['promises']['writeFile'];
21+
symlink: (typeof import('fs'))['promises']['symlink'];
22+
readlink: (typeof import('fs'))['promises']['readlink'];
1323
};
1424

1525
const promisified = {} as AsarFS;

test/cli-spec.js

+19
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const rimraf = require('rimraf');
1111
const compDirs = require('./util/compareDirectories');
1212
const compFileLists = require('./util/compareFileLists');
1313
const compFiles = require('./util/compareFiles');
14+
const createSymlinkApp = require('./util/createSymlinkApp');
1415

1516
const exec = promisify(childProcess.exec);
1617

@@ -188,4 +189,22 @@ describe('command line interface', function () {
188189
) === false,
189190
);
190191
});
192+
it('should unpack static framework with all underlying symlinks unpacked', async () => {
193+
const { tmpPath } = createSymlinkApp('app');
194+
await execAsar(
195+
`p ${tmpPath} tmp/packthis-with-symlink.asar --unpack *.txt --unpack-dir var --exclude-hidden`,
196+
);
197+
198+
assert.ok(fs.existsSync('tmp/packthis-with-symlink.asar.unpacked/private/var/file.txt'));
199+
assert.ok(fs.existsSync('tmp/packthis-with-symlink.asar.unpacked/private/var/app/file.txt'));
200+
assert.strictEqual(
201+
fs.readlinkSync('tmp/packthis-with-symlink.asar.unpacked/private/var/app/file.txt'),
202+
path.join('..', 'file.txt'),
203+
);
204+
assert.strictEqual(
205+
fs.readlinkSync('tmp/packthis-with-symlink.asar.unpacked/var'),
206+
path.join('private', 'var'),
207+
);
208+
assert.ok(fs.existsSync('tmp/packthis-with-symlink.asar.unpacked/var/file.txt'));
209+
});
191210
});

test/filesystem-spec.js

+2-22
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const assert = require('assert');
44
const fs = require('../lib/wrapped-fs').default;
55
const path = require('path');
66
const rimraf = require('rimraf');
7+
const createSymlinkedApp = require('./util/createSymlinkApp');
78

89
const Filesystem = require('../lib/filesystem').Filesystem;
910

@@ -13,28 +14,7 @@ describe('filesystem', function () {
1314
});
1415

1516
it('should does not throw an error when the src path includes a symbol link', async () => {
16-
/**
17-
* Directory structure:
18-
* tmp
19-
* ├── private
20-
* │ └── var
21-
* │ ├── app
22-
* │ │ └── file.txt -> ../file.txt
23-
* │ └── file.txt
24-
* └── var -> private/var
25-
*/
26-
const tmpPath = path.join(__dirname, '..', 'tmp');
27-
const privateVarPath = path.join(tmpPath, 'private', 'var');
28-
const varPath = path.join(tmpPath, 'var');
29-
fs.mkdirSync(privateVarPath, { recursive: true });
30-
fs.symlinkSync(path.relative(tmpPath, privateVarPath), varPath);
31-
32-
const originFilePath = path.join(varPath, 'file.txt');
33-
fs.writeFileSync(originFilePath, 'hello world');
34-
const appPath = path.join(varPath, 'app');
35-
fs.mkdirpSync(appPath);
36-
fs.symlinkSync('../file.txt', path.join(appPath, 'file.txt'));
37-
17+
const { appPath, varPath } = createSymlinkedApp('filesystem');
3818
const filesystem = new Filesystem(varPath);
3919
assert.doesNotThrow(() => {
4020
filesystem.insertLink(path.join(appPath, 'file.txt'));

test/util/createSymlinkApp.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const path = require('path');
2+
const fs = require('../../lib/wrapped-fs').default;
3+
const rimraf = require('rimraf');
4+
/**
5+
* Directory structure:
6+
* tmp
7+
* ├── private
8+
* │ └── var
9+
* │ ├── app
10+
* │ │ └── file.txt -> ../file.txt
11+
* │ └── file.txt
12+
* └── var -> private/var
13+
*/
14+
module.exports = (testName) => {
15+
const tmpPath = path.join(__dirname, '../..', 'tmp', testName || 'app');
16+
const privateVarPath = path.join(tmpPath, 'private', 'var');
17+
const varPath = path.join(tmpPath, 'var');
18+
19+
rimraf.sync(tmpPath, fs);
20+
21+
fs.mkdirSync(privateVarPath, { recursive: true });
22+
fs.symlinkSync(path.relative(tmpPath, privateVarPath), varPath);
23+
24+
const originFilePath = path.join(varPath, 'file.txt');
25+
fs.writeFileSync(originFilePath, 'hello world');
26+
const appPath = path.join(varPath, 'app');
27+
fs.mkdirpSync(appPath);
28+
fs.symlinkSync('../file.txt', path.join(appPath, 'file.txt'));
29+
return { appPath, tmpPath, varPath };
30+
};

0 commit comments

Comments
 (0)