@vzn/vx 0.0.646 → 0.0.649
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/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cache/archive.ts
CHANGED
|
@@ -55,7 +55,6 @@ import {
|
|
|
55
55
|
realpathSync,
|
|
56
56
|
statSync,
|
|
57
57
|
utimesSync,
|
|
58
|
-
writeFileSync,
|
|
59
58
|
} from 'node:fs'
|
|
60
59
|
import type { Stats } from 'node:fs'
|
|
61
60
|
import {
|
|
@@ -74,7 +73,7 @@ import {
|
|
|
74
73
|
writeFile,
|
|
75
74
|
} from 'node:fs/promises'
|
|
76
75
|
import path from 'node:path'
|
|
77
|
-
import { UserError } from '../util/index.js'
|
|
76
|
+
import { ignoreDir, UserError, writableUnder } from '../util/index.js'
|
|
78
77
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
79
78
|
import {
|
|
80
79
|
TarFormatError,
|
|
@@ -140,6 +139,7 @@ class EntrySum {
|
|
|
140
139
|
*/
|
|
141
140
|
class SumCheck {
|
|
142
141
|
private readonly sum = new EntrySum()
|
|
142
|
+
private readonly names = new Set<string>()
|
|
143
143
|
private seen: boolean = false
|
|
144
144
|
/** The entry's body, hashed as the caller reads it; null for the sum entry itself (checked here). */
|
|
145
145
|
async body(
|
|
@@ -157,6 +157,10 @@ class SumCheck {
|
|
|
157
157
|
enter(name: string): boolean {
|
|
158
158
|
if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
|
|
159
159
|
if (name === SUM_ENTRY) return true
|
|
160
|
+
// vx never packs a name twice, and a restore staged both: their renames
|
|
161
|
+
// ran together, so which body landed was a race the index never saw.
|
|
162
|
+
if (this.names.has(name)) throw new ArchiveSecurityError(`archive holds ${name} twice`)
|
|
163
|
+
this.names.add(name)
|
|
160
164
|
this.sum.entry(name)
|
|
161
165
|
return false
|
|
162
166
|
}
|
|
@@ -213,6 +217,14 @@ interface MetaFile {
|
|
|
213
217
|
* Turbo and Nx do, where it once packed a copy of the target (v45).
|
|
214
218
|
*/
|
|
215
219
|
links?: Record<string, string>
|
|
220
|
+
/**
|
|
221
|
+
* The directories under the outputs whose mode withholds a permission
|
|
222
|
+
* 0755 grants (`chmod 700 dist/keys`), entry name → permission bits
|
|
223
|
+
* (v49): a hit made each missing directory with mkdir's default, so a
|
|
224
|
+
* directory that kept its files to their owner came back readable by
|
|
225
|
+
* every user.
|
|
226
|
+
*/
|
|
227
|
+
dirModes?: Record<string, number>
|
|
216
228
|
/**
|
|
217
229
|
* What the PRODUCING execution used (2026-09-12, additive: an artifact
|
|
218
230
|
* without it reads as before). Rides the artifact so a machine that
|
|
@@ -265,6 +277,8 @@ interface PackArgs {
|
|
|
265
277
|
exec?: ExecUsage | undefined
|
|
266
278
|
/** The workspace root an output symlink's target must stay inside; absent, every link is refused. */
|
|
267
279
|
root?: string
|
|
280
|
+
/** Where `outputs/` names lie, for the directory modes (`dirModes`); absent, none is recorded. */
|
|
281
|
+
projectDir?: string
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
/** The largest second count ustar's 12-byte mtime field holds: 11 octal digits. */
|
|
@@ -332,7 +346,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
332
346
|
`${shown} is a symlink to ${to}, outside the workspace: vx restores a link as written, and a project's outputs stay in its workspace — emit a file there, or take it back with a '!' entry`,
|
|
333
347
|
)
|
|
334
348
|
}
|
|
335
|
-
return { name, link: to }
|
|
349
|
+
return { name, link: to, abs, shown }
|
|
336
350
|
}
|
|
337
351
|
if (!st.isFile()) {
|
|
338
352
|
throw new UserError(
|
|
@@ -353,6 +367,19 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
353
367
|
}
|
|
354
368
|
}),
|
|
355
369
|
)
|
|
370
|
+
const linked = new Map<string, string>()
|
|
371
|
+
const symlinks = files.filter(
|
|
372
|
+
(f): f is Extract<(typeof files)[number], { link: string }> => 'link' in f,
|
|
373
|
+
)
|
|
374
|
+
for (const f of symlinks) linked.set(f.abs, f.shown)
|
|
375
|
+
for (const f of symlinks) {
|
|
376
|
+
const via = throughLink(path.dirname(f.abs), f.link, linked)
|
|
377
|
+
if (via !== undefined) {
|
|
378
|
+
throw new UserError(
|
|
379
|
+
`${f.shown} is a symlink to ${f.link} through ${linked.get(via)}, another link: vx restores each link as written and checks where it points alone — point it at the target directly, or take it back with a '!' entry`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
356
383
|
// In the outputs' order, not as each stat settled: one tree, one artifact.
|
|
357
384
|
const regular: Array<Exclude<(typeof files)[number], { link: string }>> = []
|
|
358
385
|
for (const f of files) {
|
|
@@ -362,6 +389,17 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
362
389
|
regular.push(f)
|
|
363
390
|
}
|
|
364
391
|
}
|
|
392
|
+
if (args.projectDir !== undefined) {
|
|
393
|
+
const modes = narrowDirModes(
|
|
394
|
+
[...args.outputs.keys(), ...(args.dirs ?? [])],
|
|
395
|
+
args.dirs ?? [],
|
|
396
|
+
(name) =>
|
|
397
|
+
name.startsWith('outputs/')
|
|
398
|
+
? path.join(args.projectDir!, name.slice('outputs/'.length))
|
|
399
|
+
: path.join(args.root ?? args.projectDir!, name.slice(WORKSPACE_PREFIX.length)),
|
|
400
|
+
)
|
|
401
|
+
if (modes !== undefined) meta.dirModes = modes
|
|
402
|
+
}
|
|
365
403
|
const stdout = new TextEncoder().encode(args.stdout)
|
|
366
404
|
const metaBytes = new TextEncoder().encode(JSON.stringify(meta))
|
|
367
405
|
const inputs: TarInput[] = [
|
|
@@ -378,6 +416,36 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
378
416
|
return { inputs, size: tarSize([...inputs, sumInput('0'.repeat(SUM_SIZE))]) }
|
|
379
417
|
}
|
|
380
418
|
|
|
419
|
+
/**
|
|
420
|
+
* The modes `MetaFile.dirModes` keeps: of each directory on the way to an
|
|
421
|
+
* output name below its namespace, and of each empty output directory, the
|
|
422
|
+
* ones that withhold a permission 0755 grants. A wider mode is left to the
|
|
423
|
+
* restoring umask, as a plain mkdir's is, so most artifacts carry none.
|
|
424
|
+
*/
|
|
425
|
+
function narrowDirModes(
|
|
426
|
+
names: readonly string[],
|
|
427
|
+
emptyDirs: readonly string[],
|
|
428
|
+
abs: (name: string) => string,
|
|
429
|
+
): Record<string, number> | undefined {
|
|
430
|
+
const seen = new Set<string>()
|
|
431
|
+
let out: Record<string, number> | undefined
|
|
432
|
+
const visit = (dir: string): void => {
|
|
433
|
+
if (seen.has(dir)) return
|
|
434
|
+
seen.add(dir)
|
|
435
|
+
const st = statSync(abs(dir), { throwIfNoEntry: false })
|
|
436
|
+
if (st?.isDirectory() !== true || (st.mode & 0o755) === 0o755) return
|
|
437
|
+
;(out ??= {})[dir] = st.mode & 0o777
|
|
438
|
+
}
|
|
439
|
+
for (const d of emptyDirs) visit(d)
|
|
440
|
+
for (const name of names) {
|
|
441
|
+
const top = name.startsWith('outputs/') ? 'outputs/'.length : WORKSPACE_PREFIX.length
|
|
442
|
+
for (let i = name.lastIndexOf('/'); i > top; i = name.lastIndexOf('/', i - 1)) {
|
|
443
|
+
visit(name.slice(0, i))
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out
|
|
447
|
+
}
|
|
448
|
+
|
|
381
449
|
/** The tar as a stream: each output is read from disk as it is written. */
|
|
382
450
|
export function packArtifactStream(plan: ArtifactPlan): ReadableStream<Uint8Array> {
|
|
383
451
|
return streamOf(tarPack(summed(plan.inputs)))
|
|
@@ -503,7 +571,10 @@ export async function writeTree(plan: ArtifactPlan, dir: string): Promise<Artifa
|
|
|
503
571
|
plan.inputs.map(async (i) => {
|
|
504
572
|
if (!(i.body instanceof Blob)) return
|
|
505
573
|
const to = path.join(dir, i.name)
|
|
506
|
-
|
|
574
|
+
// The tar form's refusal (`tarPackInto`): an output that grew or shrank
|
|
575
|
+
// since the plan's stat is not the file its row describes.
|
|
576
|
+
const n = await Bun.write(to, i.body)
|
|
577
|
+
if (n !== i.size) throw new TarFormatError(`${i.name}: ${n} bytes, ${i.size} declared`)
|
|
507
578
|
const at = new Date(times[i.name]![1])
|
|
508
579
|
await utimes(to, at, at)
|
|
509
580
|
}),
|
|
@@ -526,6 +597,7 @@ export async function sealTree(dir: string, scan: ArtifactScan): Promise<void> {
|
|
|
526
597
|
}
|
|
527
598
|
if (scan.dirs.length > 0) meta.dirs = scan.dirs
|
|
528
599
|
if (scan.links.length > 0) meta.links = Object.fromEntries(scan.links)
|
|
600
|
+
if (scan.dirModes.length > 0) meta.dirModes = Object.fromEntries(scan.dirModes)
|
|
529
601
|
if (scan.exec !== undefined) meta.exec = scan.exec
|
|
530
602
|
await writeFile(path.join(dir, META_ENTRY), JSON.stringify(meta))
|
|
531
603
|
}
|
|
@@ -537,10 +609,14 @@ export function planOfTree(dir: string, stdout: string): ArtifactPlan {
|
|
|
537
609
|
const out = new TextEncoder().encode(stdout)
|
|
538
610
|
const inputs: TarInput[] = [{ name: STDOUT_ENTRY, size: out.byteLength, body: out }]
|
|
539
611
|
for (const [name, [mode, mtimeMs]] of Object.entries(meta.files)) {
|
|
612
|
+
// Bun.file reads through a link: only a regular file is packed as one.
|
|
613
|
+
const st = lstatSync(path.join(dir, name))
|
|
614
|
+
if (!st.isFile())
|
|
615
|
+
throw new ArchiveSecurityError(`kept tree entry ${name} is not a regular file`)
|
|
540
616
|
const file = Bun.file(path.join(dir, name))
|
|
541
617
|
inputs.push({
|
|
542
618
|
name,
|
|
543
|
-
size:
|
|
619
|
+
size: st.size,
|
|
544
620
|
mode,
|
|
545
621
|
mtime: Math.min(USTAR_MTIME_MAX, Math.max(0, Math.floor(mtimeMs / 1000))),
|
|
546
622
|
body: file,
|
|
@@ -557,6 +633,8 @@ interface ArtifactScan {
|
|
|
557
633
|
dirs: string[]
|
|
558
634
|
/** The sidecar's symlinks, `[name, target]`, each name and target proven safe. */
|
|
559
635
|
links: Array<[name: string, target: string]>
|
|
636
|
+
/** The sidecar's directory modes, each a directory the artifact holds something in or is. */
|
|
637
|
+
dirModes: Array<[name: string, mode: number]>
|
|
560
638
|
stdout: string | null
|
|
561
639
|
exec: ExecUsage | undefined
|
|
562
640
|
key: string | undefined
|
|
@@ -568,15 +646,17 @@ class ScanFold {
|
|
|
568
646
|
private files: MetaFile['files'] = {}
|
|
569
647
|
private dirs: string[] = []
|
|
570
648
|
private links: Array<[string, string]> = []
|
|
649
|
+
private dirModes: Array<[string, number]> = []
|
|
571
650
|
private exec: ExecUsage | undefined
|
|
572
651
|
private key: string | undefined
|
|
573
652
|
stdout: string | null = null
|
|
574
653
|
|
|
575
654
|
sidecar(text: string): void {
|
|
576
655
|
const parsed = JSON.parse(text) as MetaFile
|
|
577
|
-
this.files = parsed
|
|
656
|
+
this.files = filesOf(parsed)
|
|
578
657
|
this.dirs = dirsOf(parsed)
|
|
579
658
|
this.links = linksOf(parsed)
|
|
659
|
+
this.dirModes = dirModesOf(parsed)
|
|
580
660
|
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
581
661
|
// usage is taken only when it is a plain non-negative number.
|
|
582
662
|
this.exec = usageOf(parsed.exec)
|
|
@@ -588,6 +668,26 @@ class ScanFold {
|
|
|
588
668
|
}
|
|
589
669
|
|
|
590
670
|
result(): ArtifactScan {
|
|
671
|
+
// A mode names a directory the artifact holds, never one a restore
|
|
672
|
+
// would otherwise not make: the row it becomes is a directory a hit
|
|
673
|
+
// makes and asks to stand.
|
|
674
|
+
if (this.dirModes.length > 0) {
|
|
675
|
+
const held = new Set(this.dirs)
|
|
676
|
+
const hold = (name: string): void => {
|
|
677
|
+
for (let i = name.indexOf('/'); i !== -1; i = name.indexOf('/', i + 1))
|
|
678
|
+
held.add(name.slice(0, i))
|
|
679
|
+
}
|
|
680
|
+
for (const s of this.seen) hold(s.name)
|
|
681
|
+
for (const d of this.dirs) hold(d)
|
|
682
|
+
for (const [name] of this.links) hold(name)
|
|
683
|
+
for (const [name] of this.dirModes) {
|
|
684
|
+
if (!held.has(name)) {
|
|
685
|
+
throw new ArchiveSecurityError(
|
|
686
|
+
`archive directory mode names no output directory: ${name}`,
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
591
691
|
return {
|
|
592
692
|
entries: this.seen.map((s) => {
|
|
593
693
|
const m = this.files[s.name]
|
|
@@ -598,6 +698,7 @@ class ScanFold {
|
|
|
598
698
|
}),
|
|
599
699
|
dirs: this.dirs,
|
|
600
700
|
links: this.links,
|
|
701
|
+
dirModes: this.dirModes,
|
|
601
702
|
stdout: this.stdout,
|
|
602
703
|
exec: this.exec,
|
|
603
704
|
key: this.key,
|
|
@@ -663,6 +764,37 @@ export function scanTarBytes(tar: Uint8Array): ArtifactScan {
|
|
|
663
764
|
return fold.result()
|
|
664
765
|
}
|
|
665
766
|
|
|
767
|
+
/** The largest millisecond count a Date holds (ECMA-262 §21.4.1.1). */
|
|
768
|
+
const DATE_MAX_MS = 8.64e15
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The sidecar's stats, each an integer mode in 0..0o7777 and an integer
|
|
772
|
+
* mtime a Date holds: one taken as is reached the index, and its restore
|
|
773
|
+
* threw a TypeError out of `utimesSync` after renames had begun.
|
|
774
|
+
*/
|
|
775
|
+
function filesOf(meta: MetaFile): MetaFile['files'] {
|
|
776
|
+
const raw: unknown = meta.files
|
|
777
|
+
if (raw === undefined || raw === null) return {}
|
|
778
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
779
|
+
throw new ArchiveSecurityError('archive sidecar files is not a map')
|
|
780
|
+
}
|
|
781
|
+
for (const [name, pair] of Object.entries(raw as Record<string, unknown>)) {
|
|
782
|
+
if (
|
|
783
|
+
!Array.isArray(pair) ||
|
|
784
|
+
!Number.isInteger(pair[0]) ||
|
|
785
|
+
pair[0] < 0 ||
|
|
786
|
+
pair[0] > 0o7777 ||
|
|
787
|
+
!Number.isInteger(pair[1]) ||
|
|
788
|
+
Math.abs(pair[1]) > DATE_MAX_MS
|
|
789
|
+
) {
|
|
790
|
+
throw new ArchiveSecurityError(
|
|
791
|
+
`archive sidecar has a bad stat for ${name}: ${JSON.stringify(pair)}`,
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return raw as MetaFile['files']
|
|
796
|
+
}
|
|
797
|
+
|
|
666
798
|
/**
|
|
667
799
|
* The sidecar's directory names, each held to the rules a file entry's
|
|
668
800
|
* name is (the sidecar is as attacker-reachable as the tar), and only
|
|
@@ -707,6 +839,73 @@ function linksOf(meta: MetaFile): Array<[string, string]> {
|
|
|
707
839
|
})
|
|
708
840
|
}
|
|
709
841
|
|
|
842
|
+
/**
|
|
843
|
+
* The first of `links` (absolute paths) that `to` passes through, walked
|
|
844
|
+
* from `dir` a segment at a time. Each link's target is checked as text,
|
|
845
|
+
* but the kernel follows one link while it resolves the next: `up -> ..`
|
|
846
|
+
* and `esc -> up/../../x` each read as inside while `esc` lands outside.
|
|
847
|
+
*/
|
|
848
|
+
function throughLink(
|
|
849
|
+
dir: string,
|
|
850
|
+
to: string,
|
|
851
|
+
links: ReadonlyMap<string, string>,
|
|
852
|
+
): string | undefined {
|
|
853
|
+
let at = dir
|
|
854
|
+
for (const seg of to.split('/')) {
|
|
855
|
+
if (seg === '' || seg === '.') continue
|
|
856
|
+
at = seg === '..' ? path.dirname(at) : path.join(at, seg)
|
|
857
|
+
if (links.has(at)) return at
|
|
858
|
+
}
|
|
859
|
+
return undefined
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The sidecar's directory modes, each name held to a file entry's rules and
|
|
864
|
+
* each mode to permission bits.
|
|
865
|
+
*/
|
|
866
|
+
function dirModesOf(meta: MetaFile): Array<[string, number]> {
|
|
867
|
+
const raw: unknown = meta.dirModes
|
|
868
|
+
if (raw === undefined) return []
|
|
869
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
870
|
+
throw new ArchiveSecurityError('archive sidecar dirModes is not a map')
|
|
871
|
+
}
|
|
872
|
+
return Object.entries(raw as Record<string, unknown>).map(([name, mode]) => {
|
|
873
|
+
if (!(name.startsWith('outputs/') || name.startsWith(WORKSPACE_PREFIX))) {
|
|
874
|
+
throw new ArchiveSecurityError(`archive directory is not an output: ${name}`)
|
|
875
|
+
}
|
|
876
|
+
assertSafeName(name)
|
|
877
|
+
if (typeof mode !== 'number' || !Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
878
|
+
throw new ArchiveSecurityError(
|
|
879
|
+
`archive directory ${name} has an unsafe mode: ${String(mode)}`,
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
return [name, mode]
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* A file this user cannot read, or one gone since its stat, is not the
|
|
888
|
+
* same bytes: the rename replaces it. Any other failure is the restore's:
|
|
889
|
+
* an EMFILE read as "differs" sent the restore on to stage, and its error
|
|
890
|
+
* named the staging file.
|
|
891
|
+
*/
|
|
892
|
+
function sameBytes(file: string, body: Uint8Array): boolean {
|
|
893
|
+
try {
|
|
894
|
+
return readFileSync(file).equals(body)
|
|
895
|
+
} catch (err) {
|
|
896
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
897
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT') return false
|
|
898
|
+
throw err
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** The deepest directory holding both `a` and `b`. */
|
|
903
|
+
function commonDir(a: string, b: string): string {
|
|
904
|
+
let dir = a
|
|
905
|
+
while (!inside(b, dir) && path.dirname(dir) !== dir) dir = path.dirname(dir)
|
|
906
|
+
return dir
|
|
907
|
+
}
|
|
908
|
+
|
|
710
909
|
/** `p` is `root` or under it, lexically. */
|
|
711
910
|
const inside = (p: string, root: string): boolean => {
|
|
712
911
|
const r = path.resolve(root)
|
|
@@ -757,6 +956,7 @@ export async function extractArtifactStream(
|
|
|
757
956
|
let meta: MetaFile['files'] = {}
|
|
758
957
|
let dirs: string[] = []
|
|
759
958
|
let links: Array<[string, string]> = []
|
|
959
|
+
let dirModes: Array<[string, number]> = []
|
|
760
960
|
const fold = onScan === undefined ? undefined : new ScanFold()
|
|
761
961
|
try {
|
|
762
962
|
const check = new SumCheck()
|
|
@@ -768,9 +968,10 @@ export async function extractArtifactStream(
|
|
|
768
968
|
const text = await textOf(body)
|
|
769
969
|
fold?.sidecar(text)
|
|
770
970
|
const parsed = JSON.parse(text) as MetaFile
|
|
771
|
-
meta = parsed
|
|
971
|
+
meta = filesOf(parsed)
|
|
772
972
|
dirs = dirsOf(parsed)
|
|
773
973
|
links = linksOf(parsed)
|
|
974
|
+
dirModes = dirModesOf(parsed)
|
|
774
975
|
continue
|
|
775
976
|
}
|
|
776
977
|
assertSafeName(e.name)
|
|
@@ -811,8 +1012,23 @@ export async function extractArtifactStream(
|
|
|
811
1012
|
provided.add(`${name}/`)
|
|
812
1013
|
}
|
|
813
1014
|
// A link may point only inside the workspace (the project, without one).
|
|
814
|
-
|
|
1015
|
+
// Unpacked into a cache tree (no stage root), `outputs/` and
|
|
1016
|
+
// `workspace-outputs/` are siblings: bounded by the latter alone, a
|
|
1017
|
+
// `dist/link -> big.bin` pointed "out" and the ingest failed. The tree
|
|
1018
|
+
// bounds by their parent; its restore checks against the workspace.
|
|
1019
|
+
const bound =
|
|
1020
|
+
stageRoot ?? (workspaceDest === undefined ? destDir : commonDir(destDir, workspaceDest))
|
|
1021
|
+
const linked = new Map<string, string>()
|
|
1022
|
+
for (const [name] of links) {
|
|
1023
|
+
const dest = x.destFor(name)
|
|
1024
|
+
if (dest !== null && dest.rel.length > 0) linked.set(path.join(dest.base, dest.rel), name)
|
|
1025
|
+
}
|
|
815
1026
|
for (const [name, to] of links) {
|
|
1027
|
+
// Renamed over an entry just written, the link would stand where the
|
|
1028
|
+
// sidecar says a file is, and a re-pack of the tree reads through it.
|
|
1029
|
+
if (provided.has(name) || [...provided].some((p) => p.startsWith(`${name}/`))) {
|
|
1030
|
+
throw new ArchiveSecurityError(`archive link ${name} is also a file or directory entry`)
|
|
1031
|
+
}
|
|
816
1032
|
if (accept !== undefined && !accept(name)) {
|
|
817
1033
|
throw new ArchiveSecurityError(
|
|
818
1034
|
`archive link is not one of the task's declared outputs: ${name}`,
|
|
@@ -822,12 +1038,30 @@ export async function extractArtifactStream(
|
|
|
822
1038
|
if (dest === null || dest.rel.length === 0) continue
|
|
823
1039
|
const target = path.join(dest.base, dest.rel)
|
|
824
1040
|
await x.assertContained(dest.base, target, name)
|
|
825
|
-
|
|
1041
|
+
const at = path.resolve(path.dirname(target), to)
|
|
1042
|
+
if (!inside(at, bound)) {
|
|
826
1043
|
throw new ArchiveSecurityError(`archive link ${name} points out of the workspace: ${to}`)
|
|
827
1044
|
}
|
|
1045
|
+
const via = throughLink(path.dirname(target), to, linked)
|
|
1046
|
+
if (via !== undefined) {
|
|
1047
|
+
throw new ArchiveSecurityError(
|
|
1048
|
+
`archive link ${name} points through ${linked.get(via)}, another link it restores: ${to}`,
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
1051
|
+
// Lexical: a write through a link is judged where it resolves, so an
|
|
1052
|
+
// alias chain into .git gets no further than this link would.
|
|
1053
|
+
if (!writableUnder(at, path.resolve(bound))) {
|
|
1054
|
+
throw new ArchiveSecurityError(`archive link ${name} points into .git: ${to}`)
|
|
1055
|
+
}
|
|
828
1056
|
await x.stageLink(name, dest.base, target, to)
|
|
829
1057
|
provided.add(name)
|
|
830
1058
|
}
|
|
1059
|
+
// A moded directory is one the entries above make; its row, like an
|
|
1060
|
+
// empty directory's, names it with a trailing slash. The cache applies
|
|
1061
|
+
// the mode (`Cache.restoreOutputs`), to the directories a hit makes.
|
|
1062
|
+
for (const [name] of dirModes) {
|
|
1063
|
+
if (x.destFor(name) !== null) provided.add(`${name}/`)
|
|
1064
|
+
}
|
|
831
1065
|
verify?.(provided)
|
|
832
1066
|
// The sidecar's stat is the truth, a mode of 000 and an mtime at or
|
|
833
1067
|
// before the epoch included (`SOURCE_DATE_EPOCH=0`). Without one, a
|
|
@@ -887,7 +1121,8 @@ function keep(s: Staged, ino: number, mode: number, mtimeMs: number | undefined)
|
|
|
887
1121
|
const fd = openSync(s.target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
888
1122
|
try {
|
|
889
1123
|
const st = fstatSync(fd)
|
|
890
|
-
|
|
1124
|
+
// An in-place write since the compare keeps the inode and bumps the ctime.
|
|
1125
|
+
if (st.ino !== ino || st.nlink !== 1 || st.ctimeMs !== s.sameCtimeMs) {
|
|
891
1126
|
throw new Error(`${s.target} changed while it was restored`)
|
|
892
1127
|
}
|
|
893
1128
|
if ((st.mode & 0o7777) !== restoredMode(mode)) fchmodSync(fd, restoredMode(mode))
|
|
@@ -915,6 +1150,8 @@ interface Staged {
|
|
|
915
1150
|
base: string
|
|
916
1151
|
/** The target already holds the entry's bytes: nothing was written, and only its mode and mtime may change. */
|
|
917
1152
|
same?: number
|
|
1153
|
+
/** The kept target's ctime when its bytes were compared. */
|
|
1154
|
+
sameCtimeMs?: number
|
|
918
1155
|
/** `tmp` was copied beside `target`, which is on another filesystem than the staging (`crossOver`). */
|
|
919
1156
|
beside?: true
|
|
920
1157
|
/** A symlink to this target, made at `tmp`: no mode, no mtime. */
|
|
@@ -938,6 +1175,8 @@ interface Tree {
|
|
|
938
1175
|
*/
|
|
939
1176
|
class Extractor {
|
|
940
1177
|
private readonly staged: Staged[] = []
|
|
1178
|
+
/** Directories given owner write for a rename into them, with the mode to put back. */
|
|
1179
|
+
private readonly reopened = new Map<string, number>()
|
|
941
1180
|
/** Empty output directories made, with the top of each chain created. */
|
|
942
1181
|
private readonly madeDirs: Array<{ dir: string; created: string }> = []
|
|
943
1182
|
private inflight: Promise<unknown>[] = []
|
|
@@ -1061,7 +1300,7 @@ class Extractor {
|
|
|
1061
1300
|
)
|
|
1062
1301
|
if (real !== null) {
|
|
1063
1302
|
const realBase = await this.realBaseOf(base)
|
|
1064
|
-
if (real
|
|
1303
|
+
if (!writableUnder(real, realBase)) {
|
|
1065
1304
|
throw await linkOutError(baseResolved, probe, realBase)
|
|
1066
1305
|
}
|
|
1067
1306
|
return
|
|
@@ -1090,7 +1329,7 @@ class Extractor {
|
|
|
1090
1329
|
if (err.code !== 'EEXIST' && err.code !== 'ENOENT' && err.code !== 'ELOOP') throw err
|
|
1091
1330
|
const real = await resolveThrough(dir)
|
|
1092
1331
|
const realBase = await this.realBaseOf(base)
|
|
1093
|
-
if (real
|
|
1332
|
+
if (!writableUnder(real, realBase)) {
|
|
1094
1333
|
throw await linkOutError(path.resolve(base), dir, realBase)
|
|
1095
1334
|
}
|
|
1096
1335
|
// What stands there without a link on the way (a file named like the
|
|
@@ -1219,14 +1458,21 @@ class Extractor {
|
|
|
1219
1458
|
// file costs ~0.4 ms on ext4 (auto_da_alloc), and a hit that changed
|
|
1220
1459
|
// one of 2,000 outputs paid it 2,000 times. Bytes, not a stat stamp: a
|
|
1221
1460
|
// reproducible build gives every output one mtime (item 886).
|
|
1461
|
+
const st = created === undefined ? lstatSync(target, { throwIfNoEntry: false }) : undefined
|
|
1462
|
+
// A directory there fails its rename at commit, after the renames
|
|
1463
|
+
// before it landed: refused now, no output is replaced.
|
|
1464
|
+
if (st?.isDirectory() === true)
|
|
1465
|
+
throw Object.assign(
|
|
1466
|
+
new Error(`EISDIR: illegal operation on a directory, rename '${target}'`),
|
|
1467
|
+
{ code: 'EISDIR', path: target },
|
|
1468
|
+
)
|
|
1222
1469
|
if (body instanceof Uint8Array && created === undefined) {
|
|
1223
|
-
const st = lstatSync(target, { throwIfNoEntry: false })
|
|
1224
1470
|
if (
|
|
1225
1471
|
st !== undefined &&
|
|
1226
1472
|
st.isFile() &&
|
|
1227
1473
|
st.nlink === 1 &&
|
|
1228
1474
|
st.size === body.byteLength &&
|
|
1229
|
-
|
|
1475
|
+
sameBytes(target, body)
|
|
1230
1476
|
) {
|
|
1231
1477
|
this.staged.push({
|
|
1232
1478
|
staged: false,
|
|
@@ -1237,6 +1483,7 @@ class Extractor {
|
|
|
1237
1483
|
streamed: false,
|
|
1238
1484
|
base,
|
|
1239
1485
|
same: st.ino,
|
|
1486
|
+
sameCtimeMs: st.ctimeMs,
|
|
1240
1487
|
})
|
|
1241
1488
|
return
|
|
1242
1489
|
}
|
|
@@ -1260,7 +1507,7 @@ class Extractor {
|
|
|
1260
1507
|
// fail-closed outcome the plain write had.
|
|
1261
1508
|
// Staged under `.vx/tmp/`, so no output folder ever holds a temp: a
|
|
1262
1509
|
// reader or a key taken meanwhile saw `.vx-tmp-*` files beside the
|
|
1263
|
-
// outputs, and a killed restore left them there (
|
|
1510
|
+
// outputs, and a killed restore left them there (2026-10-16).
|
|
1264
1511
|
const staged = this.stageRoot !== undefined
|
|
1265
1512
|
const tmp = staged
|
|
1266
1513
|
? path.join(await stagingDir(this.stageRoot!), (tmpSeq++).toString(36))
|
|
@@ -1360,6 +1607,16 @@ class Extractor {
|
|
|
1360
1607
|
*/
|
|
1361
1608
|
async commit(
|
|
1362
1609
|
metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
|
|
1610
|
+
): Promise<void> {
|
|
1611
|
+
try {
|
|
1612
|
+
await this.commitAll(metaFor)
|
|
1613
|
+
} finally {
|
|
1614
|
+
this.closeReopened()
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
private async commitAll(
|
|
1619
|
+
metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
|
|
1363
1620
|
): Promise<void> {
|
|
1364
1621
|
await this.drain()
|
|
1365
1622
|
// One stat per directory, shared by the filesystem check and the group.
|
|
@@ -1472,11 +1729,49 @@ class Extractor {
|
|
|
1472
1729
|
const dir = path.dirname(s.target)
|
|
1473
1730
|
await this.makeDirIn(t.base, dir)
|
|
1474
1731
|
await this.assertStillContained(t.base, dir, checked)
|
|
1475
|
-
await
|
|
1732
|
+
await this.renameInto(s.tmp, s.target)
|
|
1476
1733
|
}
|
|
1477
1734
|
await rm(t.at, { recursive: true, force: true })
|
|
1478
1735
|
}
|
|
1479
1736
|
|
|
1737
|
+
/**
|
|
1738
|
+
* `rename`, into a directory of this user's that withholds its own write
|
|
1739
|
+
* (a task's `chmod 555 dist/ro`, which a hit now restores): the
|
|
1740
|
+
* directory gets owner write and search for the rest of the commit, and
|
|
1741
|
+
* its mode back after. Without it every later hit that changed a file
|
|
1742
|
+
* there failed EACCES, as only root could replace it.
|
|
1743
|
+
*/
|
|
1744
|
+
private async renameInto(from: string, to: string): Promise<void> {
|
|
1745
|
+
try {
|
|
1746
|
+
await rename(from, to)
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if ((err as NodeJS.ErrnoException).code !== 'EACCES' || !this.reopen(path.dirname(to)))
|
|
1749
|
+
throw err
|
|
1750
|
+
await rename(from, to)
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
private reopen(dir: string): boolean {
|
|
1755
|
+
if (this.reopened.has(dir)) return false
|
|
1756
|
+
const st = lstatSync(dir, { throwIfNoEntry: false })
|
|
1757
|
+
if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
|
|
1758
|
+
if ((st.mode & 0o300) === 0o300) return false
|
|
1759
|
+
this.reopened.set(dir, st.mode & 0o7777)
|
|
1760
|
+
chmodSync(dir, (st.mode & 0o7777) | 0o300)
|
|
1761
|
+
return true
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private closeReopened(): void {
|
|
1765
|
+
for (const [dir, mode] of this.reopened) {
|
|
1766
|
+
try {
|
|
1767
|
+
chmodSync(dir, mode)
|
|
1768
|
+
} catch {
|
|
1769
|
+
// gone with an abort's cleanup
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
this.reopened.clear()
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1480
1775
|
private async makeDirIn(base: string, dir: string): Promise<void> {
|
|
1481
1776
|
try {
|
|
1482
1777
|
await mkdir(dir, { recursive: true })
|
|
@@ -1529,7 +1824,7 @@ class Extractor {
|
|
|
1529
1824
|
*/
|
|
1530
1825
|
private async place(s: Staged, mode: number, t: Date | undefined): Promise<void> {
|
|
1531
1826
|
try {
|
|
1532
|
-
await
|
|
1827
|
+
await this.renameInto(s.tmp, s.target)
|
|
1533
1828
|
} catch (err) {
|
|
1534
1829
|
if (!s.staged || (err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
|
|
1535
1830
|
const side = siblingTemp(s.target)
|
|
@@ -1540,7 +1835,7 @@ class Extractor {
|
|
|
1540
1835
|
chmodSync(side, restoredMode(mode))
|
|
1541
1836
|
if (t !== undefined) utimesSync(side, t, t)
|
|
1542
1837
|
}
|
|
1543
|
-
await
|
|
1838
|
+
await this.renameInto(side, s.target)
|
|
1544
1839
|
} catch (e) {
|
|
1545
1840
|
await unlink(side).catch(() => undefined)
|
|
1546
1841
|
throw e
|
|
@@ -1564,7 +1859,7 @@ class Extractor {
|
|
|
1564
1859
|
checked.add(dir)
|
|
1565
1860
|
const realBase = await this.realBaseOf(base)
|
|
1566
1861
|
const real = await realOf(dir, true)
|
|
1567
|
-
if (real
|
|
1862
|
+
if (!writableUnder(real, realBase)) {
|
|
1568
1863
|
throw await linkOutError(path.resolve(base), dir, realBase)
|
|
1569
1864
|
}
|
|
1570
1865
|
}
|
|
@@ -1590,7 +1885,7 @@ class Extractor {
|
|
|
1590
1885
|
for (const [dir, { base, placed }] of landed) {
|
|
1591
1886
|
const realBase = await this.realBaseOf(base)
|
|
1592
1887
|
const real = await realOf(dir, true).catch(() => null)
|
|
1593
|
-
if (real === null || real
|
|
1888
|
+
if (real === null || writableUnder(real, realBase)) continue
|
|
1594
1889
|
await Promise.allSettled(placed.map((p) => rm(p, { recursive: true, force: true })))
|
|
1595
1890
|
throw await linkOutError(path.resolve(base), dir, realBase)
|
|
1596
1891
|
}
|
|
@@ -1681,7 +1976,7 @@ async function settle(renames: readonly Promise<void>[]): Promise<void> {
|
|
|
1681
1976
|
let tmpSeq = 0
|
|
1682
1977
|
|
|
1683
1978
|
/** `.vx/<STAGING>/`: where restores stage, each process in its own `<pid>-0` directory. */
|
|
1684
|
-
|
|
1979
|
+
const STAGING = 'tmp'
|
|
1685
1980
|
|
|
1686
1981
|
const stagingMade = new Set<string>()
|
|
1687
1982
|
|
|
@@ -1698,7 +1993,7 @@ async function stagingDir(root: string): Promise<string> {
|
|
|
1698
1993
|
await mkdir(own, { recursive: true })
|
|
1699
1994
|
// Ignored by git whatever `.vx/.gitignore` an older init committed.
|
|
1700
1995
|
try {
|
|
1701
|
-
|
|
1996
|
+
ignoreDir(top)
|
|
1702
1997
|
} catch {
|
|
1703
1998
|
// there already, or the restore fails on its first write anyway
|
|
1704
1999
|
}
|
|
@@ -1735,7 +2030,13 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
|
|
|
1735
2030
|
for (const part of path.relative(base, probe).split(path.sep)) {
|
|
1736
2031
|
dir = path.join(dir, part)
|
|
1737
2032
|
real = await resolveThrough(dir)
|
|
1738
|
-
if (real
|
|
2033
|
+
if (!writableUnder(real, realBase)) break
|
|
2034
|
+
}
|
|
2035
|
+
if (real.startsWith(realBase + path.sep)) {
|
|
2036
|
+
return new UserError(
|
|
2037
|
+
`${dir} is a symbolic link to ${real}, Git's metadata — a cache restore never writes ` +
|
|
2038
|
+
'into a .git directory. Remove the link and re-run, or stop declaring outputs under it.',
|
|
2039
|
+
)
|
|
1739
2040
|
}
|
|
1740
2041
|
return new UserError(
|
|
1741
2042
|
`${dir} is a symbolic link to ${real}, outside ${base} — a cache restore never writes ` +
|