@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,147 @@
1
+ // The run history: one `runs` row per task outcome and one `invocations`
2
+ // header per `vx run`, written together, plus their 30-day retention.
3
+ // What `vx why`, `vx last`, `stats` and the history reader read. Owns
4
+ // its statements over the store's handle; `Cache` delegates.
5
+
6
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
+ import type { InvocationRecord, RunRecord } from './layer.js'
8
+
9
+ export class RunHistory {
10
+ private readonly insertRun: ReturnType<Database['prepare']>
11
+ private readonly insertInvocation: ReturnType<Database['prepare']>
12
+
13
+ constructor(private readonly db: Database) {
14
+ this.insertRun = this.db.prepare(`
15
+ INSERT INTO runs(
16
+ hash, project, task, status, exit_code, duration_ms, forward_args,
17
+ started_at, ended_at,
18
+ run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
19
+ cache_hit, attempts, cached
20
+ )
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
+ `)
23
+ this.insertInvocation = this.db.prepare(`
24
+ INSERT INTO invocations(
25
+ run_id, command, requested_tasks, cache_policy, concurrency, flow,
26
+ started_at, ended_at, total_duration_ms,
27
+ task_count, failed_count, hit_count, hit_local_count, hit_remote_count,
28
+ exit_ok,
29
+ commit_sha, branch, dirty, ci, ci_provider,
30
+ host, os, arch, vx_version, tags
31
+ )
32
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33
+ ON CONFLICT(run_id) DO NOTHING
34
+ `)
35
+ }
36
+
37
+ recordRun(run: RunRecord): void {
38
+ this.insertRun.run(...bindRun(run))
39
+ }
40
+
41
+ recordRuns(runs: readonly RunRecord[]): void {
42
+ if (runs.length === 0) return
43
+ if (runs.length === 1) {
44
+ this.insertRun.run(...bindRun(runs[0]!))
45
+ return
46
+ }
47
+ // `bun:sqlite`'s `transaction()` returns a callable that wraps the
48
+ // body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
49
+ // run that's one fsync instead of 200.
50
+ const insert = this.insertRun
51
+ const tx = this.db.transaction((batch: readonly RunRecord[]) => {
52
+ for (const r of batch) insert.run(...bindRun(r))
53
+ })
54
+ tx(runs)
55
+ }
56
+
57
+ recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
58
+ // Whole run records atomically — one transaction, one fsync: the
59
+ // per-task `runs` rows and the one `invocations` header. The
60
+ // input-fingerprint rows do NOT live here: they're written inside
61
+ // the entry-save transaction (`save`/`ingest`) so a warm
62
+ // all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
63
+ // mismatch — pays nothing for the moat it isn't refreshing.
64
+ const insertRun = this.insertRun
65
+ const insertInvocation = this.insertInvocation
66
+ this.db.transaction(() => {
67
+ for (const r of bundle.runs) insertRun.run(...bindRun(r))
68
+ insertInvocation.run(...bindInvocation(bundle.invocation))
69
+ })()
70
+ }
71
+
72
+ /**
73
+ * Retention: the runs table grows by one row per executed task per
74
+ * invocation — a 2000-task repo accretes ~20k rows in days. 30 days
75
+ * covers `vx stats` (24 h windows) and CI-side consumers. The
76
+ * `invocations` header is pruned on the SAME window, or a header would
77
+ * outlive its rows and `vx last` would list a run whose detail is gone.
78
+ */
79
+ pruneOlderThan(cutoff: number): void {
80
+ this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
81
+ this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Bind a RunRecord to the positional parameters expected by the
87
+ * `insertRun` prepared statement (17 columns). Shared between the
88
+ * single and batched record paths.
89
+ */
90
+ function bindRun(run: RunRecord): SQLQueryBindings[] {
91
+ return [
92
+ // The ONE place the no-key sentinel is applied, so the column's
93
+ // NOT NULL invariant can't be violated from a call site.
94
+ run.hash ?? '',
95
+ run.project,
96
+ run.task,
97
+ run.status,
98
+ run.exitCode,
99
+ run.durationMs,
100
+ run.forwardArgs ? JSON.stringify(run.forwardArgs) : null,
101
+ run.startedAt,
102
+ run.endedAt,
103
+ run.runId ?? null,
104
+ run.cpuMs ?? null,
105
+ run.peakRssBytes ?? null,
106
+ run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
107
+ run.wallclockEndNs !== undefined ? run.wallclockEndNs : null,
108
+ run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
109
+ run.attempts ?? null,
110
+ run.cached === undefined ? null : run.cached ? 1 : 0,
111
+ ]
112
+ }
113
+
114
+ /**
115
+ * Bind an InvocationRecord to the positional parameters of the
116
+ * `insertInvocation` prepared statement (25 columns). Booleans map to
117
+ * 0/1; null-or-bool columns (`dirty`) keep null distinct from 0.
118
+ */
119
+ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
120
+ return [
121
+ inv.runId,
122
+ inv.command,
123
+ inv.requestedTasks,
124
+ inv.cachePolicy,
125
+ inv.concurrency,
126
+ inv.flow,
127
+ inv.startedAt,
128
+ inv.endedAt,
129
+ inv.totalDurationMs,
130
+ inv.taskCount,
131
+ inv.failedCount,
132
+ inv.hitCount,
133
+ inv.hitLocalCount,
134
+ inv.hitRemoteCount,
135
+ inv.exitOk ? 1 : 0,
136
+ inv.commitSha,
137
+ inv.branch,
138
+ inv.dirty === null ? null : inv.dirty ? 1 : 0,
139
+ inv.ci ? 1 : 0,
140
+ inv.ciProvider,
141
+ inv.host,
142
+ inv.os,
143
+ inv.arch,
144
+ inv.vxVersion,
145
+ inv.tags,
146
+ ]
147
+ }
@@ -0,0 +1,358 @@
1
+ // A streaming tar reader for the restore path. `Bun.Archive` needs the whole
2
+ // tar in memory and `.files()` copies every entry, so restoring a 150 MiB
3
+ // artifact peaked at 3.2× its size (measured 2026-09-03). This reads the
4
+ // tar as it streams out of the zstd decoder and hands each entry's body to
5
+ // the caller as chunks, so memory is bounded by one chunk, not the archive.
6
+ //
7
+ // Dialect: what libarchive (Bun.Archive) writes — ustar with the name/prefix
8
+ // split, pax extended headers (`x`: path, size) past ustar's limits — plus
9
+ // GNU long names (`L`) for foreign artifacts. Every header's checksum is
10
+ // verified; an archive that ends before its data does is an error, never a
11
+ // short entry. Non-regular entries are reported with their type and their
12
+ // bodies skipped; the caller decides what to materialise (nothing but `0`).
13
+
14
+ export interface TarEntry {
15
+ name: string
16
+ size: number
17
+ /** POSIX typeflag: '0' regular, '5' directory, '2' symlink, … */
18
+ type: string
19
+ /** Header mtime in ms (second precision — the sidecar carries the real one). */
20
+ mtimeMs: number
21
+ /** The entry's bytes, in stream order. Must be drained before the next entry. */
22
+ body: AsyncIterable<Uint8Array>
23
+ }
24
+
25
+ export class TarFormatError extends Error {
26
+ constructor(message: string) {
27
+ super(message)
28
+ this.name = 'TarFormatError'
29
+ }
30
+ }
31
+
32
+ const BLOCK = 512
33
+ const decoder = new TextDecoder()
34
+
35
+ function field(h: Uint8Array, off: number, len: number): string {
36
+ let end = off
37
+ while (end < off + len && h[end] !== 0) end++
38
+ return decoder.decode(h.subarray(off, end))
39
+ }
40
+
41
+ function octal(h: Uint8Array, off: number, len: number): number {
42
+ // GNU base-256 (high bit set) for sizes ≥ 8 GiB.
43
+ if (h[off]! & 0x80) {
44
+ let n = 0
45
+ for (let i = off + 1; i < off + len; i++) n = n * 256 + h[i]!
46
+ return n
47
+ }
48
+ const s = field(h, off, len).trim()
49
+ if (s === '') return 0
50
+ // Every digit, not the longest parseable prefix: `parseInt` would read
51
+ // a corrupt `5zz` as 5 and let the damage surface later, if at all.
52
+ if (!/^[0-7]+$/.test(s)) throw new TarFormatError(`bad octal field: ${JSON.stringify(s)}`)
53
+ return parseInt(s, 8)
54
+ }
55
+
56
+ function checksumOk(h: Uint8Array): boolean {
57
+ const stored = octal(h, 148, 8)
58
+ let sum = 0
59
+ for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : h[i]!
60
+ return sum === stored
61
+ }
62
+
63
+ /** pax `x` body: `<len> <key>=<value>\n` records. */
64
+ function parsePax(body: Uint8Array): Map<string, string> {
65
+ const out = new Map<string, string>()
66
+ let i = 0
67
+ while (i < body.byteLength) {
68
+ let sp = i
69
+ while (sp < body.byteLength && body[sp] !== 32) sp++
70
+ const len = Number(decoder.decode(body.subarray(i, sp)))
71
+ if (!Number.isInteger(len) || len <= 0 || i + len > body.byteLength) {
72
+ throw new TarFormatError('malformed pax record')
73
+ }
74
+ const rec = decoder.decode(body.subarray(sp + 1, i + len - 1))
75
+ const eq = rec.indexOf('=')
76
+ if (eq > 0) out.set(rec.slice(0, eq), rec.slice(eq + 1))
77
+ i += len
78
+ }
79
+ return out
80
+ }
81
+
82
+ /** A byte source with exact-size reads over a stream of arbitrary chunks. */
83
+ class Source {
84
+ private chunks: Uint8Array[] = []
85
+ private head = 0
86
+ private done = false
87
+ constructor(private readonly reader: AsyncIterator<Uint8Array>) {}
88
+
89
+ private async fill(): Promise<boolean> {
90
+ if (this.done) return false
91
+ const { done, value } = await this.reader.next()
92
+ if (done === true || value === undefined) {
93
+ this.done = true
94
+ return false
95
+ }
96
+ if (value.byteLength > 0) this.chunks.push(value)
97
+ return true
98
+ }
99
+
100
+ /** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
101
+ async exact(n: number, what: string): Promise<Uint8Array | null> {
102
+ const out = new Uint8Array(n)
103
+ let got = 0
104
+ while (got < n) {
105
+ if (this.chunks.length === 0 && !(await this.fill())) {
106
+ if (got === 0) return null
107
+ throw new TarFormatError(`archive ends inside ${what} (${got} of ${n} bytes)`)
108
+ }
109
+ const c = this.chunks[0]!
110
+ const take = Math.min(n - got, c.byteLength - this.head)
111
+ out.set(c.subarray(this.head, this.head + take), got)
112
+ got += take
113
+ this.head += take
114
+ if (this.head === c.byteLength) {
115
+ this.chunks.shift()
116
+ this.head = 0
117
+ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ /** Up to `n` bytes as they arrive (never more), for streaming a body. */
123
+ async *take(n: number, what: string): AsyncIterable<Uint8Array> {
124
+ let left = n
125
+ while (left > 0) {
126
+ if (this.chunks.length === 0 && !(await this.fill())) {
127
+ throw new TarFormatError(`archive ends inside ${what} (${n - left} of ${n} bytes)`)
128
+ }
129
+ const c = this.chunks[0]!
130
+ const take = Math.min(left, c.byteLength - this.head)
131
+ const piece = c.subarray(this.head, this.head + take)
132
+ left -= take
133
+ this.head += take
134
+ if (this.head === c.byteLength) {
135
+ this.chunks.shift()
136
+ this.head = 0
137
+ }
138
+ yield piece
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Iterate a tar stream entry by entry. Each entry's `body` MUST be fully
145
+ * consumed (or the iterator drains it) before the next entry is read.
146
+ */
147
+ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGenerator<TarEntry> {
148
+ const src = new Source(stream[Symbol.asyncIterator]())
149
+ let pendingPath: string | undefined
150
+ let pendingSize: number | undefined
151
+ let zeroBlocks = 0
152
+ for (;;) {
153
+ const h = await src.exact(BLOCK, 'a header')
154
+ if (h === null) {
155
+ if (zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
156
+ return
157
+ }
158
+ if (h.every((b) => b === 0)) {
159
+ zeroBlocks++
160
+ if (zeroBlocks >= 2) return
161
+ continue
162
+ }
163
+ zeroBlocks = 0
164
+ if (!checksumOk(h)) throw new TarFormatError('header checksum mismatch')
165
+ const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]!)
166
+ const size = pendingSize ?? octal(h, 124, 12)
167
+ const mtimeMs = octal(h, 136, 12) * 1000
168
+ const padded = Math.ceil(size / BLOCK) * BLOCK
169
+ const magic = field(h, 257, 6)
170
+ const prefix = magic.startsWith('ustar') ? field(h, 345, 155) : ''
171
+ const rawName = field(h, 0, 100)
172
+ let name = pendingPath ?? (prefix ? `${prefix}/${rawName}` : rawName)
173
+ pendingPath = undefined
174
+ pendingSize = undefined
175
+
176
+ if (type === 'x' || type === 'L') {
177
+ // Extended header: applies to the NEXT entry only.
178
+ const body = await src.exact(padded, 'an extended header')
179
+ if (body === null) throw new TarFormatError('archive ends inside an extended header')
180
+ if (type === 'x') {
181
+ const pax = parsePax(body.subarray(0, size))
182
+ const p = pax.get('path')
183
+ if (p !== undefined) pendingPath = p
184
+ const s = pax.get('size')
185
+ if (s !== undefined) pendingSize = Number(s)
186
+ } else {
187
+ pendingPath = field(body, 0, size)
188
+ }
189
+ continue
190
+ }
191
+ if (type === 'g') {
192
+ // pax global header: not used by Bun.Archive; skip its body.
193
+ await src.exact(padded, 'a global header')
194
+ continue
195
+ }
196
+ name = name.replace(/\/+$/, type === '5' ? '/' : '')
197
+
198
+ let drained = false
199
+ const body = (async function* (): AsyncIterable<Uint8Array> {
200
+ if (size > 0) for await (const c of src.take(size, `entry ${name}`)) yield c
201
+ const pad = padded - size
202
+ if (pad > 0 && (await src.exact(pad, `padding after ${name}`)) === null) {
203
+ throw new TarFormatError(`archive ends inside padding after ${name}`)
204
+ }
205
+ drained = true
206
+ })()
207
+ yield { name, size, type, mtimeMs, body }
208
+ if (!drained) {
209
+ // The caller skipped this body: drain it so the next header lines up.
210
+ for await (const _ of body) {
211
+ // discard
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // ─── Writer ───────────────────────────────────────────────────────────
218
+
219
+ /** One regular file to pack. `body` is read as it is written. */
220
+ export interface TarInput {
221
+ name: string
222
+ size: number
223
+ /** Permission bits; the sidecar carries the exact value, this is for foreign readers. */
224
+ mode?: number
225
+ /** Seconds since epoch; the sidecar carries the millisecond value. */
226
+ mtime?: number
227
+ body: Blob | Uint8Array | string
228
+ }
229
+
230
+ const encoder = new TextEncoder()
231
+
232
+ function writeOctal(h: Uint8Array, off: number, len: number, n: number): void {
233
+ const digits = n.toString(8).padStart(len - 1, '0')
234
+ if (digits.length > len - 1)
235
+ throw new TarFormatError(`value ${n} does not fit a ${len}-byte field`)
236
+ h.set(encoder.encode(digits), off)
237
+ h[off + len - 1] = 0
238
+ }
239
+
240
+ function header(
241
+ name: string | Uint8Array,
242
+ size: number,
243
+ type: string,
244
+ mode: number,
245
+ mtime: number,
246
+ ): Uint8Array {
247
+ const h = new Uint8Array(BLOCK)
248
+ const nameBytes = typeof name === 'string' ? encoder.encode(name) : name
249
+ if (nameBytes.byteLength <= 100) {
250
+ h.set(nameBytes, 0)
251
+ } else {
252
+ // ustar prefix split: the longest tail that fits 100 bytes, split at
253
+ // a `/`, with the head fitting 155. Anything else needs pax.
254
+ const cut = splitForUstar(nameBytes)
255
+ if (cut === null)
256
+ throw new TarFormatError(`name too long for ustar: ${decoder.decode(nameBytes)}`)
257
+ h.set(nameBytes.subarray(cut + 1), 0)
258
+ h.set(nameBytes.subarray(0, cut), 345)
259
+ }
260
+ writeOctal(h, 100, 8, mode & 0o7777)
261
+ writeOctal(h, 108, 8, 0)
262
+ writeOctal(h, 116, 8, 0)
263
+ writeOctal(h, 124, 12, size)
264
+ writeOctal(h, 136, 12, mtime)
265
+ h[156] = type.charCodeAt(0)
266
+ h.set(encoder.encode('ustar\0'), 257)
267
+ h.set(encoder.encode('00'), 263)
268
+ h.fill(32, 148, 156)
269
+ let sum = 0
270
+ for (let i = 0; i < BLOCK; i++) sum += h[i]!
271
+ h.set(encoder.encode(sum.toString(8).padStart(6, '0')), 148)
272
+ h[154] = 0
273
+ h[155] = 32
274
+ return h
275
+ }
276
+
277
+ /** Byte index of the `/` to split at, or null when no split satisfies both fields. */
278
+ function splitForUstar(name: Uint8Array): number | null {
279
+ for (let i = name.byteLength - 1; i > 0; i--) {
280
+ if (name[i] !== 47) continue
281
+ const tail = name.byteLength - i - 1
282
+ if (tail > 100) return null
283
+ if (tail >= 1 && i <= 155) return i
284
+ }
285
+ return null
286
+ }
287
+
288
+ function paxRecord(key: string, value: string): Uint8Array {
289
+ const body = encoder.encode(` ${key}=${value}\n`)
290
+ let len = body.byteLength + 1
291
+ while (String(len).length + body.byteLength !== len) len = String(len).length + body.byteLength
292
+ const out = new Uint8Array(len)
293
+ out.set(encoder.encode(String(len)), 0)
294
+ out.set(body, String(len).length)
295
+ return out
296
+ }
297
+
298
+ const padding = (size: number): Uint8Array => new Uint8Array((BLOCK - (size % BLOCK)) % BLOCK)
299
+ const padded = (size: number): number => size + ((BLOCK - (size % BLOCK)) % BLOCK)
300
+
301
+ /** Whether `name` needs a pax `path` record (fits neither ustar field). */
302
+ function needsPax(name: string): boolean {
303
+ const bytes = encoder.encode(name)
304
+ return bytes.byteLength > 100 && splitForUstar(bytes) === null
305
+ }
306
+
307
+ /** The exact number of bytes `tarPack` writes for these inputs. */
308
+ export function tarSize(inputs: readonly TarInput[]): number {
309
+ let size = BLOCK * 2
310
+ for (const i of inputs) {
311
+ if (needsPax(i.name)) size += BLOCK + padded(paxRecord('path', i.name).byteLength)
312
+ size += BLOCK + padded(i.size)
313
+ }
314
+ return size
315
+ }
316
+
317
+ /**
318
+ * Pack regular files as a tar stream: ustar with the name/prefix split,
319
+ * a pax `path` record when a name fits neither field — exactly the
320
+ * dialect `tarEntries` reads — and the two-block end marker. Bodies are
321
+ * streamed, so memory is bounded by a chunk, never by an entry.
322
+ */
323
+ export async function* tarPack(
324
+ inputs: AsyncIterable<TarInput> | Iterable<TarInput>,
325
+ ): AsyncGenerator<Uint8Array> {
326
+ for await (const input of inputs) {
327
+ const mode = input.mode ?? 0o644
328
+ const mtime = input.mtime ?? 0
329
+ // Under a pax record the ustar name is a courtesy for readers that
330
+ // ignore pax: its first 100 BYTES, never characters — a multibyte
331
+ // name sliced by characters can be over 100 bytes again.
332
+ let headerName: string | Uint8Array = input.name
333
+ if (needsPax(input.name)) {
334
+ const pax = paxRecord('path', input.name)
335
+ yield header('PaxHeaders/entry', pax.byteLength, 'x', 0o644, mtime)
336
+ yield pax
337
+ yield padding(pax.byteLength)
338
+ headerName = encoder.encode(input.name).subarray(0, 100)
339
+ }
340
+ yield header(headerName, input.size, '0', mode, mtime)
341
+ if (input.body instanceof Blob) {
342
+ let n = 0
343
+ for await (const chunk of input.body.stream()) {
344
+ n += chunk.byteLength
345
+ yield chunk
346
+ }
347
+ if (n !== input.size)
348
+ throw new TarFormatError(`${input.name}: ${n} bytes read, ${input.size} declared`)
349
+ } else {
350
+ const bytes = typeof input.body === 'string' ? encoder.encode(input.body) : input.body
351
+ if (bytes.byteLength !== input.size)
352
+ throw new TarFormatError(`${input.name}: ${bytes.byteLength} bytes, ${input.size} declared`)
353
+ yield bytes
354
+ }
355
+ yield padding(input.size)
356
+ }
357
+ yield new Uint8Array(BLOCK * 2)
358
+ }
@@ -0,0 +1,160 @@
1
+ // zstd framing for artifacts: the declared-size gate that refuses a
2
+ // decompression bomb before it can expand, and the bounded one-call /
3
+ // streamed decoders the store and the ingest path share.
4
+
5
+ import { CorruptArtifactError } from './layer.js'
6
+
7
+ /**
8
+ * A decompressed artifact above this is refused as a zstd bomb rather than
9
+ * expanded into memory. 2 GiB comfortably exceeds any real build output while
10
+ * bounding a malicious/compromised remote's ability to OOM a victim who takes
11
+ * a cache hit.
12
+ */
13
+ const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
14
+
15
+ /**
16
+ * Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
17
+ * decompressing. Returns null when the frame omits it (streaming frames) or
18
+ * the header is too short to parse. vx's own producer (single-shot
19
+ * `Bun.zstdCompress` of a known buffer) always writes it, so an artifact that
20
+ * declares an enormous size can be rejected before a byte is allocated.
21
+ *
22
+ * Exported for tests: pins the per-`fcsFlag` byte layouts (incl. the 2-byte
23
+ * `+256` adjustment and the `dictIdFlag` offset) that a bomb-refusal e2e can't
24
+ * discriminate (their max declarable sizes sit below the ceiling).
25
+ */
26
+ export function zstdContentSize(b: Uint8Array): bigint | null {
27
+ if (b.length < 5) return null
28
+ // Magic_Number 0xFD2FB528, little-endian.
29
+ if (b[0] !== 0x28 || b[1] !== 0xb5 || b[2] !== 0x2f || b[3] !== 0xfd) return null
30
+ const desc = b[4]!
31
+ const fcsFlag = desc >> 6
32
+ const singleSegment = (desc >> 5) & 1
33
+ const dictIdFlag = desc & 3
34
+ let off = 5
35
+ if (singleSegment === 0) off += 1 // Window_Descriptor byte
36
+ off += dictIdFlag === 3 ? 4 : dictIdFlag // Dictionary_ID: 0,1,2,4 bytes
37
+ let fcsSize: number
38
+ if (fcsFlag === 0) fcsSize = singleSegment === 1 ? 1 : 0
39
+ else if (fcsFlag === 1) fcsSize = 2
40
+ else if (fcsFlag === 2) fcsSize = 4
41
+ else fcsSize = 8
42
+ if (fcsSize === 0 || b.length < off + fcsSize) return null
43
+ let v = 0n
44
+ for (let i = 0; i < fcsSize; i++) v |= BigInt(b[off + i]!) << BigInt(8 * i)
45
+ if (fcsSize === 2) v += 256n // per spec, the 2-byte field stores value − 256
46
+ return v
47
+ }
48
+
49
+ /**
50
+ * Decompress a zstd artifact that DECLARES its content size, with a hard
51
+ * output ceiling: refused before a byte is allocated when the declaration
52
+ * is over the cap, and again on the actual length. A frame with no
53
+ * declaration (a streamed producer's — vx's own, above 4 MiB) never comes
54
+ * here: `decodedTar` decodes it as a stream under the running count, so
55
+ * a sizeless bomb has nowhere to expand.
56
+ */
57
+ async function zstdDecompressBounded(compressed: Uint8Array, hash: string): Promise<Uint8Array> {
58
+ assertDeclaredSize(compressed, hash)
59
+ const out = await Bun.zstdDecompress(compressed)
60
+ if (out.length > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
61
+ throw new CorruptArtifactError(
62
+ hash,
63
+ `decompressed to ${out.length} bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
64
+ )
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** The pre-decompress half of the ceiling: the frame header's own claim, when it makes one. */
70
+ function assertDeclaredSize(compressed: Uint8Array, hash: string): bigint | null {
71
+ const declared = zstdContentSize(compressed)
72
+ if (declared !== null && declared > BigInt(MAX_DECOMPRESSED_ARTIFACT_BYTES)) {
73
+ throw new CorruptArtifactError(
74
+ hash,
75
+ `declares ${declared} decompressed bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
76
+ )
77
+ }
78
+ return declared
79
+ }
80
+
81
+ /** Compressed artifacts above this size are decoded as a stream, on restore and on ingest. */
82
+ export const STREAM_DECODE_FROM = 4 * 1024 * 1024
83
+
84
+ /** Collect a byte stream; only ever used where the seam wants bytes. */
85
+ export async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
86
+ return new Uint8Array(await new Response(stream).arrayBuffer())
87
+ }
88
+
89
+ /**
90
+ * Bun's `CompressionStream` types do not satisfy `pipeThrough`'s pair
91
+ * (its readable side is typed `NonSharedUint8Array`); the runtime object
92
+ * is a plain byte transform.
93
+ */
94
+ export const zstdEncoder = (): TransformStream<Uint8Array, Uint8Array> =>
95
+ new CompressionStream('zstd') as unknown as TransformStream<Uint8Array, Uint8Array>
96
+
97
+ const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
98
+ new ReadableStream({
99
+ start(c) {
100
+ c.enqueue(bytes)
101
+ c.close()
102
+ },
103
+ })
104
+
105
+ /**
106
+ * The decoded tar as a stream: one call for a small artifact that
107
+ * declares its size, a streamed decode otherwise — the stream setup is
108
+ * ~35 µs, which matters at a thousand one-file artifacts and nowhere
109
+ * else. Bytes in memory are only ever the small case (a large source
110
+ * must be a FILE: a Blob copies its bytes and hands the decoder
111
+ * everything at once, measured +519 MiB on 150 MiB against +448 for the
112
+ * plain decode). The 2 GiB ceiling applies either way: the declaration
113
+ * and the result length in one call, a running count on the stream.
114
+ */
115
+ export async function decodedTar(
116
+ source: Uint8Array | Bun.BunFile,
117
+ hash: string,
118
+ ): Promise<ReadableStream<Uint8Array>> {
119
+ if (source instanceof Uint8Array) {
120
+ if (assertDeclaredSize(source, hash) === null) return zstdDecodeStream(new Blob([source]), hash)
121
+ return oneChunk(await zstdDecompressBounded(source, hash))
122
+ }
123
+ if (source.size <= STREAM_DECODE_FROM) {
124
+ const bytes = await source.bytes()
125
+ if (assertDeclaredSize(bytes, hash) === null) return zstdDecodeStream(source, hash)
126
+ return oneChunk(await zstdDecompressBounded(bytes, hash))
127
+ }
128
+ assertDeclaredSize(await source.slice(0, 32).bytes(), hash)
129
+ return zstdDecodeStream(source, hash)
130
+ }
131
+
132
+ /**
133
+ * The streaming twin of `zstdDecompressBounded`: the decoded bytes as a
134
+ * stream, refused past the same ceiling. A malicious frame cannot expand
135
+ * past the cap here either — the count runs as bytes are produced, before
136
+ * any of them reach the reader's next entry.
137
+ */
138
+ function zstdDecodeStream(source: Blob, hash: string): ReadableStream<Uint8Array> {
139
+ let total = 0
140
+ return source
141
+ .stream()
142
+ .pipeThrough(new DecompressionStream('zstd'))
143
+ .pipeThrough(
144
+ new TransformStream<Uint8Array, Uint8Array>({
145
+ transform(chunk, controller) {
146
+ total += chunk.byteLength
147
+ if (total > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
148
+ controller.error(
149
+ new CorruptArtifactError(
150
+ hash,
151
+ `decompresses past ${MAX_DECOMPRESSED_ARTIFACT_BYTES} bytes (cap)`,
152
+ ),
153
+ )
154
+ return
155
+ }
156
+ controller.enqueue(chunk)
157
+ },
158
+ }),
159
+ )
160
+ }