@bun-native/cli 0.0.0-stage → 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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bun-native-source.json +151 -0
  4. package/package.json +63 -4
  5. package/src/.oxfmtrc.json +20 -0
  6. package/src/.oxlintrc.json +12 -0
  7. package/src/experiments/aot-gpui/compile.ts +44 -0
  8. package/src/experiments/aot-gpui/inspect-runtime.ts +304 -0
  9. package/src/experiments/aot-gpui/inspect.ts +66 -0
  10. package/src/scripts/native/artifact.ts +73 -0
  11. package/src/scripts/native/build.ts +231 -0
  12. package/src/scripts/native/bundle.ts +121 -0
  13. package/src/scripts/native/consumer-files.ts +46 -0
  14. package/src/scripts/native/context.ts +95 -0
  15. package/src/scripts/native/doctor.ts +69 -0
  16. package/src/scripts/native/host-download.ts +179 -0
  17. package/src/scripts/native/host-header.ts +33 -0
  18. package/src/scripts/native/host.ts +22 -0
  19. package/src/scripts/native/inputs.ts +95 -0
  20. package/src/scripts/native/lint.ts +43 -0
  21. package/src/scripts/native/package-archive.ts +94 -0
  22. package/src/scripts/native/package-lock.ts +220 -0
  23. package/src/scripts/native/package-set.ts +417 -0
  24. package/src/scripts/native/package-stamp.ts +209 -0
  25. package/src/scripts/native/paths.ts +20 -0
  26. package/src/scripts/native/project.ts +271 -0
  27. package/src/scripts/native/quality.ts +76 -0
  28. package/src/scripts/native/registry-project.ts +90 -0
  29. package/src/scripts/native/session.ts +299 -0
  30. package/src/scripts/native/standalone.ts +64 -0
  31. package/src/scripts/native/ustar.ts +29 -0
  32. package/src/scripts/native/watch.ts +104 -0
  33. package/src/scripts/native.ts +237 -0
  34. package/src/scripts/solid/transform.ts +16 -0
  35. package/src/toolchain.json +74 -0
  36. package/src/tsconfig.json +45 -0
@@ -0,0 +1,69 @@
1
+ import toolchain from '../../toolchain.json'
2
+ import { cliContext } from './context.ts'
3
+ import { prebuiltHost } from './host.ts'
4
+
5
+ type Diagnosis = { name: string; status: 'ok' | 'failed' | 'skipped'; detail: string }
6
+
7
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
8
+
9
+ /**
10
+ * Report toolchain, platform, host and project compatibility without launching anything.
11
+ * A matching host header is not proof of a Bun Native host; the runtime handshake decides that.
12
+ */
13
+ export async function diagnose(
14
+ repository: string,
15
+ options: { host?: string | undefined; project?: string | undefined }
16
+ ): Promise<Diagnosis[]> {
17
+ const results: Diagnosis[] = []
18
+ results.push(
19
+ Bun.revision === toolchain.bun.revision
20
+ ? { name: 'bun', status: 'ok', detail: `pinned revision ${toolchain.bun.cliRevision}` }
21
+ : {
22
+ name: 'bun',
23
+ status: 'failed',
24
+ detail: `running ${Bun.version} (${Bun.revision}); dev, build and package sets require ${toolchain.bun.cliRevision} from toolchain.json`,
25
+ }
26
+ )
27
+ const target = `${process.platform}-${process.arch}`
28
+ results.push(
29
+ target in toolchain.bun.packages
30
+ ? { name: 'platform', status: 'ok', detail: `${target} has a pinned Bun distribution` }
31
+ : {
32
+ name: 'platform',
33
+ status: 'failed',
34
+ detail: `${target} has no pinned Bun distribution; supported: ${Object.keys(toolchain.bun.packages).join(', ')}`,
35
+ }
36
+ )
37
+ if (options.host === undefined)
38
+ results.push({
39
+ name: 'host',
40
+ status: 'skipped',
41
+ detail: 'pass --host /absolute/path/to/bun-native-host to check a prebuilt host',
42
+ })
43
+ else
44
+ try {
45
+ const host = await prebuiltHost(options.host)
46
+ results.push({
47
+ name: 'host',
48
+ status: 'ok',
49
+ detail: `${host} is a readable ${process.platform}/${process.arch} executable (protocol handshake not run)`,
50
+ })
51
+ } catch (error) {
52
+ results.push({ name: 'host', status: 'failed', detail: message(error) })
53
+ }
54
+ if (options.project === undefined)
55
+ results.push({ name: 'project', status: 'skipped', detail: 'pass a project directory' })
56
+ else
57
+ try {
58
+ const { readProject } = await import('./project.ts')
59
+ const project = await readProject(repository, options.project)
60
+ results.push({
61
+ name: 'project',
62
+ status: 'ok',
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'}`,
64
+ })
65
+ } catch (error) {
66
+ results.push({ name: 'project', status: 'failed', detail: message(error) })
67
+ }
68
+ return results
69
+ }
@@ -0,0 +1,179 @@
1
+ import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { gunzipSync } from 'node:zlib'
4
+ import { sha256 } from './artifact.ts'
5
+ import { prebuiltHost } from './host.ts'
6
+ import { ustarChecksumValid, ustarNumber } from './ustar.ts'
7
+
8
+ /** The platforms whose release host archives the release workflow attaches (ADR 0021). */
9
+ export const releaseHostTargets = [
10
+ 'linux-x64',
11
+ 'linux-arm64',
12
+ 'darwin-arm64',
13
+ 'darwin-x64',
14
+ ] as const
15
+ export type ReleaseHostTarget = (typeof releaseHostTargets)[number]
16
+
17
+ const releases = 'https://github.com/0xPlayerOne/bun-native/releases/download'
18
+ const maximumArchiveBytes = 512 * 1024 * 1024
19
+ const maximumChecksumBytes = 64 * 1024
20
+ const defaultTimeoutMs = 5 * 60_000
21
+
22
+ export function releaseHostTarget(
23
+ platform: string = process.platform,
24
+ arch: string = process.arch
25
+ ): ReleaseHostTarget {
26
+ const target = `${platform}-${arch}`
27
+ if (!(releaseHostTargets as readonly string[]).includes(target))
28
+ throw new Error(
29
+ `No released native host for ${target}; supply --host with a host built from source (released: ${releaseHostTargets.join(', ')})`
30
+ )
31
+ return target as ReleaseHostTarget
32
+ }
33
+
34
+ export function releaseHostAsset(target: ReleaseHostTarget): string {
35
+ return `bun-native-host-${target}.tar.gz`
36
+ }
37
+
38
+ /** The expected digest of `asset` from a release's `sha256sum`-format SHA256SUMS. */
39
+ export function checksumFor(sums: string, asset: string): string {
40
+ const matches = sums
41
+ .split('\n')
42
+ .map((line) => /^([0-9a-f]{64}) [ *]([A-Za-z0-9._-]+)$/.exec(line.trimEnd()))
43
+ .filter((match): match is RegExpExecArray => match !== null && match[2] === asset)
44
+ if (matches.length !== 1)
45
+ throw new Error(`SHA256SUMS must list ${asset} exactly once (found ${matches.length})`)
46
+ return matches[0]![1]!
47
+ }
48
+
49
+ /**
50
+ * The single `bun-native-host` member of a host archive made by host-archive.ts:
51
+ * a gzip-compressed USTAR stream with exactly one regular file, mode 0755.
52
+ */
53
+ export function hostFromArchive(archive: Uint8Array): Buffer {
54
+ const tar = gunzipSync(archive, { maxOutputLength: maximumArchiveBytes })
55
+ if (tar.length < 1024 || tar.length % 512 !== 0) throw new Error('Host archive is truncated')
56
+ const header = tar.subarray(0, 512)
57
+ const name = header.toString('latin1', 0, 100).replace(/\0+$/, '')
58
+ if (name !== 'bun-native-host')
59
+ throw new Error(`Host archive must hold only bun-native-host, not ${name || 'nothing'}`)
60
+ if (header[156] !== 0x30 && header[156] !== 0)
61
+ throw new Error('Host member is not a regular file')
62
+ if (header.toString('latin1', 257, 262) !== 'ustar') throw new Error('Host archive is not USTAR')
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')
66
+ const end = 512 + Math.ceil(size / 512) * 512
67
+ if (size === 0 || end + 1024 > tar.length) throw new Error('Host archive is truncated')
68
+ if (tar.subarray(end).some((byte) => byte !== 0))
69
+ throw new Error('Host archive must hold exactly one member')
70
+ return Buffer.from(tar.subarray(512, 512 + size))
71
+ }
72
+
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
+ }
122
+ }
123
+
124
+ export type HostInstallOptions = {
125
+ version: string
126
+ /** Release download base; the tag directory is appended. Defaults to GitHub releases. */
127
+ baseUrl?: string
128
+ /** Directory that holds one host per version and target. */
129
+ cache: string
130
+ force?: boolean
131
+ target?: ReleaseHostTarget
132
+ /** Deadline for each download, request and body together. Defaults to 5 minutes. */
133
+ timeoutMs?: number
134
+ }
135
+
136
+ function installedHostPath(options: HostInstallOptions): string {
137
+ const target = options.target ?? releaseHostTarget()
138
+ return join(options.cache, `v${options.version}`, target, 'bun-native-host')
139
+ }
140
+
141
+ /**
142
+ * Download this version's release host for the current OS and CPU, check the
143
+ * archive against the release's SHA256SUMS, unpack its one member and check its
144
+ * executable header. A cached host from an earlier install is reused unless
145
+ * `force` is set. The host is unsigned; the checksum proves only that the bytes
146
+ * match what the release workflow attached (docs/developer-tooling.md).
147
+ */
148
+ export async function installReleaseHost(options: HostInstallOptions): Promise<string> {
149
+ if (!/^\d+\.\d+\.\d+$/.test(options.version))
150
+ throw new Error(`Release host version must be x.y.z, not ${options.version}`)
151
+ const target = options.target ?? releaseHostTarget()
152
+ const destination = installedHostPath({ ...options, target })
153
+ if (!options.force && (await stat(destination).catch(() => undefined))?.isFile())
154
+ return prebuiltHost(destination)
155
+ const base = `${(options.baseUrl ?? process.env['BUN_NATIVE_HOST_RELEASE_URL'] ?? releases).replace(/\/+$/, '')}/v${options.version}`
156
+ const asset = releaseHostAsset(target)
157
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs
158
+ const sums = new TextDecoder().decode(
159
+ await download(`${base}/SHA256SUMS`, maximumChecksumBytes, timeoutMs)
160
+ )
161
+ const expected = checksumFor(sums, asset)
162
+ const archive = await download(`${base}/${asset}`, maximumArchiveBytes, timeoutMs)
163
+ const actual = sha256(archive)
164
+ if (actual !== expected)
165
+ throw new Error(`${asset} digest ${actual} does not match SHA256SUMS ${expected}`)
166
+ const host = hostFromArchive(archive)
167
+ await mkdir(dirname(destination), { recursive: true })
168
+ const partial = `${destination}.${process.pid}.partial`
169
+ try {
170
+ await writeFile(partial, host, { mode: 0o755 })
171
+ await chmod(partial, 0o755)
172
+ await prebuiltHost(partial)
173
+ await rename(partial, destination)
174
+ } finally {
175
+ await rm(partial, { force: true })
176
+ }
177
+ await writeFile(`${destination}.archive.sha256`, `${actual} ${asset}\n`)
178
+ return prebuiltHost(destination)
179
+ }
@@ -0,0 +1,33 @@
1
+ import type { FileHandle } from 'node:fs/promises'
2
+
3
+ /** Inspect the existing bounded header fields; the caller retains the read handle. */
4
+ export async function hasExecutableHeader(
5
+ file: Pick<FileHandle, 'read'>,
6
+ target: { platform: NodeJS.Platform; arch: NodeJS.Architecture }
7
+ ): Promise<boolean> {
8
+ const header = Buffer.alloc(64)
9
+ const { bytesRead } = await file.read(header, 0, header.length, 0)
10
+ if (bytesRead !== header.length) return false
11
+ if (target.platform === 'linux')
12
+ return (
13
+ header.readUInt32LE(0) === 0x464c457f &&
14
+ header[4] === 2 &&
15
+ header[5] === 1 &&
16
+ header.readUInt16LE(18) === (target.arch === 'arm64' ? 183 : 62)
17
+ )
18
+ if (target.platform === 'darwin')
19
+ return (
20
+ header.readUInt32LE(0) === 0xfeedfacf &&
21
+ header.readUInt32LE(4) === (target.arch === 'arm64' ? 0x0100000c : 0x01000007)
22
+ )
23
+ if (target.platform === 'win32' && header.subarray(0, 2).toString() === 'MZ') {
24
+ const pe = Buffer.alloc(6)
25
+ const read = await file.read(pe, 0, pe.length, header.readUInt32LE(60))
26
+ return (
27
+ read.bytesRead === pe.length &&
28
+ pe.readUInt32LE(0) === 0x00004550 &&
29
+ pe.readUInt16LE(4) === (target.arch === 'arm64' ? 0xaa64 : 0x8664)
30
+ )
31
+ }
32
+ return false
33
+ }
@@ -0,0 +1,22 @@
1
+ import { constants } from 'node:fs'
2
+ import { access, open, realpath, stat } from 'node:fs/promises'
3
+ import { isAbsolute } from 'node:path'
4
+ import { hasExecutableHeader } from './host-header.ts'
5
+
6
+ export async function prebuiltHost(path: string | undefined): Promise<string> {
7
+ if (!path || !isAbsolute(path))
8
+ throw new Error(
9
+ '--host must be an absolute path to an existing prebuilt native host, or auto for the released host; this command never runs Cargo'
10
+ )
11
+ const host = await realpath(path)
12
+ if (!(await stat(host)).isFile()) throw new Error('The prebuilt native host must be a file')
13
+ await access(host, constants.R_OK | (process.platform === 'win32' ? 0 : constants.X_OK))
14
+ const file = await open(host, 'r')
15
+ try {
16
+ if (!(await hasExecutableHeader(file, { platform: process.platform, arch: process.arch })))
17
+ throw new Error(`Prebuilt host must be a ${process.platform}/${process.arch} executable`)
18
+ } finally {
19
+ await file.close()
20
+ }
21
+ return host
22
+ }
@@ -0,0 +1,95 @@
1
+ import { readdir, realpath, stat } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+
4
+ const ignored = new Set(['node_modules', '.git', '.bun-native', 'dist', 'build'])
5
+
6
+ export function rescanInput(path: string | Buffer | null | undefined): boolean {
7
+ // Missing names request a content rescan; they do not establish a source edit.
8
+ if (path === null || path === undefined) return true
9
+ const name = path.toString().replaceAll('\\', '/')
10
+ // A name without an extension may be a directory being replaced or removed.
11
+ // The snapshot decides whether relevant files actually changed.
12
+ return !name.split('/').some((part) => ignored.has(part))
13
+ }
14
+
15
+ function disappeared(error: unknown): boolean {
16
+ return (
17
+ error instanceof Error &&
18
+ 'code' in error &&
19
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
20
+ )
21
+ }
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
+
54
+ /** One content digest, with no retained file contents or earlier generations. */
55
+ export async function projectFingerprint(directory: string, signal?: AbortSignal): Promise<string> {
56
+ const inputs: Input[] = []
57
+ const visit = async (folder: string, prefix: string): Promise<void> => {
58
+ signal?.throwIfAborted()
59
+ let entries
60
+ try {
61
+ entries = await readdir(folder, { withFileTypes: true })
62
+ } catch (error) {
63
+ if (disappeared(error)) return
64
+ throw error
65
+ }
66
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
67
+ for (const entry of entries) {
68
+ if (ignored.has(entry.name)) continue
69
+ const path = join(folder, entry.name)
70
+ const name = prefix + entry.name
71
+ if (entry.isDirectory()) await visit(path, name + '/')
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() })
77
+ }
78
+ }
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))
91
+ signal?.throwIfAborted()
92
+ const fingerprint = new Bun.CryptoHasher('sha256')
93
+ for (const digest of digests) if (digest !== undefined) fingerprint.update(digest)
94
+ return fingerprint.digest('hex')
95
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ // Package the template lint rules with the CLI so projects without their own config use them.
4
+ import '../../.oxlintrc.json'
5
+ // Importing the manifest makes oxlint a pinned dependency of the packaged CLI.
6
+ import oxlint from 'oxlint/package.json'
7
+ import type { Project } from './project.ts'
8
+ import { runQualityStep, type QualityStep } from './quality.ts'
9
+
10
+ /** The rules the generated templates satisfy; a project's own `.oxlintrc.json` replaces them. */
11
+ export const templateLinterConfig = join(import.meta.dir, '../../.oxlintrc.json')
12
+
13
+ /** Generated build output and development bundles are never linted. */
14
+ const generatedOutput = ['dist/**', '.bun-native/**', 'node_modules/**']
15
+
16
+ /** The CLI's own pinned oxlint, resolved from the CLI location rather than the project. */
17
+ function linterEntry(): string {
18
+ const manifest = Bun.resolveSync('oxlint/package.json', import.meta.dir)
19
+ return join(dirname(manifest), oxlint.bin.oxlint)
20
+ }
21
+
22
+ /** Arguments for the pinned linter with deterministic, uncoloured line output. */
23
+ export function lintArguments(project: Project): string[] {
24
+ const own = join(project.directory, '.oxlintrc.json')
25
+ const args = [
26
+ '--no-install',
27
+ linterEntry(),
28
+ '--config',
29
+ existsSync(own) ? own : templateLinterConfig,
30
+ '--deny-warnings',
31
+ '--format',
32
+ 'unix',
33
+ ]
34
+ // Package archives and their canonical manifest are verified by digest, never linted.
35
+ const ignored = project.packageSet ? [...generatedOutput, 'vendor/**'] : generatedOutput
36
+ for (const pattern of ignored) args.push('--ignore-pattern', pattern)
37
+ args.push('.')
38
+ return args
39
+ }
40
+
41
+ export async function lintProject(project: Project): Promise<QualityStep> {
42
+ return { name: 'lint', ...(await runQualityStep(project, lintArguments(project))) }
43
+ }
@@ -0,0 +1,94 @@
1
+ import { gunzipSync } from 'node:zlib'
2
+ import { sha256, type FileDigest } from './artifact.ts'
3
+ import { packageArchivePath } from './paths.ts'
4
+ import { USTAR_BLOCK as block, ustarChecksumValid, ustarNumber } from './ustar.ts'
5
+
6
+ const maximumBytes = 64 * 1024 * 1024
7
+
8
+ function text(field: Buffer): string {
9
+ const end = field.indexOf(0)
10
+ if (end !== -1 && field.subarray(end).some((byte) => byte !== 0))
11
+ throw new Error('Invalid package tar text padding')
12
+ return field.subarray(0, end === -1 ? undefined : end).toString('latin1')
13
+ }
14
+
15
+ function octal(field: Buffer): number {
16
+ const number = ustarNumber(field)
17
+ if (number === undefined) throw new Error('Invalid package tar numeric field')
18
+ return number
19
+ }
20
+
21
+ /** Inspect original USTAR members before any map can erase duplicates or link metadata. */
22
+ export async function verifyPackageArchive(
23
+ path: string,
24
+ expected: Record<string, FileDigest>
25
+ ): Promise<void> {
26
+ const files = new Map<string, FileDigest>()
27
+ const directories = new Set<string>()
28
+ let paddedContents = 0
29
+ for (const [name, digest] of Object.entries(expected)) {
30
+ if (!packageArchivePath(name) || !Number.isSafeInteger(digest.bytes) || digest.bytes < 0)
31
+ throw new Error('Invalid expected package archive inventory')
32
+ const full = `package/${name}`
33
+ files.set(full, digest)
34
+ const parts = full.split('/')
35
+ for (let index = 1; index < parts.length; index += 1)
36
+ directories.add(parts.slice(0, index).join('/'))
37
+ paddedContents += Math.ceil(digest.bytes / block) * block
38
+ }
39
+ // Permit the declared files, directory headers, end blocks and one conventional
40
+ // 10 KiB blocking pad. Bound decompression before parsing any member contents.
41
+ const limit = paddedContents + (files.size + directories.size + 2) * block + 10_240
42
+ if (!Number.isSafeInteger(limit) || limit > maximumBytes || Bun.file(path).size > maximumBytes)
43
+ throw new Error('Package archive exceeds the bounded source-package inspection limit')
44
+ const data = gunzipSync(await Bun.file(path).bytes(), { maxOutputLength: limit })
45
+ if (data.length % block !== 0) throw new Error('Truncated package tar blocks')
46
+ const seen = new Set<string>()
47
+ const regular = new Set<string>()
48
+ let ended = false
49
+ for (let offset = 0; offset + block <= data.length;) {
50
+ const header = data.subarray(offset, offset + block)
51
+ if (header.every((byte) => byte === 0)) {
52
+ if (data.length - offset < block * 2 || data.subarray(offset).some((byte) => byte !== 0))
53
+ throw new Error('Invalid package tar end records or trailing data')
54
+ ended = true
55
+ break
56
+ }
57
+ octal(header.subarray(148, 156))
58
+ if (!ustarChecksumValid(header)) throw new Error('Invalid package tar header checksum')
59
+ if (!['ustar\0', 'ustar '].includes(header.subarray(257, 263).toString('latin1')))
60
+ throw new Error('Package archive must use the supported USTAR format')
61
+ const kind = header[156] === 0 ? '0' : String.fromCharCode(header[156]!)
62
+ if (!['0', '5'].includes(kind) || text(header.subarray(157, 257)) !== '')
63
+ throw new Error(`Unsupported package tar member type or link: ${kind}`)
64
+ const size = octal(header.subarray(124, 136))
65
+ const prefix = text(header.subarray(345, 500))
66
+ const raw = (prefix ? prefix + '/' : '') + text(header.subarray(0, 100))
67
+ const name = kind === '5' ? raw.replace(/\/$/, '') : raw
68
+ if (!packageArchivePath(name))
69
+ throw new Error(`Package archive inventory contains an invalid path: ${raw}`)
70
+ if (seen.has(name)) throw new Error(`Duplicate package tar member: ${name}`)
71
+ seen.add(name)
72
+ const start = offset + block
73
+ const end = start + size
74
+ const next = start + Math.ceil(size / block) * block
75
+ if (!Number.isSafeInteger(next) || next > data.length)
76
+ throw new Error('Truncated package tar member')
77
+ if (data.subarray(end, next).some((byte) => byte !== 0))
78
+ throw new Error('Invalid package tar member padding')
79
+ if (kind === '5') {
80
+ if (size !== 0 || !directories.has(name))
81
+ throw new Error(`Unexpected package tar directory: ${name}`)
82
+ } else {
83
+ const expected = files.get(name)
84
+ if (!expected) throw new Error(`Packed archive file inventory differs: ${name}`)
85
+ if (size !== expected.bytes || sha256(data.subarray(start, end)) !== expected.sha256)
86
+ throw new Error(`Packed archive digest mismatch: ${name}`)
87
+ regular.add(name)
88
+ }
89
+ offset = next
90
+ }
91
+ if (!ended) throw new Error('Package tar has no end records')
92
+ if (regular.size !== files.size)
93
+ throw new Error('Packed archive file inventory differs from the staged package')
94
+ }