@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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
|
+
}
|