@bun-native/cli 0.33.0 → 0.33.1

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.
@@ -0,0 +1,95 @@
1
+ import { homedir } from 'node:os'
2
+ import { basename, dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Where this CLI runs from, computed once per invocation.
6
+ *
7
+ * - `checkout`: `scripts/native.ts` in a Bun Native checkout. The version is the
8
+ * workspace's root `package.json`; released hosts are cached in the checkout's
9
+ * `.bun-native/hosts`.
10
+ * - `package`: the installed `@bun-native/cli`, whose sources live under `src/`.
11
+ * The version is the package's own manifest. Released hosts are cached in the
12
+ * per-user cache, never inside the digest-verified CLI package and never in the
13
+ * consumer project, whose `.bun-native/` holds only `dev` modules.
14
+ */
15
+ export type CliContext = {
16
+ mode: 'checkout' | 'package'
17
+ /** The directory that holds `scripts/native.ts`: the checkout root, or the package's `src`. */
18
+ root: string
19
+ /** Absent only for a bare copy of the CLI sources; host downloads then need --version. */
20
+ version: string | undefined
21
+ hostCache: string
22
+ /** How the usage text spells this CLI. */
23
+ command: string
24
+ }
25
+
26
+ type Environment = Record<string, string | undefined>
27
+
28
+ const packageCommand = 'bun --no-install ./node_modules/@bun-native/cli/src/scripts/native.ts'
29
+
30
+ async function manifest(path: string): Promise<{ name?: unknown; version?: unknown } | undefined> {
31
+ const file = Bun.file(path)
32
+ return (await file.exists()) ? file.json() : undefined
33
+ }
34
+
35
+ /**
36
+ * The per-user cache for state the CLI keeps between runs (released hosts,
37
+ * verification stamps, the compiler digest). Projects stay free of it.
38
+ */
39
+ export function userCache(
40
+ environment: Environment = process.env,
41
+ platform: NodeJS.Platform = process.platform,
42
+ home: string = homedir()
43
+ ): string {
44
+ const base =
45
+ environment['XDG_CACHE_HOME'] ||
46
+ (platform === 'darwin'
47
+ ? join(home, 'Library', 'Caches')
48
+ : platform === 'win32'
49
+ ? environment['LOCALAPPDATA'] || join(home, 'AppData', 'Local')
50
+ : join(home, '.cache'))
51
+ return join(base, 'bun-native')
52
+ }
53
+
54
+ /** Released hosts of an installed CLI. */
55
+ export function userHostCache(
56
+ environment: Environment = process.env,
57
+ platform: NodeJS.Platform = process.platform,
58
+ home: string = homedir()
59
+ ): string {
60
+ return join(userCache(environment, platform, home), 'hosts')
61
+ }
62
+
63
+ export async function cliContext(
64
+ root: string,
65
+ environment: Environment = process.env
66
+ ): Promise<CliContext> {
67
+ const packageRoot = dirname(root)
68
+ const installed =
69
+ basename(root) === 'src' ? await manifest(join(packageRoot, 'package.json')) : undefined
70
+ if (installed?.name === '@bun-native/cli') {
71
+ return {
72
+ mode: 'package',
73
+ root,
74
+ version: typeof installed.version === 'string' ? installed.version : undefined,
75
+ hostCache: userHostCache(environment),
76
+ command: packageCommand,
77
+ }
78
+ }
79
+ const workspace = await manifest(join(root, 'package.json'))
80
+ return {
81
+ mode: 'checkout',
82
+ root,
83
+ version: typeof workspace?.version === 'string' ? workspace.version : undefined,
84
+ hostCache: join(root, '.bun-native', 'hosts'),
85
+ command: 'bun run native',
86
+ }
87
+ }
88
+
89
+ /** The release version for host downloads: an explicit one, else this CLI's own. */
90
+ export function releaseVersion(context: CliContext, explicit?: string): string {
91
+ const version = explicit ?? context.version
92
+ if (version === undefined)
93
+ throw new Error('This CLI has no package version; pass host install --version x.y.z')
94
+ return version
95
+ }
@@ -1,6 +1,6 @@
1
1
  import toolchain from '../../toolchain.json'
2
+ import { cliContext } from './context.ts'
2
3
  import { prebuiltHost } from './host.ts'
3
- import { readProject } from './project.ts'
4
4
 
5
5
  type Diagnosis = { name: string; status: 'ok' | 'failed' | 'skipped'; detail: string }
6
6
 
@@ -55,11 +55,12 @@ export async function diagnose(
55
55
  results.push({ name: 'project', status: 'skipped', detail: 'pass a project directory' })
56
56
  else
57
57
  try {
58
+ const { readProject } = await import('./project.ts')
58
59
  const project = await readProject(repository, options.project)
59
60
  results.push({
60
61
  name: 'project',
61
62
  status: 'ok',
62
- detail: `${project.framework} project ${project.directory}${project.packageSet ? ' with verified installed package set' : ' in this checkout'}`,
63
+ detail: `${project.framework} project ${project.directory}${project.packageSet ? ' with verified installed package set' : (await cliContext(repository)).mode === 'package' ? ' with registry packages' : ' in this checkout'}`,
63
64
  })
64
65
  } catch (error) {
65
66
  results.push({ name: 'project', status: 'failed', detail: message(error) })
@@ -1,8 +1,9 @@
1
- import { createHash } from 'node:crypto'
2
1
  import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
3
2
  import { dirname, join } from 'node:path'
4
3
  import { gunzipSync } from 'node:zlib'
4
+ import { sha256 } from './artifact.ts'
5
5
  import { prebuiltHost } from './host.ts'
6
+ import { ustarChecksumValid, ustarNumber } from './ustar.ts'
6
7
 
7
8
  /** The platforms whose release host archives the release workflow attaches (ADR 0021). */
8
9
  export const releaseHostTargets = [
@@ -16,6 +17,7 @@ export type ReleaseHostTarget = (typeof releaseHostTargets)[number]
16
17
  const releases = 'https://github.com/0xPlayerOne/bun-native/releases/download'
17
18
  const maximumArchiveBytes = 512 * 1024 * 1024
18
19
  const maximumChecksumBytes = 64 * 1024
20
+ const defaultTimeoutMs = 5 * 60_000
19
21
 
20
22
  export function releaseHostTarget(
21
23
  platform: string = process.platform,
@@ -44,12 +46,6 @@ export function checksumFor(sums: string, asset: string): string {
44
46
  return matches[0]![1]!
45
47
  }
46
48
 
47
- function octal(header: Buffer, offset: number, length: number): number {
48
- const text = header.toString('latin1', offset, offset + length).replace(/[\0 ]+$/, '')
49
- if (!/^[0-7]+$/.test(text)) throw new Error('Host archive has an invalid USTAR number')
50
- return Number.parseInt(text, 8)
51
- }
52
-
53
49
  /**
54
50
  * The single `bun-native-host` member of a host archive made by host-archive.ts:
55
51
  * a gzip-compressed USTAR stream with exactly one regular file, mode 0755.
@@ -64,11 +60,9 @@ export function hostFromArchive(archive: Uint8Array): Buffer {
64
60
  if (header[156] !== 0x30 && header[156] !== 0)
65
61
  throw new Error('Host member is not a regular file')
66
62
  if (header.toString('latin1', 257, 262) !== 'ustar') throw new Error('Host archive is not USTAR')
67
- let checksum = 0
68
- for (let index = 0; index < 512; index++)
69
- checksum += index >= 148 && index < 156 ? 0x20 : header[index]!
70
- if (checksum !== octal(header, 148, 8)) throw new Error('Host archive header checksum mismatch')
71
- const size = octal(header, 124, 12)
63
+ if (!ustarChecksumValid(header)) throw new Error('Host archive header checksum mismatch')
64
+ const size = ustarNumber(header.subarray(124, 136))
65
+ if (size === undefined) throw new Error('Host archive has an invalid USTAR number')
72
66
  const end = 512 + Math.ceil(size / 512) * 512
73
67
  if (size === 0 || end + 1024 > tar.length) throw new Error('Host archive is truncated')
74
68
  if (tar.subarray(end).some((byte) => byte !== 0))
@@ -76,14 +70,55 @@ export function hostFromArchive(archive: Uint8Array): Buffer {
76
70
  return Buffer.from(tar.subarray(512, 512 + size))
77
71
  }
78
72
 
79
- async function download(url: string, limit: number): Promise<Uint8Array> {
80
- const response = await fetch(url, { redirect: 'follow' })
81
- if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`)
82
- const declared = Number(response.headers.get('content-length') ?? '0')
83
- if (declared > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
84
- const bytes = new Uint8Array(await response.arrayBuffer())
85
- if (bytes.length > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
86
- return bytes
73
+ /**
74
+ * One bounded download: the same deadline covers the request and the whole body,
75
+ * which is streamed into a byte-limited buffer (the install-bun/download.mjs pattern).
76
+ * A stalled release server fails the command instead of hanging it.
77
+ */
78
+ async function download(url: string, limit: number, timeoutMs: number): Promise<Uint8Array> {
79
+ const controller = new AbortController()
80
+ const expired = () => new Error(`Download exceeded ${timeoutMs} ms: ${url}`)
81
+ let timer: ReturnType<typeof setTimeout> | undefined
82
+ const deadline = new Promise<never>((_, reject) => {
83
+ timer = setTimeout(() => {
84
+ controller.abort()
85
+ reject(expired())
86
+ }, timeoutMs)
87
+ })
88
+ const operation = (async () => {
89
+ let response: Response
90
+ try {
91
+ response = await fetch(url, { redirect: 'follow', signal: controller.signal })
92
+ } catch (error) {
93
+ if (controller.signal.aborted) throw expired()
94
+ throw error
95
+ }
96
+ try {
97
+ if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`)
98
+ const declared = Number(response.headers.get('content-length') ?? '0')
99
+ if (declared > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
100
+ const parts: Uint8Array[] = []
101
+ let total = 0
102
+ for await (const part of response.body ?? []) {
103
+ total += part.byteLength
104
+ if (total > limit) throw new Error(`Download is larger than ${limit} bytes: ${url}`)
105
+ parts.push(part)
106
+ }
107
+ return Buffer.concat(parts, total)
108
+ } catch (error) {
109
+ if (controller.signal.aborted) throw expired()
110
+ throw error
111
+ } finally {
112
+ // Release the connection on every early exit; a completed body is a no-op.
113
+ controller.abort()
114
+ }
115
+ })()
116
+ try {
117
+ return await Promise.race([operation, deadline])
118
+ } finally {
119
+ clearTimeout(timer)
120
+ operation.catch(() => {})
121
+ }
87
122
  }
88
123
 
89
124
  export type HostInstallOptions = {
@@ -94,9 +129,11 @@ export type HostInstallOptions = {
94
129
  cache: string
95
130
  force?: boolean
96
131
  target?: ReleaseHostTarget
132
+ /** Deadline for each download, request and body together. Defaults to 5 minutes. */
133
+ timeoutMs?: number
97
134
  }
98
135
 
99
- export function installedHostPath(options: HostInstallOptions): string {
136
+ function installedHostPath(options: HostInstallOptions): string {
100
137
  const target = options.target ?? releaseHostTarget()
101
138
  return join(options.cache, `v${options.version}`, target, 'bun-native-host')
102
139
  }
@@ -117,10 +154,13 @@ export async function installReleaseHost(options: HostInstallOptions): Promise<s
117
154
  return prebuiltHost(destination)
118
155
  const base = `${(options.baseUrl ?? process.env['BUN_NATIVE_HOST_RELEASE_URL'] ?? releases).replace(/\/+$/, '')}/v${options.version}`
119
156
  const asset = releaseHostAsset(target)
120
- const sums = new TextDecoder().decode(await download(`${base}/SHA256SUMS`, maximumChecksumBytes))
157
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs
158
+ const sums = new TextDecoder().decode(
159
+ await download(`${base}/SHA256SUMS`, maximumChecksumBytes, timeoutMs)
160
+ )
121
161
  const expected = checksumFor(sums, asset)
122
- const archive = await download(`${base}/${asset}`, maximumArchiveBytes)
123
- const actual = createHash('sha256').update(archive).digest('hex')
162
+ const archive = await download(`${base}/${asset}`, maximumArchiveBytes, timeoutMs)
163
+ const actual = sha256(archive)
124
164
  if (actual !== expected)
125
165
  throw new Error(`${asset} digest ${actual} does not match SHA256SUMS ${expected}`)
126
166
  const host = hostFromArchive(archive)
@@ -1,5 +1,3 @@
1
- import { createHash } from 'node:crypto'
2
- import { createReadStream } from 'node:fs'
3
1
  import { readdir, realpath, stat } from 'node:fs/promises'
4
2
  import { join, relative } from 'node:path'
5
3
 
@@ -22,9 +20,40 @@ function disappeared(error: unknown): boolean {
22
20
  )
23
21
  }
24
22
 
23
+ type Input = { name: string; path: string; link: boolean }
24
+
25
+ /** The digest entry for one input, or undefined when it vanished or is not a file. */
26
+ async function inputDigest(
27
+ directory: string,
28
+ input: Input,
29
+ signal?: AbortSignal
30
+ ): Promise<string | undefined> {
31
+ try {
32
+ // File links are hashed through their targets. Do not follow directory
33
+ // links: recursive aliases can escape the project or introduce cycles.
34
+ if (input.link && !(await stat(input.path)).isFile()) return undefined
35
+ const target = input.link ? relative(directory, await realpath(input.path)) : null
36
+ const contents = await Bun.file(input.path).bytes()
37
+ signal?.throwIfAborted()
38
+ return JSON.stringify([
39
+ input.name,
40
+ target,
41
+ new Bun.CryptoHasher('sha256').update(contents).digest('hex'),
42
+ ])
43
+ } catch (error) {
44
+ // Deletion/atomic replacement during a scan is an absent input; a
45
+ // queued create/change event requests another scan. Other IO fails.
46
+ if (!disappeared(error)) throw error
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ // Enough concurrent reads to overlap file system latency without exhausting descriptors.
52
+ const READERS = 16
53
+
25
54
  /** One content digest, with no retained file contents or earlier generations. */
26
55
  export async function projectFingerprint(directory: string, signal?: AbortSignal): Promise<string> {
27
- const fingerprint = createHash('sha256')
56
+ const inputs: Input[] = []
28
57
  const visit = async (folder: string, prefix: string): Promise<void> => {
29
58
  signal?.throwIfAborted()
30
59
  let entries
@@ -36,30 +65,31 @@ export async function projectFingerprint(directory: string, signal?: AbortSignal
36
65
  }
37
66
  entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
38
67
  for (const entry of entries) {
39
- signal?.throwIfAborted()
40
68
  if (ignored.has(entry.name)) continue
41
69
  const path = join(folder, entry.name)
42
70
  const name = prefix + entry.name
43
71
  if (entry.isDirectory()) await visit(path, name + '/')
44
- else if (/\.(?:[cm]?[jt]s|[jt]sx|jsonc?|toml)$/.test(name)) {
45
- try {
46
- // File links are hashed through their targets. Do not follow directory
47
- // links: recursive aliases can escape the project or introduce cycles.
48
- if (!entry.isFile() && !(entry.isSymbolicLink() && (await stat(path)).isFile())) continue
49
- const target = entry.isSymbolicLink() ? relative(directory, await realpath(path)) : null
50
- const contents = createHash('sha256')
51
- for await (const chunk of createReadStream(path, { signal })) contents.update(chunk)
52
- signal?.throwIfAborted()
53
- fingerprint.update(JSON.stringify([name, target, contents.digest('hex')]))
54
- } catch (error) {
55
- // Deletion/atomic replacement during a scan is an absent input; a
56
- // queued create/change event requests another scan. Other IO fails.
57
- if (!disappeared(error)) throw error
58
- }
59
- }
72
+ else if (
73
+ /\.(?:[cm]?[jt]s|[jt]sx|jsonc?|toml)$/.test(name) &&
74
+ (entry.isFile() || entry.isSymbolicLink())
75
+ )
76
+ inputs.push({ name, path, link: entry.isSymbolicLink() })
60
77
  }
61
78
  }
62
79
  await visit(directory, '')
80
+ // Read and hash files concurrently, then fold them in the sorted traversal order,
81
+ // so the digest is identical to a serial scan.
82
+ const digests: (string | undefined)[] = Array.from({ length: inputs.length })
83
+ let next = 0
84
+ const reader = async () => {
85
+ for (let index = next++; index < inputs.length; index = next++) {
86
+ signal?.throwIfAborted()
87
+ digests[index] = await inputDigest(directory, inputs[index]!, signal)
88
+ }
89
+ }
90
+ await Promise.all(Array.from({ length: Math.min(READERS, inputs.length) }, reader))
63
91
  signal?.throwIfAborted()
92
+ const fingerprint = new Bun.CryptoHasher('sha256')
93
+ for (const digest of digests) if (digest !== undefined) fingerprint.update(digest)
64
94
  return fingerprint.digest('hex')
65
95
  }
@@ -38,6 +38,6 @@ export function lintArguments(project: Project): string[] {
38
38
  return args
39
39
  }
40
40
 
41
- export function lintProject(project: Project): QualityStep {
42
- return { name: 'lint', ...runQualityStep(project, lintArguments(project)) }
41
+ export async function lintProject(project: Project): Promise<QualityStep> {
42
+ return { name: 'lint', ...(await runQualityStep(project, lintArguments(project))) }
43
43
  }
@@ -1,9 +1,8 @@
1
- import { createHash } from 'node:crypto'
2
1
  import { gunzipSync } from 'node:zlib'
3
- import type { FileDigest } from './artifact.ts'
2
+ import { sha256, type FileDigest } from './artifact.ts'
4
3
  import { packageArchivePath } from './paths.ts'
4
+ import { USTAR_BLOCK as block, ustarChecksumValid, ustarNumber } from './ustar.ts'
5
5
 
6
- const block = 512
7
6
  const maximumBytes = 64 * 1024 * 1024
8
7
 
9
8
  function text(field: Buffer): string {
@@ -14,10 +13,8 @@ function text(field: Buffer): string {
14
13
  }
15
14
 
16
15
  function octal(field: Buffer): number {
17
- const value = field.toString('latin1')
18
- if (!/^[\0 ]*[0-7]+[\0 ]*$/.test(value)) throw new Error('Invalid package tar numeric field')
19
- const number = Number.parseInt(value.replaceAll('\0', '').trim(), 8)
20
- if (!Number.isSafeInteger(number)) throw new Error('Invalid package tar size')
16
+ const number = ustarNumber(field)
17
+ if (number === undefined) throw new Error('Invalid package tar numeric field')
21
18
  return number
22
19
  }
23
20
 
@@ -57,11 +54,8 @@ export async function verifyPackageArchive(
57
54
  ended = true
58
55
  break
59
56
  }
60
- const checksum = octal(header.subarray(148, 156))
61
- let sum = 0
62
- for (let index = 0; index < block; index += 1)
63
- sum += index >= 148 && index < 156 ? 32 : header[index]!
64
- if (sum !== checksum) throw new Error('Invalid package tar header checksum')
57
+ octal(header.subarray(148, 156))
58
+ if (!ustarChecksumValid(header)) throw new Error('Invalid package tar header checksum')
65
59
  if (!['ustar\0', 'ustar '].includes(header.subarray(257, 263).toString('latin1')))
66
60
  throw new Error('Package archive must use the supported USTAR format')
67
61
  const kind = header[156] === 0 ? '0' : String.fromCharCode(header[156]!)
@@ -88,10 +82,7 @@ export async function verifyPackageArchive(
88
82
  } else {
89
83
  const expected = files.get(name)
90
84
  if (!expected) throw new Error(`Packed archive file inventory differs: ${name}`)
91
- if (
92
- size !== expected.bytes ||
93
- createHash('sha256').update(data.subarray(start, end)).digest('hex') !== expected.sha256
94
- )
85
+ if (size !== expected.bytes || sha256(data.subarray(start, end)) !== expected.sha256)
95
86
  throw new Error(`Packed archive digest mismatch: ${name}`)
96
87
  regular.add(name)
97
88
  }
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto'
2
1
  import { readdir, realpath, stat } from 'node:fs/promises'
3
2
  import { dirname, join } from 'node:path'
4
3
  import { isDeepStrictEqual } from 'node:util'
@@ -11,7 +10,7 @@ import {
11
10
  type DependencyLock,
12
11
  } from './package-lock.ts'
13
12
  import { inside, packageArchivePath, packagePath } from './paths.ts'
14
- import { fileDigest, type FileDigest } from './artifact.ts'
13
+ import { fileDigest, sha256, type FileDigest } from './artifact.ts'
15
14
  import { verifyPackageArchive } from './package-archive.ts'
16
15
 
17
16
  export const packageNames = [
@@ -160,7 +159,7 @@ function packageManifest(
160
159
  if (
161
160
  !isDeepStrictEqual(contents['package.json'], {
162
161
  bytes: Buffer.byteLength(text),
163
- sha256: createHash('sha256').update(text).digest('hex'),
162
+ sha256: sha256(text),
164
163
  })
165
164
  )
166
165
  throw new Error(`Package manifest digest mismatch: ${name}`)
@@ -302,6 +301,12 @@ async function containedFile(directory: string, path: string): Promise<string> {
302
301
  return target
303
302
  }
304
303
 
304
+ /** Run checks concurrently, but report the first failure in their listed order. */
305
+ async function settledInOrder(tasks: Promise<void>[]): Promise<void> {
306
+ for (const result of await Promise.allSettled(tasks))
307
+ if (result.status === 'rejected') throw result.reason
308
+ }
309
+
305
310
  export async function readPackageSet(path: string): Promise<ReadPackageSet> {
306
311
  if (Bun.revision !== toolchain.bun.revision)
307
312
  throw new Error('Use the pinned Bun for this package set')
@@ -311,12 +316,14 @@ export async function readPackageSet(path: string): Promise<ReadPackageSet> {
311
316
  const manifest = parsePackageSet(JSON.parse(text))
312
317
  if (text !== JSON.stringify(manifest, null, 2) + '\n')
313
318
  throw new Error('Package-set manifest must use its original canonical JSON encoding')
314
- for (const [name, pkg] of Object.entries(manifest.packages)) {
315
- const file = await containedFile(directory, pkg.archive.file)
316
- if (!isDeepStrictEqual(await fileDigest(file), digest(pkg.archive)))
317
- throw new Error(`Package archive digest mismatch: ${name}`)
318
- await verifyPackageArchive(file, pkg.files)
319
- }
319
+ await settledInOrder(
320
+ Object.entries(manifest.packages).map(async ([name, pkg]) => {
321
+ const file = await containedFile(directory, pkg.archive.file)
322
+ if (!isDeepStrictEqual(await fileDigest(file), digest(pkg.archive)))
323
+ throw new Error(`Package archive digest mismatch: ${name}`)
324
+ await verifyPackageArchive(file, pkg.files)
325
+ })
326
+ )
320
327
  return { directory, manifest, text }
321
328
  }
322
329
 
@@ -362,7 +369,7 @@ export async function verifyInstalledPackages(
362
369
  ...pkg.files,
363
370
  '.bun-tag': {
364
371
  bytes: Buffer.byteLength(tag),
365
- sha256: createHash('sha256').update(tag).digest('hex'),
372
+ sha256: sha256(tag),
366
373
  },
367
374
  }
368
375
  const expected = Object.keys(installedFiles).sort()
@@ -385,11 +392,13 @@ export async function verifyInstalledPackages(
385
392
  await visit(root, '')
386
393
  if (!isDeepStrictEqual(inventory.sort(), expected))
387
394
  throw new Error(`Installed package file inventory differs: ${name}`)
388
- for (const [path, expected] of Object.entries(installedFiles)) {
389
- const file = await containedFile(root, path)
390
- if (!isDeepStrictEqual(await fileDigest(file), expected))
391
- throw new Error(`Installed package digest mismatch: ${name}/${path}`)
392
- }
395
+ await settledInOrder(
396
+ Object.entries(installedFiles).map(async ([path, expected]) => {
397
+ const file = await containedFile(root, path)
398
+ if (!isDeepStrictEqual(await fileDigest(file), expected))
399
+ throw new Error(`Installed package digest mismatch: ${name}/${path}`)
400
+ })
401
+ )
393
402
  }
394
403
  for (const name of required) {
395
404
  const root = locations.get(name)!