reaproof 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -5,13 +5,26 @@ import { applyFix, planFix, undoLastFix } from './fix.mjs';
5
5
  import { MeasureError, measureWithRea, REA_PACKAGE } from './measure.mjs';
6
6
  import { isWebAddress, scanDeployedSite } from './remote.mjs';
7
7
  import { createReporter, pickLanguage } from './report.mjs';
8
+ import { hasLeakAtOrAbove, LEAK_SEVERITIES } from './rules.mjs';
8
9
  import { resolveTargets, scan, ScanError, withoutFiles } from './scan.mjs';
9
10
  import { toPrintable, toVisibleJson } from './text.mjs';
10
11
 
11
12
  const COMMANDS = { scan: runScan, fix: runFix, undo: runUndo, measure: runMeasure };
13
+ const OPTIONS = {
14
+ json: { type: 'boolean' },
15
+ yes: { type: 'boolean', short: 'y' },
16
+ // The mildest leak that still stops a release.
17
+ 'fail-on': { type: 'string' },
18
+ lang: { type: 'string' },
19
+ help: { type: 'boolean', short: 'h' },
20
+ version: { type: 'boolean', short: 'v' },
21
+ };
12
22
  const EXIT_OK = 0;
13
23
  const EXIT_FINDINGS = 1;
14
24
  const EXIT_USAGE = 2;
25
+ // Nothing could be read: an address that did not answer, or a target with nothing in it
26
+ // that this scanner opens. A check that read nothing must not pass for a clean one.
27
+ const EXIT_UNREAD = 3;
15
28
 
16
29
  /**
17
30
  * Everything the command needs from the outside world, so the flow can be driven by a
@@ -38,24 +51,24 @@ export async function main(argv, outside) {
38
51
  };
39
52
  let parsed;
40
53
  try {
41
- parsed = parseArgs({
42
- args: argv,
43
- options: {
44
- json: { type: 'boolean' },
45
- yes: { type: 'boolean', short: 'y' },
46
- lang: { type: 'string' },
47
- help: { type: 'boolean', short: 'h' },
48
- version: { type: 'boolean', short: 'v' },
49
- },
50
- allowPositionals: true,
51
- });
54
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
52
55
  } catch (error) {
53
- terminal.printError(createReporter(pickLanguage()).usageError(error));
54
- return EXIT_USAGE;
56
+ // What can still be read of the command says how to answer it: in which language, and as JSON or not.
57
+ const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
58
+ const report = createReporter(pickLanguage(typeof values.lang === 'string' ? values.lang : undefined));
59
+ const wantsJson = values.json === true;
60
+ const reason = report.usageError(error);
61
+ // A person is shown how the command is used. An agent gets the reason alone.
62
+ const message = wantsJson ? reason : `${reason}\n\n${report.help}`;
63
+ return fail(terminal, report, wantsJson, EXIT_USAGE, { code: 'usage', message });
55
64
  }
56
65
 
57
- const { values: options, positionals } = parsed;
66
+ const { values, positionals } = parsed;
67
+ const options = { ...values, failOn: values['fail-on'] ?? 'high' };
58
68
  const report = createReporter(pickLanguage(options.lang));
69
+ if (!LEAK_SEVERITIES.includes(options.failOn)) {
70
+ return fail(terminal, report, options.json, EXIT_USAGE, { code: 'usage', message: report.failOnError(options.failOn) });
71
+ }
59
72
  if (options.help) {
60
73
  terminal.print(report.help);
61
74
  return EXIT_OK;
@@ -72,17 +85,31 @@ export async function main(argv, outside) {
72
85
  return await COMMANDS[command](target, options, report, terminal);
73
86
  } catch (error) {
74
87
  if (error instanceof ScanError) {
75
- terminal.printError(report.scanError(error));
76
- return EXIT_USAGE;
88
+ const { code, path, details } = error;
89
+ const exitCode = code === 'page-unreachable' ? EXIT_UNREAD : EXIT_USAGE;
90
+ return fail(terminal, report, options.json, exitCode, { code, message: report.scanError(error), path, ...details });
77
91
  }
78
92
  if (error instanceof MeasureError) {
79
- terminal.printError(report.measureFailed(error));
80
- return EXIT_USAGE;
93
+ const failure = { code: 'measure-failed', message: report.measureFailed(error) };
94
+ return fail(terminal, report, options.json, EXIT_USAGE, failure);
81
95
  }
82
96
  throw error;
83
97
  }
84
98
  }
85
99
 
100
+ /**
101
+ * Says why the command stopped, and hands back the code it ends with. When JSON was asked
102
+ * for it is JSON on standard output like any other answer, so that whoever parses the
103
+ * output is never handed nothing.
104
+ *
105
+ * @param {{ code: string, message: string }} failure and whatever else is known about it
106
+ */
107
+ function fail(terminal, report, wantsJson, exitCode, failure) {
108
+ if (wantsJson) terminal.print(toJson({ exitCode, error: failure }, report));
109
+ else terminal.printError(failure.message);
110
+ return exitCode;
111
+ }
112
+
86
113
  /**
87
114
  * The one command most people need. For a person at a terminal it carries on from the
88
115
  * report: it offers to build when nothing is built yet, and to take out what can be taken
@@ -93,13 +120,14 @@ async function runScan(target, options, report, terminal) {
93
120
  const result = isWebAddress(target)
94
121
  ? await scanDeployedSite(target)
95
122
  : await scanOrOfferBuild(resolve(target), ask, report, terminal);
96
- terminal.print(options.json ? toJson(withExplanation(result, report), report) : report.scan(result));
123
+ const ending = endingOf(result, options);
124
+ terminal.print(options.json ? toJson(withExplanation(result, report, ending), report) : report.scan(result));
97
125
 
98
126
  // A deployed result plans nothing: none of its findings is a file on disk.
99
127
  const mapPaths = planFix(result);
100
- if (!ask || mapPaths.length === 0) return exitCodeFor(result);
128
+ if (!ask || mapPaths.length === 0) return closeWith(ending, options, report, terminal);
101
129
  terminal.print(`\n${report.fixPlan(result.root, mapPaths)}`);
102
- if (!(await ask(report.confirmQuestion))) return exitCodeFor(result);
130
+ if (!(await ask(report.confirmQuestion))) return closeWith(ending, options, report, terminal);
103
131
  return takeOutMaps(result, mapPaths, options, report, terminal);
104
132
  }
105
133
 
@@ -108,7 +136,7 @@ async function scanOrOfferBuild(root, ask, report, terminal) {
108
136
  return scan(root);
109
137
  } catch (error) {
110
138
  // Only a missing build output carries the command that would produce it.
111
- const buildCommand = error instanceof ScanError ? error.buildCommand : null;
139
+ const buildCommand = error instanceof ScanError ? error.details.buildCommand : null;
112
140
  if (!ask || !buildCommand || !(await ask(report.buildQuestion(buildCommand.join(' '))))) throw error;
113
141
  if (!terminal.runBuild(buildCommand, root)) throw new ScanError('build-failed', root);
114
142
  return scan(root);
@@ -125,9 +153,10 @@ async function runFix(target, options, report, terminal) {
125
153
  const root = toLocalRoot(target);
126
154
  const before = scan(root);
127
155
  const mapPaths = planFix(before);
156
+ const ending = endingOf(before, options);
128
157
  if (mapPaths.length === 0) {
129
- terminal.print(options.json ? toJson({ movedCount: 0, before }, report) : report.nothingToFix);
130
- return exitCodeFor(before);
158
+ terminal.print(options.json ? toJson({ ...ending, movedCount: 0, before }, report) : report.nothingToFix);
159
+ return closeWith(ending, options, report, terminal);
131
160
  }
132
161
 
133
162
  if (!options.yes) {
@@ -142,7 +171,7 @@ async function runFix(target, options, report, terminal) {
142
171
  }
143
172
  if (!(await terminal.ask(report.confirmQuestion))) {
144
173
  terminal.print(report.cancelled);
145
- return exitCodeFor(before);
174
+ return closeWith(ending, options, report, terminal);
146
175
  }
147
176
  }
148
177
  return takeOutMaps(before, mapPaths, options, report, terminal);
@@ -152,8 +181,13 @@ function takeOutMaps(before, mapPaths, options, report, terminal) {
152
181
  const applied = applyFix(before.root, mapPaths);
153
182
  // The moved files are the only change, so the rest of the output is not read again.
154
183
  const after = withoutFiles(before, mapPaths);
155
- terminal.print(options.json ? toJson({ ...applied, before, after }, report) : report.fixDone(before, after, applied));
156
- return exitCodeFor(after);
184
+ // The check has been made already, so an output with nothing left in it is not a failure.
185
+ const exitCode = hasLeakAtOrAbove(after.leaks, options.failOn) ? EXIT_FINDINGS : EXIT_OK;
186
+ const ending = { failOn: options.failOn, exitCode };
187
+ terminal.print(
188
+ options.json ? toJson({ ...ending, ...applied, before, after }, report) : report.fixDone(before, after, applied),
189
+ );
190
+ return closeWith(ending, options, report, terminal);
157
191
  }
158
192
 
159
193
  function runUndo(target, options, report, terminal) {
@@ -168,7 +202,7 @@ function runUndo(target, options, report, terminal) {
168
202
 
169
203
  async function runMeasure(target, options, report, terminal) {
170
204
  const root = toLocalRoot(target);
171
- const targets = resolveTargets(root);
205
+ const { targets } = resolveTargets(root);
172
206
  // rea is someone else's program: it is downloaded and run only after a yes.
173
207
  if (!options.yes) {
174
208
  if (options.json) {
@@ -196,14 +230,35 @@ async function runMeasure(target, options, report, terminal) {
196
230
  return EXIT_OK;
197
231
  }
198
232
 
199
- /** The JSON a scan prints: the result, plus what it means and what to do, in words. */
200
- function withExplanation(result, report) {
201
- return { ...result, explain: report.explain(result) };
233
+ /**
234
+ * The JSON a scan prints: the result, plus what it means and what to do, in words. The
235
+ * verdict comes first and the list of findings last, so an answer that a reader cuts short
236
+ * still says the most important thing.
237
+ *
238
+ * @param {{ failOn: string, exitCode: number }} ending
239
+ */
240
+ function withExplanation(result, report, ending) {
241
+ const { risk, leaks, findings, ...facts } = result;
242
+ return { risk, leaks, ...ending, explain: report.explain(result), ...facts, findings };
243
+ }
244
+
245
+ /**
246
+ * How a command ends when that follows from a result. A leak is worth failing a release
247
+ * check over when it is critical or high, unless `--fail-on` says otherwise: medium ones
248
+ * too, or critical ones alone. The answer says how it ends as well, for whoever reads a
249
+ * log later and for an agent that is not shown the exit code: `failOn` and `exitCode` in
250
+ * the JSON, and a closing line in the text (`closeWith`).
251
+ */
252
+ function endingOf(result, options) {
253
+ const stops = hasLeakAtOrAbove(result.leaks, options.failOn);
254
+ const exitCode = stops ? EXIT_FINDINGS : result.risk === 'unrated' ? EXIT_UNREAD : EXIT_OK;
255
+ return { failOn: options.failOn, exitCode };
202
256
  }
203
257
 
204
- /** Critical and high leaks are the ones worth failing a release check over. */
205
- function exitCodeFor({ leaks }) {
206
- return leaks.critical + leaks.high > 0 ? EXIT_FINDINGS : EXIT_OK;
258
+ function closeWith({ failOn, exitCode }, options, report, terminal) {
259
+ const closing = options.json ? null : report.closing(exitCode, failOn);
260
+ if (closing) terminal.print(`\n${closing}`);
261
+ return exitCode;
207
262
  }
208
263
 
209
264
  function readPackageVersion() {
package/src/electron.mjs CHANGED
@@ -1,14 +1,9 @@
1
- import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { readAt } from './bytes.mjs';
4
-
5
1
  // Layout from @electron/fuses: the sentinel, one version byte, one length byte, then one
6
2
  // byte per fuse in FuseV1Options order.
7
3
  const SENTINEL = Buffer.from('dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX');
8
4
  const WIRE_VERSION = 1;
9
5
  const FUSE_ENABLED = 0x31;
10
6
  const WIRE_HEADER_BYTES = 2;
11
- const SCAN_CHUNK_BYTES = 4 * 1024 * 1024;
12
7
  // Fuses that let someone run or debug the shipped app as a development tool. All three are
13
8
  // on in a stock Electron build.
14
9
  const DEVELOPMENT_FUSES = [
@@ -16,53 +11,33 @@ const DEVELOPMENT_FUSES = [
16
11
  [2, 'nodeOptions'],
17
12
  [3, 'nodeCliInspect'],
18
13
  ];
19
- const MACOS_FRAMEWORK_NAME = 'Electron Framework.framework';
20
-
21
- /** Where the fuses of a macOS Electron app live, given a directory the scanner came across. */
22
- export function electronBinaryIn(directory, name) {
23
- return name === MACOS_FRAMEWORK_NAME ? join(directory, 'Electron Framework') : null;
24
- }
25
14
 
26
15
  /**
27
- * Reports development fuses left on in a packaged Electron binary.
16
+ * Reports development fuses left on, when a stretch of a file holds Electron's fuse list.
17
+ * The list sits in Electron's own binary, whatever the system and whatever the file is
18
+ * called, so it is looked for in the bytes and not by name.
28
19
  *
29
- * @param {string} path how the binary is shown to the user
30
- * @param {string} binaryPath absolute path
20
+ * @param {string} path how the file is shown to the user
21
+ * @param {import('./bytes.mjs').ByteSource} source the whole file
22
+ * @param {{ position: number, bytes: Buffer }} chunk the stretch of it being searched
31
23
  */
32
- export function checkElectronFuses(path, binaryPath) {
33
- const enabled = readEnabledDevelopmentFuses(binaryPath);
24
+ export function checkElectronFuses(path, source, { position, bytes }) {
25
+ const index = bytes.indexOf(SENTINEL);
26
+ if (index === -1) return [];
27
+ const enabled = readEnabledDevelopmentFuses(source, position + index + SENTINEL.length);
34
28
  if (!enabled || enabled.length === 0) return [];
35
29
  return [{ rule: 'electron-fuse', severity: 'medium', path, detail: { enabled } }];
36
30
  }
37
31
 
38
- /** @returns {string[] | null} null when the file is not an Electron binary this can read */
39
- function readEnabledDevelopmentFuses(binaryPath) {
40
- let fd;
32
+ /** @returns {string[] | null} null when what follows the sentinel is not a fuse list this can read */
33
+ function readEnabledDevelopmentFuses(source, wirePosition) {
41
34
  try {
42
- fd = openSync(binaryPath, 'r');
43
- const sentinelPosition = findSentinel(fd, fstatSync(fd).size);
44
- if (sentinelPosition === -1) return null;
45
-
46
- const wirePosition = sentinelPosition + SENTINEL.length;
47
- const [version, length] = readAt(fd, wirePosition, WIRE_HEADER_BYTES);
35
+ const [version, length] = source.read(wirePosition, WIRE_HEADER_BYTES);
48
36
  if (version !== WIRE_VERSION) return null;
49
- const wire = readAt(fd, wirePosition + WIRE_HEADER_BYTES, length);
50
- return DEVELOPMENT_FUSES.filter(([index]) => wire[index] === FUSE_ENABLED).map(([, name]) => name);
37
+ const wire = source.read(wirePosition + WIRE_HEADER_BYTES, length);
38
+ return DEVELOPMENT_FUSES.filter(([fuse]) => wire[fuse] === FUSE_ENABLED).map(([, name]) => name);
51
39
  } catch {
52
- // Missing, unreadable or cut short: nothing can be said about it.
40
+ // Cut short: nothing can be said about it.
53
41
  return null;
54
- } finally {
55
- if (fd !== undefined) closeSync(fd);
56
- }
57
- }
58
-
59
- /** Reads the file in overlapping chunks, so a sentinel across a chunk boundary is still found. */
60
- function findSentinel(fd, fileBytes) {
61
- const buffer = Buffer.alloc(SCAN_CHUNK_BYTES + SENTINEL.length - 1);
62
- for (let position = 0; position < fileBytes; position += SCAN_CHUNK_BYTES) {
63
- const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
64
- const index = buffer.subarray(0, bytesRead).indexOf(SENTINEL);
65
- if (index !== -1) return position + index;
66
42
  }
67
- return -1;
68
43
  }
package/src/macho.mjs ADDED
@@ -0,0 +1,256 @@
1
+ // Layouts are those of Apple's <mach-o/fat.h>, <mach-o/loader.h> and <mach-o/nlist.h>, and
2
+ // of xnu's cs_blobs.h for the signature. A Mach-O is little-endian on every Mac built since
3
+ // 2006; the universal wrapper and the signature are big-endian.
4
+ const UNIVERSAL_MAGIC = 0xcafebabe;
5
+ const UNIVERSAL_MAGIC_64 = 0xcafebabf;
6
+ const MAGIC_32 = 0xfeedface;
7
+ const MAGIC_64 = 0xfeedfacf;
8
+ // A Java class file opens with the same four bytes as a universal binary and then states
9
+ // its version, 45 or more, where a universal binary counts its slices.
10
+ const MAX_SLICES = 16;
11
+ const HEADER_BYTES = { [MAGIC_32]: 28, [MAGIC_64]: 32 };
12
+ const SYMBOL_BYTES = { [MAGIC_32]: 12, [MAGIC_64]: 16 };
13
+ const MAX_COMMAND_BYTES = 16 * 1024 * 1024;
14
+
15
+ const FILE_KINDS = { 2: 'executable', 6: 'library', 8: 'library' };
16
+ const ARM64 = 0x0100000c;
17
+ const ARCHITECTURES = { [ARM64]: 'arm64', 0x01000007: 'x86_64', 0x0200000c: 'arm64_32', 7: 'i386', 12: 'arm' };
18
+ const ARM64E_SUBTYPE = 2;
19
+ const SUBTYPE_MASK = 0x00ffffff;
20
+
21
+ const LC_SYMTAB = 0x2;
22
+ const LC_CODE_SIGNATURE = 0x1d;
23
+ const LC_BUILD_VERSION = 0x32;
24
+ // Builds from before LC_BUILD_VERSION say what they are for by which "minimum version"
25
+ // command they carry: macOS, iOS, tvOS or watchOS.
26
+ const PLATFORM_BY_VERSION_COMMAND = { 0x24: 1, 0x25: 2, 0x2f: 3, 0x30: 4 };
27
+ // PLATFORM_* of <mach-o/loader.h>. Mac Catalyst apps run on the Mac under its rules; the
28
+ // phone, TV, watch and headset systems and their simulators share theirs.
29
+ const SYSTEM_BY_PLATFORM = { 1: 'macos', 6: 'macos', 2: 'ios', 3: 'ios', 4: 'ios', 7: 'ios', 8: 'ios', 9: 'ios', 11: 'ios', 12: 'ios' };
30
+ const MACOS_PLATFORM = 1;
31
+
32
+ // In a symbol's type byte: any of the top three bits marks an entry written for a debugger,
33
+ // the lowest bit a name that other files may link against, and the three between them say
34
+ // where the name is defined, or with none of them set, that it is defined somewhere else.
35
+ const DEBUG_ENTRY_BITS = 0xe0;
36
+ const EXTERNAL_BIT = 0x01;
37
+ const DEFINED_BITS = 0x0e;
38
+ const SOURCE_NAME_ENTRY = 0x64;
39
+ const SYMBOLS_PER_READ = 65536;
40
+ const MAX_NAME_BYTES = 1024;
41
+ // Real programs name a few thousand original files in a few hundred kilobytes. The limit
42
+ // keeps a file that claims far more from filling memory.
43
+ const MAX_SOURCE_NAME_BYTES = 4 * 1024 * 1024;
44
+
45
+ const EMBEDDED_SIGNATURE_MAGIC = 0xfade0cc0;
46
+ const CODE_DIRECTORY_MAGIC = 0xfade0c02;
47
+ const ENTITLEMENTS_MAGIC = 0xfade7171;
48
+ const CODE_DIRECTORY_SLOT = 0;
49
+ const ENTITLEMENTS_SLOT = 5;
50
+ const MAX_SIGNATURE_BLOBS = 64;
51
+ const MAX_ENTITLEMENTS_BYTES = 1024 * 1024;
52
+
53
+ /**
54
+ * One architecture's build inside a Mach-O file.
55
+ *
56
+ * @typedef {object} MachOImage
57
+ * @property {string} architecture for example 'arm64'
58
+ * @property {'executable' | 'library' | 'other'} kind
59
+ * @property {'macos' | 'ios' | 'other'} system which family of Apple systems it is built for;
60
+ * 'ios' takes in tvOS, watchOS and visionOS, whose rules are the same
61
+ * @property {number} debugEntries symbol table entries written for a debugger
62
+ * @property {number} localNames names of functions and variables that nothing outside the file needs
63
+ * @property {number} exportedNames names defined here that other files may link against
64
+ * @property {string[]} sourcePaths the original files, as the linker recorded where they were
65
+ * @property {{ flags: number, entitlements: string } | null} signature null when the build is not signed
66
+ */
67
+
68
+ /**
69
+ * Reads what a Mach-O file says about itself, without running it or any other program.
70
+ *
71
+ * @param {import('./bytes.mjs').ByteSource} source
72
+ * @returns {MachOImage[] | null} one image per architecture; null when the bytes are not a
73
+ * Mach-O, or are cut short or malformed, which the system would not load either
74
+ */
75
+ export function readMachO(source) {
76
+ try {
77
+ const images = listSlices(source).map(({ start, bytes }) => readImage(readerWithin(source.read, start, bytes)));
78
+ return images.length > 0 && !images.includes(null) ? images : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** A reader that stays inside one stretch of the bytes, whatever offsets the file claims. */
85
+ function readerWithin(read, start, bytes) {
86
+ return Object.assign(
87
+ (offset, length) => {
88
+ if (offset + length > bytes) throw new Error('a read reaches past the end');
89
+ return read(start + offset, length);
90
+ },
91
+ { bytes },
92
+ );
93
+ }
94
+
95
+ /** @returns {{ start: number, bytes: number }[]} where each architecture's build sits; none for another kind of file */
96
+ function listSlices(source) {
97
+ if (source.byteLength < 8) return [];
98
+ const opening = source.read(0, 8);
99
+ const magic = opening.readUInt32BE(0);
100
+ if (magic !== UNIVERSAL_MAGIC && magic !== UNIVERSAL_MAGIC_64) return [{ start: 0, bytes: source.byteLength }];
101
+
102
+ const sliceCount = opening.readUInt32BE(4);
103
+ if (sliceCount > MAX_SLICES) return [];
104
+ const isWide = magic === UNIVERSAL_MAGIC_64;
105
+ const entryBytes = isWide ? 32 : 20;
106
+ const table = source.read(8, sliceCount * entryBytes);
107
+ return Array.from({ length: sliceCount }, (_, index) => {
108
+ const entry = table.subarray(index * entryBytes);
109
+ const start = isWide ? Number(entry.readBigUInt64BE(8)) : entry.readUInt32BE(8);
110
+ const bytes = isWide ? Number(entry.readBigUInt64BE(16)) : entry.readUInt32BE(12);
111
+ if (start + bytes > source.byteLength) throw new Error('a slice reaches past the end of the file');
112
+ return { start, bytes };
113
+ }).sort((one, other) => one.start - other.start).map((slice, index, slices) => {
114
+ // Each build has its own stretch of the file. Builds that share bytes are not what a
115
+ // linker writes, and would have the same bytes read once for every one of them.
116
+ if (index > 0 && slice.start < slices[index - 1].start + slices[index - 1].bytes) throw new Error('slices overlap');
117
+ return slice;
118
+ });
119
+ }
120
+
121
+ /**
122
+ * @param {((offset: number, length: number) => Buffer) & { bytes: number }} read stays inside the slice
123
+ * @returns {MachOImage | null} null when the slice is not a Mach-O image
124
+ */
125
+ function readImage(read) {
126
+ const magic = read.bytes >= 4 ? read(0, 4).readUInt32LE(0) : 0;
127
+ if (!(magic in HEADER_BYTES)) return null;
128
+ const header = read(0, HEADER_BYTES[magic]);
129
+ const commandBytes = header.readUInt32LE(20);
130
+ if (commandBytes > MAX_COMMAND_BYTES) throw new Error('the load commands are implausibly large');
131
+ const commands = read(HEADER_BYTES[magic], commandBytes);
132
+ const commandCount = header.readUInt32LE(16);
133
+
134
+ let symbolTable = null;
135
+ let signature = null;
136
+ // A build that does not say what it is for is taken as one for the Mac.
137
+ let platform = MACOS_PLATFORM;
138
+ // A read past the end of the commands throws, which also ends a count that is not true.
139
+ for (let index = 0, offset = 0; index < commandCount; index += 1) {
140
+ const command = commands.readUInt32LE(offset);
141
+ const size = commands.readUInt32LE(offset + 4);
142
+ if (size < 8) throw new Error('a load command has no size');
143
+ if (command === LC_SYMTAB) {
144
+ symbolTable = {
145
+ offset: commands.readUInt32LE(offset + 8),
146
+ count: commands.readUInt32LE(offset + 12),
147
+ namesOffset: commands.readUInt32LE(offset + 16),
148
+ namesBytes: commands.readUInt32LE(offset + 20),
149
+ };
150
+ } else if (command === LC_CODE_SIGNATURE) {
151
+ signature = { offset: commands.readUInt32LE(offset + 8), bytes: commands.readUInt32LE(offset + 12) };
152
+ } else if (command === LC_BUILD_VERSION) {
153
+ platform = commands.readUInt32LE(offset + 8);
154
+ } else if (command in PLATFORM_BY_VERSION_COMMAND) {
155
+ platform = PLATFORM_BY_VERSION_COMMAND[command];
156
+ }
157
+ offset += size;
158
+ }
159
+
160
+ return {
161
+ architecture: nameArchitecture(header.readUInt32LE(4), header.readUInt32LE(8)),
162
+ kind: FILE_KINDS[header.readUInt32LE(12)] ?? 'other',
163
+ system: SYSTEM_BY_PLATFORM[platform] ?? 'other',
164
+ ...(symbolTable ? readSymbols(read, symbolTable, SYMBOL_BYTES[magic]) : NO_SYMBOLS),
165
+ signature: signature && readSignature(readerWithin(read, signature.offset, signature.bytes)),
166
+ };
167
+ }
168
+
169
+ const NO_SYMBOLS = { debugEntries: 0, localNames: 0, exportedNames: 0, sourcePaths: [] };
170
+
171
+ function nameArchitecture(cpuType, cpuSubtype) {
172
+ if (cpuType === ARM64 && (cpuSubtype & SUBTYPE_MASK) === ARM64E_SUBTYPE) return 'arm64e';
173
+ return ARCHITECTURES[cpuType] ?? `cpu ${cpuType}`;
174
+ }
175
+
176
+ function readSymbols(read, { offset, count, namesOffset, namesBytes }, symbolBytes) {
177
+ let debugEntries = 0;
178
+ let localNames = 0;
179
+ let exportedNames = 0;
180
+ let sourceNameBytes = 0;
181
+ const sourceNames = [];
182
+ const readName = (nameOffset) => {
183
+ if (nameOffset >= namesBytes) return '';
184
+ const name = read(namesOffset + nameOffset, Math.min(MAX_NAME_BYTES, namesBytes - nameOffset));
185
+ const end = name.indexOf(0);
186
+ return name.toString('utf8', 0, end === -1 ? name.length : end);
187
+ };
188
+
189
+ for (let first = 0; first < count; first += SYMBOLS_PER_READ) {
190
+ const symbolsRead = Math.min(SYMBOLS_PER_READ, count - first);
191
+ const symbols = read(offset + first * symbolBytes, symbolsRead * symbolBytes);
192
+ for (let at = 0; at < symbols.length; at += symbolBytes) {
193
+ const type = symbols[at + 4];
194
+ if (type & DEBUG_ENTRY_BITS) {
195
+ debugEntries += 1;
196
+ if (type === SOURCE_NAME_ENTRY && sourceNameBytes < MAX_SOURCE_NAME_BYTES) {
197
+ const name = readName(symbols.readUInt32LE(at));
198
+ sourceNames.push(name);
199
+ // An empty name still costs a read, so it counts for something.
200
+ sourceNameBytes += name.length + 1;
201
+ }
202
+ } else if (!(type & EXTERNAL_BIT)) {
203
+ localNames += 1;
204
+ } else if (type & DEFINED_BITS) {
205
+ exportedNames += 1;
206
+ }
207
+ }
208
+ }
209
+ return { debugEntries, localNames, exportedNames, sourcePaths: joinSourcePaths(sourceNames) };
210
+ }
211
+
212
+ /**
213
+ * The linker records each original file as two names in a row, its folder (ending in a
214
+ * slash) and then the file, and closes the file's entries with an empty name.
215
+ */
216
+ function joinSourcePaths(names) {
217
+ const paths = new Set();
218
+ let folder = '';
219
+ for (const name of names) {
220
+ if (name.endsWith('/')) {
221
+ folder = name;
222
+ } else {
223
+ if (name) paths.add(name.startsWith('/') ? name : folder + name);
224
+ folder = '';
225
+ }
226
+ }
227
+ return [...paths];
228
+ }
229
+
230
+ /** @param {(offset: number, length: number) => Buffer} read stays inside the signature */
231
+ function readSignature(read) {
232
+ const opening = read(0, 12);
233
+ if (opening.readUInt32BE(0) !== EMBEDDED_SIGNATURE_MAGIC) throw new Error('not an embedded signature');
234
+ const blobCount = opening.readUInt32BE(8);
235
+ if (blobCount > MAX_SIGNATURE_BLOBS) throw new Error('the signature lists implausibly many parts');
236
+ const index = read(12, blobCount * 8);
237
+
238
+ let flags = null;
239
+ let entitlements = '';
240
+ for (let at = 0; at < index.length; at += 8) {
241
+ const slot = index.readUInt32BE(at);
242
+ const blobOffset = index.readUInt32BE(at + 4);
243
+ if (slot === CODE_DIRECTORY_SLOT) {
244
+ const codeDirectory = read(blobOffset, 16);
245
+ if (codeDirectory.readUInt32BE(0) !== CODE_DIRECTORY_MAGIC) throw new Error('not a code directory');
246
+ flags = codeDirectory.readUInt32BE(12);
247
+ } else if (slot === ENTITLEMENTS_SLOT) {
248
+ const blob = read(blobOffset, 8);
249
+ if (blob.readUInt32BE(0) !== ENTITLEMENTS_MAGIC) throw new Error('not an entitlements list');
250
+ const textBytes = Math.min(blob.readUInt32BE(4) - 8, MAX_ENTITLEMENTS_BYTES);
251
+ entitlements = read(blobOffset + 8, textBytes).toString('utf8');
252
+ }
253
+ }
254
+ if (flags === null) throw new Error('the signature has no code directory');
255
+ return { flags, entitlements };
256
+ }