@vzn/vx 0.0.18 → 0.0.22
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 +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cache/archive.ts
CHANGED
|
@@ -37,15 +37,17 @@
|
|
|
37
37
|
// skipped — vx's outputs are regular files, and an artifact that claims
|
|
38
38
|
// otherwise silently loses the claim rather than acting on it.
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
|
|
41
|
+
import { mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
41
42
|
import path from 'node:path'
|
|
43
|
+
import { UserError } from '../util/index.js'
|
|
42
44
|
import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
|
|
43
45
|
|
|
44
46
|
/** Archive entry name carrying the per-output mode/mtime sidecar. */
|
|
45
47
|
const META_ENTRY = '.vx-meta.json'
|
|
46
48
|
|
|
47
49
|
/** Archive entry name of the always-present stdout record. */
|
|
48
|
-
|
|
50
|
+
const STDOUT_ENTRY = 'stdout'
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
|
|
@@ -57,7 +59,29 @@ interface MetaFile {
|
|
|
57
59
|
/** Bumped only if the sidecar's own shape changes; the artifact
|
|
58
60
|
* container is versioned by CACHE_VERSION, which gates reads. */
|
|
59
61
|
version: 1
|
|
62
|
+
/**
|
|
63
|
+
* The cache key the artifact was packed under (v35, item 943). Ingest
|
|
64
|
+
* refuses bytes whose key is not the one it asked for: nothing else ties
|
|
65
|
+
* an artifact to its key, so a remote layer that answered one key with
|
|
66
|
+
* another's bytes (a truncated or colliding mapping) replayed the other
|
|
67
|
+
* task's outputs under a green `cache-hit-remote`.
|
|
68
|
+
*/
|
|
69
|
+
key?: string
|
|
60
70
|
files: Record<string, [mode: number, mtimeMs: number]>
|
|
71
|
+
/**
|
|
72
|
+
* What the PRODUCING execution used (2026-09-12, additive: an artifact
|
|
73
|
+
* without it reads as before). Rides the artifact so a machine that
|
|
74
|
+
* never ran the task — a fresh CI runner on a remote hit — still learns
|
|
75
|
+
* what it needs; every wire ships the bytes verbatim, so no seam
|
|
76
|
+
* changes.
|
|
77
|
+
*/
|
|
78
|
+
exec?: ExecUsage
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** CPU time and peak RSS of the execution that produced an artifact. */
|
|
82
|
+
export interface ExecUsage {
|
|
83
|
+
cpuMs?: number
|
|
84
|
+
peakRssBytes?: number
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/** One regular file in an artifact, with its restore metadata resolved. */
|
|
@@ -86,8 +110,12 @@ export class ArchiveSecurityError extends Error {
|
|
|
86
110
|
|
|
87
111
|
/** What `packArtifactStream` packs: stdout plus archive name → absolute source path. */
|
|
88
112
|
export interface PackArgs {
|
|
113
|
+
/** The cache key, recorded in the sidecar (item 943); ingest refuses an artifact without it. */
|
|
114
|
+
key?: string
|
|
89
115
|
stdout: string
|
|
90
116
|
outputs: ReadonlyMap<string, string>
|
|
117
|
+
/** The producing execution's usage, when the runner reported it. */
|
|
118
|
+
exec?: ExecUsage | undefined
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
/** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
|
|
@@ -106,9 +134,29 @@ export interface ArtifactPlan {
|
|
|
106
134
|
*/
|
|
107
135
|
export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
108
136
|
const meta: MetaFile = { version: 1, files: {} }
|
|
137
|
+
if (args.key !== undefined) meta.key = args.key
|
|
138
|
+
const exec = usageOf(args.exec)
|
|
139
|
+
if (exec !== undefined) meta.exec = exec
|
|
109
140
|
const files = await Promise.all(
|
|
110
141
|
[...args.outputs].map(async ([name, abs]) => {
|
|
111
|
-
|
|
142
|
+
// `stat` follows a symlink, so a link to a file is packed as that
|
|
143
|
+
// file's bytes and comes back as a regular file. A link to a
|
|
144
|
+
// directory, or a dangling one, has no bytes to pack: refuse loudly
|
|
145
|
+
// rather than store an entry that restores to nothing.
|
|
146
|
+
const shown = name.startsWith('outputs/') ? name.slice('outputs/'.length) : name
|
|
147
|
+
const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
|
|
148
|
+
if (err.code === 'ENOENT') {
|
|
149
|
+
throw new UserError(
|
|
150
|
+
`output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
throw err
|
|
154
|
+
})
|
|
155
|
+
if (!st.isFile()) {
|
|
156
|
+
throw new UserError(
|
|
157
|
+
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
112
160
|
meta.files[name] = [st.mode & 0o777, Math.floor(st.mtimeMs)]
|
|
113
161
|
return {
|
|
114
162
|
name,
|
|
@@ -187,16 +235,26 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
187
235
|
* name is unsafe — a partial reading of a poisoned artifact is the
|
|
188
236
|
* outcome the traversal defense exists to prevent.
|
|
189
237
|
*/
|
|
190
|
-
export async function scanArtifact(
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
|
|
239
|
+
entries: ArchiveEntry[]
|
|
240
|
+
stdout: string | null
|
|
241
|
+
exec: ExecUsage | undefined
|
|
242
|
+
key: string | undefined
|
|
243
|
+
}> {
|
|
193
244
|
const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
194
245
|
let meta: MetaFile['files'] = {}
|
|
246
|
+
let exec: ExecUsage | undefined
|
|
247
|
+
let key: string | undefined
|
|
195
248
|
let stdout: string | null = null
|
|
196
249
|
for await (const e of tarEntries(tar)) {
|
|
197
250
|
if (e.type !== '0') continue
|
|
198
251
|
if (e.name === META_ENTRY) {
|
|
199
|
-
|
|
252
|
+
const parsed = JSON.parse(await textOf(e.body)) as MetaFile
|
|
253
|
+
meta = parsed.files ?? {}
|
|
254
|
+
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
255
|
+
// usage is taken only when it is a plain non-negative number.
|
|
256
|
+
exec = usageOf(parsed.exec)
|
|
257
|
+
if (typeof parsed.key === 'string') key = parsed.key
|
|
200
258
|
continue
|
|
201
259
|
}
|
|
202
260
|
assertSafeName(e.name)
|
|
@@ -212,9 +270,22 @@ export async function scanArtifact(
|
|
|
212
270
|
return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
|
|
213
271
|
}),
|
|
214
272
|
stdout,
|
|
273
|
+
exec,
|
|
274
|
+
key,
|
|
215
275
|
}
|
|
216
276
|
}
|
|
217
277
|
|
|
278
|
+
/** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
|
|
279
|
+
function usageOf(raw: unknown): ExecUsage | undefined {
|
|
280
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
281
|
+
const { cpuMs, peakRssBytes } = raw as { cpuMs?: unknown; peakRssBytes?: unknown }
|
|
282
|
+
const out: { cpuMs?: number; peakRssBytes?: number } = {}
|
|
283
|
+
if (typeof cpuMs === 'number' && Number.isFinite(cpuMs) && cpuMs >= 0) out.cpuMs = cpuMs
|
|
284
|
+
if (typeof peakRssBytes === 'number' && Number.isFinite(peakRssBytes) && peakRssBytes >= 0)
|
|
285
|
+
out.peakRssBytes = peakRssBytes
|
|
286
|
+
return out.cpuMs === undefined && out.peakRssBytes === undefined ? undefined : out
|
|
287
|
+
}
|
|
288
|
+
|
|
218
289
|
/**
|
|
219
290
|
* Restore from `tar`, the DECOMPRESSED archive as a byte stream:
|
|
220
291
|
* `outputs/<rel>` lands under `destDir/<rel>` and — when `workspaceDest`
|
|
@@ -232,7 +303,10 @@ export async function extractArtifactStream(
|
|
|
232
303
|
workspaceDest: string | undefined,
|
|
233
304
|
verify?: (provided: ReadonlySet<string>) => void,
|
|
234
305
|
): Promise<Set<string>> {
|
|
235
|
-
|
|
306
|
+
// No `mkdir -p` of the destination here: `stage` creates each entry's
|
|
307
|
+
// directory chain, the destination included, and the cache restores
|
|
308
|
+
// into a project directory that exists. The eager one cost every
|
|
309
|
+
// restore a thread-pool round trip for an EEXIST and a stat (item 627).
|
|
236
310
|
const x = new Extractor(destDir, workspaceDest)
|
|
237
311
|
const provided = new Set<string>()
|
|
238
312
|
const headerMtime = new Map<string, number>()
|
|
@@ -251,7 +325,12 @@ export async function extractArtifactStream(
|
|
|
251
325
|
await x.assertContained(dest.base, target, e.name)
|
|
252
326
|
provided.add(e.name)
|
|
253
327
|
headerMtime.set(e.name, e.mtimeMs)
|
|
254
|
-
await x.stage(
|
|
328
|
+
await x.stage(
|
|
329
|
+
e.name,
|
|
330
|
+
dest.base,
|
|
331
|
+
target,
|
|
332
|
+
e.size <= SMALL_ENTRY ? await bytesOf(e.body) : e.body,
|
|
333
|
+
)
|
|
255
334
|
}
|
|
256
335
|
verify?.(provided)
|
|
257
336
|
await x.commit((name) => {
|
|
@@ -314,6 +393,16 @@ class Extractor {
|
|
|
314
393
|
private inflight: Promise<unknown>[] = []
|
|
315
394
|
private inflightBytes = 0
|
|
316
395
|
private readonly realBaseCache = new Map<string, string>()
|
|
396
|
+
// Per-directory memos. An artifact holds a few files per directory
|
|
397
|
+
// (payload's ui: 4,069 files in 535 directories), and the containment
|
|
398
|
+
// walk and the `mkdir -p` are properties of the directory, not the
|
|
399
|
+
// entry: doing both once per directory took the restore of that
|
|
400
|
+
// artifact from 220–300 µs per file to the write itself. The window
|
|
401
|
+
// this opens — a symlink planted at a validated directory between two
|
|
402
|
+
// of its entries — is the same one that already lies between an
|
|
403
|
+
// entry's own check and its write.
|
|
404
|
+
private readonly containedDirs = new Set<string>()
|
|
405
|
+
private readonly ensuredDirs = new Set<string>()
|
|
317
406
|
|
|
318
407
|
constructor(
|
|
319
408
|
private readonly destDir: string,
|
|
@@ -382,12 +471,23 @@ class Extractor {
|
|
|
382
471
|
if (targetResolved !== baseResolved && !targetResolved.startsWith(baseResolved + path.sep)) {
|
|
383
472
|
throw new ArchiveSecurityError(`archive entry escapes destDir (unsafe): ${name}`)
|
|
384
473
|
}
|
|
385
|
-
const
|
|
474
|
+
const parentResolved = path.dirname(targetResolved)
|
|
475
|
+
if (this.containedDirs.has(parentResolved)) return
|
|
476
|
+
await this.assertParentContained(base, targetResolved)
|
|
477
|
+
this.containedDirs.add(parentResolved)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async assertParentContained(base: string, targetResolved: string): Promise<void> {
|
|
481
|
+
const baseResolved = path.resolve(base)
|
|
386
482
|
// Only ancestors strictly BELOW the base are candidates — those are the
|
|
387
483
|
// ones a poisoned entry could follow out of the tree. The walk must never
|
|
388
484
|
// climb past the base: when the base itself does not exist yet (the
|
|
389
485
|
// workspace-outputs anchor is created lazily), its parent legitimately
|
|
390
486
|
// resolves outside and comparing against it would reject every entry.
|
|
487
|
+
// The base's own real path is resolved only once an ancestor EXISTS to
|
|
488
|
+
// compare it with: after a clean pruned the output directories (the
|
|
489
|
+
// common restore) nothing below the base is there, and the memoised
|
|
490
|
+
// resolve was a round trip spent on a comparison never made (item 627).
|
|
391
491
|
let probe = path.dirname(targetResolved)
|
|
392
492
|
while (probe.startsWith(baseResolved + path.sep)) {
|
|
393
493
|
const real = await realpath(probe).then(
|
|
@@ -395,10 +495,9 @@ class Extractor {
|
|
|
395
495
|
() => null,
|
|
396
496
|
)
|
|
397
497
|
if (real !== null) {
|
|
498
|
+
const realBase = await this.realBaseOf(base)
|
|
398
499
|
if (real !== realBase && !real.startsWith(realBase + path.sep)) {
|
|
399
|
-
throw
|
|
400
|
-
`archive entry escapes destDir via a symlinked parent: ${name}`,
|
|
401
|
-
)
|
|
500
|
+
throw await linkOutError(baseResolved, probe, realBase)
|
|
402
501
|
}
|
|
403
502
|
return
|
|
404
503
|
}
|
|
@@ -408,6 +507,33 @@ class Extractor {
|
|
|
408
507
|
// pre-existing symlink to follow — the chain will be created fresh.
|
|
409
508
|
}
|
|
410
509
|
|
|
510
|
+
/**
|
|
511
|
+
* `mkdir -p` does not follow a DANGLING link: it fails EEXIST at the link,
|
|
512
|
+
* ENOENT below it, ELOOP in a cycle — and the containment walk passed it,
|
|
513
|
+
* finding nothing that resolves between it and the base. A live link that
|
|
514
|
+
* stays inside the base is written through, so a dangling one is too: the
|
|
515
|
+
* directory it names is created and the entry lands through the link,
|
|
516
|
+
* which stays the user's (never replaced, nx#37061). One that leads out of
|
|
517
|
+
* the base is the refusal a live link out gets. Only the failure pays for
|
|
518
|
+
* the walk; a clean tree never reaches it.
|
|
519
|
+
*/
|
|
520
|
+
private async mkdirThroughLink(
|
|
521
|
+
base: string,
|
|
522
|
+
dir: string,
|
|
523
|
+
err: NodeJS.ErrnoException,
|
|
524
|
+
): Promise<string | undefined> {
|
|
525
|
+
if (err.code !== 'EEXIST' && err.code !== 'ENOENT' && err.code !== 'ELOOP') throw err
|
|
526
|
+
const real = await resolveThrough(dir)
|
|
527
|
+
const realBase = await this.realBaseOf(base)
|
|
528
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) {
|
|
529
|
+
throw await linkOutError(path.resolve(base), dir, realBase)
|
|
530
|
+
}
|
|
531
|
+
// What stands there without a link on the way (a file named like the
|
|
532
|
+
// directory) fails this mkdir as it failed the first: the caller names
|
|
533
|
+
// it as a stray.
|
|
534
|
+
return mkdir(real, { recursive: true })
|
|
535
|
+
}
|
|
536
|
+
|
|
411
537
|
/**
|
|
412
538
|
* Write the entry beside its target. A buffer is written without
|
|
413
539
|
* waiting, bounded by `INFLIGHT_BYTES`; a chunk stream goes to a file
|
|
@@ -415,10 +541,20 @@ class Extractor {
|
|
|
415
541
|
*/
|
|
416
542
|
async stage(
|
|
417
543
|
name: string,
|
|
544
|
+
base: string,
|
|
418
545
|
target: string,
|
|
419
546
|
body: Uint8Array | AsyncIterable<Uint8Array>,
|
|
420
547
|
): Promise<void> {
|
|
421
|
-
const
|
|
548
|
+
const dir = path.dirname(target)
|
|
549
|
+
let created: string | undefined
|
|
550
|
+
if (!this.ensuredDirs.has(dir)) {
|
|
551
|
+
try {
|
|
552
|
+
created = await mkdir(dir, { recursive: true })
|
|
553
|
+
} catch (err) {
|
|
554
|
+
created = await this.mkdirThroughLink(base, dir, err as NodeJS.ErrnoException)
|
|
555
|
+
}
|
|
556
|
+
this.ensuredDirs.add(dir)
|
|
557
|
+
}
|
|
422
558
|
// Write beside the target and RENAME into place. rename(2) replaces
|
|
423
559
|
// the destination's directory ENTRY without following it, which is
|
|
424
560
|
// what makes this both link-safe and concurrency-safe:
|
|
@@ -436,11 +572,24 @@ class Extractor {
|
|
|
436
572
|
//
|
|
437
573
|
// A directory at the target makes the rename fail — the same
|
|
438
574
|
// fail-closed outcome the plain write had.
|
|
439
|
-
|
|
575
|
+
// The temp is a sibling with its own short name, never `<target>.suffix`:
|
|
576
|
+
// a suffix pushed a legal name of 242–255 bytes past NAME_MAX, and the
|
|
577
|
+
// restore of a valid artifact failed with ENAMETOOLONG (item 666).
|
|
578
|
+
const tmp = path.join(
|
|
579
|
+
path.dirname(target),
|
|
580
|
+
`.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
|
|
581
|
+
)
|
|
440
582
|
this.staged.push({ name, tmp, target, created })
|
|
441
583
|
if (body instanceof Uint8Array) {
|
|
442
584
|
this.inflightBytes += body.byteLength
|
|
443
|
-
|
|
585
|
+
// `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
|
|
586
|
+
// does the open, write and close on the calling thread and only
|
|
587
|
+
// its promise is asynchronous, so ten restores in flight wrote
|
|
588
|
+
// their 14,430 files one after another — a serial restore of
|
|
589
|
+
// payload took the same 6–7 s as one at concurrency 10, and a CPU
|
|
590
|
+
// profile put 6.7 of its 8.0 s inside `write` (2026-09-11). The
|
|
591
|
+
// thread-pool write lets the restores overlap their I/O.
|
|
592
|
+
const write = writeFile(tmp, body)
|
|
444
593
|
// A write that fails before `commit` or `abort` awaits it must not
|
|
445
594
|
// surface as an unhandled rejection; the awaiting `Promise.all` still
|
|
446
595
|
// sees the error through the original promise.
|
|
@@ -464,20 +613,31 @@ class Extractor {
|
|
|
464
613
|
await Promise.all(pending)
|
|
465
614
|
}
|
|
466
615
|
|
|
467
|
-
/**
|
|
616
|
+
/**
|
|
617
|
+
* Apply each entry's mode and mtime, then rename everything into place.
|
|
618
|
+
* Synchronous calls in batches: the three metadata calls per file are a
|
|
619
|
+
* few microseconds each on the calling thread and ~40 µs each as
|
|
620
|
+
* thread-pool round trips, and a large artifact has thousands of them
|
|
621
|
+
* (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
|
|
622
|
+
* archive bench). A yield every `COMMIT_BATCH` files keeps the other
|
|
623
|
+
* restores' round trips flowing. The chmod is skipped when the mode is
|
|
624
|
+
* the one the temp file was created with — the common case.
|
|
625
|
+
*/
|
|
468
626
|
async commit(metaFor: (name: string) => [mode: number, mtimeMs: number]): Promise<void> {
|
|
469
627
|
await this.drain()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
628
|
+
const first = this.staged[0]
|
|
629
|
+
const createdMode = first === undefined ? -1 : statSync(first.tmp).mode & 0o777
|
|
630
|
+
let n = 0
|
|
631
|
+
for (const s of this.staged) {
|
|
632
|
+
const [mode, mtimeMs] = metaFor(s.name)
|
|
633
|
+
if (mode !== 0 && (mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
|
|
634
|
+
if (mtimeMs > 0) {
|
|
635
|
+
const t = mtimeMs / 1000
|
|
636
|
+
utimesSync(s.tmp, t, t)
|
|
637
|
+
}
|
|
638
|
+
renameSync(s.tmp, s.target)
|
|
639
|
+
if (++n % COMMIT_BATCH === 0) await new Promise<void>((r) => setImmediate(r))
|
|
640
|
+
}
|
|
481
641
|
this.staged.length = 0
|
|
482
642
|
}
|
|
483
643
|
|
|
@@ -495,6 +655,9 @@ class Extractor {
|
|
|
495
655
|
if (s.created === undefined) continue
|
|
496
656
|
let dir = path.dirname(s.target)
|
|
497
657
|
const top = path.resolve(s.created)
|
|
658
|
+
// Created at a dangling link's resolved target (`mkdirThroughLink`),
|
|
659
|
+
// so the chain is pruned from where the link leads.
|
|
660
|
+
if (!dir.startsWith(top)) dir = await realpath(dir).catch(() => dir)
|
|
498
661
|
while (dir.startsWith(top)) {
|
|
499
662
|
if (
|
|
500
663
|
!(await rmdir(dir).then(
|
|
@@ -511,11 +674,73 @@ class Extractor {
|
|
|
511
674
|
}
|
|
512
675
|
}
|
|
513
676
|
|
|
677
|
+
/** Files renamed into place between two yields of a commit. */
|
|
678
|
+
const COMMIT_BATCH = 256
|
|
679
|
+
|
|
514
680
|
/** Per-process counter for extract temp names; uniqueness only. */
|
|
515
681
|
let tmpSeq = 0
|
|
516
682
|
|
|
517
683
|
const WORKSPACE_PREFIX = 'workspace-outputs/'
|
|
518
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The on-disk half of containment failed: `probe`, a directory under
|
|
687
|
+
* `base`, resolves outside it, so a directory between them is a link out.
|
|
688
|
+
* That is the tree, not the artifact — an output directory made a link
|
|
689
|
+
* after the entry was saved (upstream survey, nx#37061) — so it is the
|
|
690
|
+
* user's to fix, and the refusal names the link and where it leads. The
|
|
691
|
+
* restore never writes through it and never replaces it: the link is the
|
|
692
|
+
* user's, and replacing it is the bug nx#37061 reports.
|
|
693
|
+
*/
|
|
694
|
+
async function linkOutError(base: string, probe: string, realBase: string): Promise<UserError> {
|
|
695
|
+
let dir = base
|
|
696
|
+
let real = realBase
|
|
697
|
+
for (const part of path.relative(base, probe).split(path.sep)) {
|
|
698
|
+
dir = path.join(dir, part)
|
|
699
|
+
real = await resolveThrough(dir)
|
|
700
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) break
|
|
701
|
+
}
|
|
702
|
+
return new UserError(
|
|
703
|
+
`${dir} is a symbolic link to ${real}, outside ${base} — a cache restore never writes ` +
|
|
704
|
+
'through a link that leaves its directory. Remove the link and re-run (the restore ' +
|
|
705
|
+
'puts a real directory there), or stop declaring outputs under it.',
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Where `p` leads, every link on the way followed, a dangling one included:
|
|
711
|
+
* `realpath` for the part that exists, and past the first missing name the
|
|
712
|
+
* rest as written — where `mkdir -p` would create it if it followed links.
|
|
713
|
+
* A cycle is the user's tree, not the artifact, so it is refused by name.
|
|
714
|
+
*/
|
|
715
|
+
async function resolveThrough(p: string): Promise<string> {
|
|
716
|
+
const pending = path.resolve(p).split(path.sep).filter(Boolean).reverse()
|
|
717
|
+
let at: string = path.sep
|
|
718
|
+
let hops = 0
|
|
719
|
+
while (pending.length > 0) {
|
|
720
|
+
const next = path.join(at, pending.pop()!)
|
|
721
|
+
const link = await readlink(next).catch((err: NodeJS.ErrnoException) => {
|
|
722
|
+
if (err.code === 'EINVAL') return null
|
|
723
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return undefined
|
|
724
|
+
throw err
|
|
725
|
+
})
|
|
726
|
+
if (link === undefined) return path.join(next, ...pending.reverse())
|
|
727
|
+
if (link === null) {
|
|
728
|
+
at = next
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
// Linux's own bound on links followed in one lookup (MAXSYMLINKS).
|
|
732
|
+
if (++hops > 40) {
|
|
733
|
+
throw new UserError(
|
|
734
|
+
`${p} goes through a cycle of symbolic links (at ${next}) — a cache restore cannot ` +
|
|
735
|
+
'write through it. Remove the link and re-run.',
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
if (path.isAbsolute(link)) at = path.sep
|
|
739
|
+
pending.push(...link.split(path.sep).filter(Boolean).reverse())
|
|
740
|
+
}
|
|
741
|
+
return at
|
|
742
|
+
}
|
|
743
|
+
|
|
519
744
|
/**
|
|
520
745
|
* Path-traversal defense, applied to every entry name at read time.
|
|
521
746
|
* Mirrors Turbo's `restore.rs:check_path` normalization: an artifact is
|
|
@@ -549,8 +774,34 @@ function assertSafeName(name: string): void {
|
|
|
549
774
|
if (name.includes('\0')) {
|
|
550
775
|
throw new ArchiveSecurityError(`archive entry name contains a null byte (unsafe): ${name}`)
|
|
551
776
|
}
|
|
777
|
+
// Only a pax `path` record can carry a name this long. Left to the file
|
|
778
|
+
// system it is a raw ENAMETOOLONG, which the restore reports as an
|
|
779
|
+
// internal error rather than a refused artifact. A UTF-16 unit is at most
|
|
780
|
+
// three UTF-8 bytes, so the common short name never pays for the count.
|
|
781
|
+
if (name.length * 3 >= PATH_MAX && Buffer.byteLength(name) >= PATH_MAX) {
|
|
782
|
+
throw new ArchiveSecurityError(
|
|
783
|
+
`archive entry name is ${Buffer.byteLength(name)} bytes, past PATH_MAX (${PATH_MAX}) (unsafe): ${name.slice(0, 64)}…`,
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
// The same raw ENAMETOOLONG for one component past NAME_MAX, the same on
|
|
787
|
+
// Linux and macOS. Same cheap bound: a name under 86 units cannot hold one.
|
|
788
|
+
if (name.length * 3 > NAME_MAX) {
|
|
789
|
+
for (const part of name.split('/')) {
|
|
790
|
+
if (part.length * 3 > NAME_MAX && Buffer.byteLength(part) > NAME_MAX) {
|
|
791
|
+
throw new ArchiveSecurityError(
|
|
792
|
+
`archive entry has a ${Buffer.byteLength(part)}-byte component, past NAME_MAX (${NAME_MAX}) (unsafe): ${part.slice(0, 64)}…`,
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
552
797
|
}
|
|
553
798
|
|
|
799
|
+
/** Bytes one path component may hold (limits.h), on Linux and macOS alike. */
|
|
800
|
+
const NAME_MAX = 255
|
|
801
|
+
|
|
802
|
+
/** Bytes a path may hold, its terminating NUL included (limits.h). */
|
|
803
|
+
const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
|
|
804
|
+
|
|
554
805
|
function hasParentSegment(p: string): boolean {
|
|
555
806
|
if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
|
|
556
807
|
return p.includes('/../')
|