@vzn/vx-reapi 0.0.0 → 0.0.485

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/src/merkle.ts ADDED
@@ -0,0 +1,867 @@
1
+ // The Merkle input root: vx's flat list of workspace-relative input paths
2
+ // becomes REAPI's nested `Directory` tree, and every node is a CAS blob.
3
+ //
4
+ // Why the file digests are recomputed rather than reused: vx's `InputFile`
5
+ // carries a GIT BLOB OID (sha1 over `blob <len>\0` + content), which is a
6
+ // different function over different bytes than REAPI's sha256-of-content.
7
+ // The key's digest cannot transfer, so the plugin hashes the worktree bytes
8
+ // itself, from the bytes it reads for the upload anyway. A memo keyed by
9
+ // (path, size, mtime) saved only that hash and served a stale digest for a
10
+ // same-size rewrite within the mtime's resolution (F-7).
11
+
12
+ import { createHash, type Hash } from 'node:crypto'
13
+ import { lstat, readlink } from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import type { Digest, Directory, DirectoryNode, FileNode, SymlinkNode } from './wire.js'
16
+
17
+ export interface Blob {
18
+ digest: Digest
19
+ data: Uint8Array
20
+ }
21
+
22
+ export interface InputTree {
23
+ /** Digest of the root `Directory` — goes in `Action.input_root_digest`. */
24
+ root: Digest
25
+ /** Every blob the server needs: file contents plus the Directory nodes. */
26
+ blobs: Blob[]
27
+ /** File count, for logging/metrics. */
28
+ fileCount: number
29
+ /**
30
+ * Paths where a grafted subtree SHADOWED a disk-built directory of the
31
+ * same name. The graft wins (it is the upstream's authoritative output;
32
+ * the disk copy at best a stale materialisation) — but any DECLARED input
33
+ * files under that path are silently absent from the tree, so the caller
34
+ * should surface these.
35
+ */
36
+ shadowed: string[]
37
+ /**
38
+ * Paths whose bytes as read no longer carry the digest `expected` gave
39
+ * them: the key describes one state of the tree and this is another.
40
+ */
41
+ moved: string[]
42
+ }
43
+
44
+ /** `git hash-object` of `bytes` in the object format `oid` is written in. */
45
+ function carriesOid(bytes: Uint8Array, oid: string): boolean {
46
+ // A key digest may carry a mode (`100755:<oid>`); the bytes answer for the oid.
47
+ const bare = oid.slice(oid.lastIndexOf(':') + 1)
48
+ const hash = createHash(bare.length === 64 ? 'sha256' : 'sha1')
49
+ return hash.update(`blob ${bytes.byteLength}\0`).update(bytes).digest('hex') === bare
50
+ }
51
+
52
+ export function sha256(data: Uint8Array): Digest {
53
+ return { hash: createHash('sha256').update(data).digest('hex'), size_bytes: data.length }
54
+ }
55
+
56
+ interface DirNode {
57
+ files: Map<string, FileNode>
58
+ dirs: Map<string, DirNode>
59
+ symlinks: Map<string, SymlinkNode>
60
+ /** Pre-digested subtrees grafted by reference — no bytes on this machine. */
61
+ rawDirs: Map<string, Digest>
62
+ }
63
+
64
+ const emptyDir = (): DirNode => ({
65
+ files: new Map(),
66
+ dirs: new Map(),
67
+ symlinks: new Map(),
68
+ rawDirs: new Map(),
69
+ })
70
+
71
+ /** A file grafted into the tree by DIGEST — its bytes are already in the CAS. */
72
+ export interface FileGraft {
73
+ /** Workspace-relative POSIX path. */
74
+ path: string
75
+ digest: Digest
76
+ isExecutable: boolean
77
+ }
78
+
79
+ /** A whole directory grafted from a decoded REAPI `Tree`. */
80
+ export interface TreeGraft {
81
+ /** Workspace-relative POSIX path the subtree lands at. */
82
+ path: string
83
+ root: Directory
84
+ children: Directory[]
85
+ /**
86
+ * Each child's digest over the bytes the worker SENT (`decodeTreeWithBytes`),
87
+ * parallel to `children`: the only key a parent's `DirectoryNode` digest
88
+ * is sure to match.
89
+ */
90
+ childDigests?: readonly string[]
91
+ }
92
+
93
+ /**
94
+ * Re-canonicalise a Tree's directory graph bottom-up under OUR encoder.
95
+ * The Tree's internal `DirectoryNode.digest` values were computed by the
96
+ * WORKER's encoder; if its byte layout differs from ours in any way, reusing
97
+ * them while re-encoding parents would produce parents that reference child
98
+ * digests no blob matches. Rebuilding every digest from the leaves up makes
99
+ * the graph self-consistent regardless of who produced it — file digests are
100
+ * untouched (content-addressed, already in the CAS).
101
+ */
102
+ function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
103
+ // Keyed by the worker's own bytes first: a child re-encoded by US matches
104
+ // its parent's reference only when both encoders agree byte for byte, and
105
+ // one that does not (a field order, a `node_properties` our decoder drops)
106
+ // stayed unresolved, leaving the parent pointing at a Directory no blob
107
+ // here matches (item 820).
108
+ const byOldDigest = new Map<string, Directory>()
109
+ graft.children.forEach((child, i) => {
110
+ byOldDigest.set(sha256(encodeDirectory(child)).hash, child)
111
+ const sent = graft.childDigests?.[i]
112
+ if (sent !== undefined) byOldDigest.set(sent, child)
113
+ })
114
+ const blobs: Blob[] = []
115
+ const rebuild = (dir: Directory): Digest => {
116
+ const directories: DirectoryNode[] = dir.directories.map((d) => {
117
+ const child = byOldDigest.get(d.digest.hash)
118
+ // A child we cannot resolve keeps its original digest — the blob may
119
+ // exist server-side under the worker's encoding even if we cannot
120
+ // re-derive it. Better a possibly-dangling reference than a wrong one.
121
+ return child === undefined ? d : { name: d.name, digest: rebuild(child) }
122
+ })
123
+ const canonical: Directory = { files: dir.files, directories, symlinks: dir.symlinks }
124
+ const data = encodeDirectory(canonical)
125
+ const digest = sha256(data)
126
+ blobs.push({ digest, data })
127
+ return digest
128
+ }
129
+ return { root: rebuild(graft.root), blobs }
130
+ }
131
+
132
+ /** File-system reads `buildInputTree` keeps in flight. */
133
+ const READ_CONCURRENCY = 32
134
+
135
+ /** `f` over `items`, at most `limit` at once, results in input order; the first failure stops it. */
136
+ export async function mapBounded<T, R>(
137
+ items: readonly T[],
138
+ limit: number,
139
+ f: (item: T) => Promise<R>,
140
+ ): Promise<R[]> {
141
+ const out: R[] = []
142
+ let next = 0
143
+ let failed = false
144
+ const worker = async (): Promise<void> => {
145
+ while (!failed && next < items.length) {
146
+ const i = next++
147
+ out[i] = await f(items[i]!).catch((err: unknown) => {
148
+ failed = true
149
+ throw err
150
+ })
151
+ }
152
+ }
153
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * Build the input root from workspace-relative paths. `executableFor` decides
159
+ * the executable bit, which REAPI carries per file and which a build that
160
+ * runs a checked-in script depends on.
161
+ */
162
+ export async function buildInputTree(args: {
163
+ workspaceRoot: string
164
+ paths: readonly string[]
165
+ readFile?: (abs: string) => Promise<Uint8Array>
166
+ /**
167
+ * Directories that must EXIST in the tree even when nothing puts a file
168
+ * inside them. REAPI requires an action's `working_directory` to exist in
169
+ * the input root; a task with no file inputs (runtime-only keys, or a pure
170
+ * generator) otherwise ships an empty tree and dies on the worker with an
171
+ * ENOENT that reads exactly like a missing shell.
172
+ */
173
+ ensureDirs?: readonly string[]
174
+ /** Files referenced by digest — upstream outputs already in the CAS. */
175
+ fileGrafts?: readonly FileGraft[]
176
+ /** An upstream record's symlinks, by workspace-relative path. */
177
+ symlinkGrafts?: readonly { path: string; target: string }[]
178
+ /** Directories grafted from upstream output Trees, re-canonicalised. */
179
+ treeGrafts?: readonly TreeGraft[]
180
+ /**
181
+ * The git blob OID the cache key folded for a path (for a symlink, of its
182
+ * target). A file read with other bytes is reported in `moved`.
183
+ */
184
+ expected?: ReadonlyMap<string, string>
185
+ }): Promise<InputTree> {
186
+ const read =
187
+ args.readFile ?? (async (abs: string) => new Uint8Array(await Bun.file(abs).arrayBuffer()))
188
+ const root = emptyDir()
189
+ const blobs: Blob[] = []
190
+ const seen = new Set<string>()
191
+ const moved: string[] = []
192
+ let fileCount = 0
193
+
194
+ // Insertion order is NOT what makes the tree deterministic — `encodeDirectory`
195
+ // sorts every node's names at encode time, and it has to, because grafts are
196
+ // inserted after this loop and no ordering here could reach them. Sorted
197
+ // anyway so a warning naming several paths reads the same run to run.
198
+ // Read READ_CONCURRENCY at once, inserted in sorted order below: one at a
199
+ // time, 2 000 small inputs cost 304 ms of file-system round trips (F-36).
200
+ const sorted = [...args.paths].sort()
201
+ const reads = await mapBounded(sorted, READ_CONCURRENCY, async (rel) => {
202
+ const abs = path.join(args.workspaceRoot, rel)
203
+ // lstat, not stat: a symlinked input must be REPRESENTED as a symlink.
204
+ // Following it would upload the target's bytes under the link's path —
205
+ // a tree that lies about its own shape, and a worker that materialises a
206
+ // copy where the task expects a link.
207
+ let st: Awaited<ReturnType<typeof lstat>>
208
+ try {
209
+ st = await lstat(abs)
210
+ } catch (err) {
211
+ // Gone since the key saw it: the key describes a file this tree lacks.
212
+ if (args.expected?.has(rel) === true && (err as NodeJS.ErrnoException).code === 'ENOENT') {
213
+ return { kind: 'gone' as const }
214
+ }
215
+ throw err
216
+ }
217
+ if (st.isSymbolicLink()) return { kind: 'link' as const, target: await readlink(abs) }
218
+ if (!st.isFile()) return { kind: 'other' as const }
219
+ return { kind: 'file' as const, data: await read(abs), mode: st.mode }
220
+ })
221
+ for (const [i, rel] of sorted.entries()) {
222
+ const got = reads[i]!
223
+ if (got.kind === 'gone') {
224
+ moved.push(rel)
225
+ continue
226
+ }
227
+ if (got.kind === 'link') {
228
+ const target = got.target
229
+ const want = args.expected?.get(rel)
230
+ if (want !== undefined && !carriesOid(new TextEncoder().encode(target), want)) moved.push(rel)
231
+ const parts = rel.split('/')
232
+ let node = root
233
+ for (const seg of parts.slice(0, -1)) {
234
+ let next = node.dirs.get(seg)
235
+ if (next === undefined) {
236
+ next = emptyDir()
237
+ node.dirs.set(seg, next)
238
+ }
239
+ node = next
240
+ }
241
+ node.symlinks.set(parts[parts.length - 1]!, { name: parts[parts.length - 1]!, target })
242
+ continue
243
+ }
244
+ if (got.kind === 'other') continue
245
+ const data = got.data
246
+ const want = args.expected?.get(rel)
247
+ if (want !== undefined && !carriesOid(data, want)) moved.push(rel)
248
+ // Hashed from the bytes just read, never memoised by (path, size,
249
+ // mtime): a same-size rewrite within the mtime's resolution kept the
250
+ // old digest while `expected` checked the new bytes, and the worker ran
251
+ // the old blob under a key naming the new one (F-7).
252
+ const digest = sha256(data)
253
+ if (!seen.has(digest.hash)) {
254
+ seen.add(digest.hash)
255
+ blobs.push({ digest, data })
256
+ }
257
+ const parts = rel.split('/')
258
+ let node = root
259
+ for (const seg of parts.slice(0, -1)) {
260
+ let next = node.dirs.get(seg)
261
+ if (next === undefined) {
262
+ next = emptyDir()
263
+ node.dirs.set(seg, next)
264
+ }
265
+ node = next
266
+ }
267
+ // The owner-execute bit is what REAPI models; group/other add nothing a
268
+ // worker can act on.
269
+ node.files.set(parts[parts.length - 1]!, {
270
+ name: parts[parts.length - 1]!,
271
+ digest,
272
+ is_executable: (got.mode & 0o100) !== 0,
273
+ })
274
+ fileCount++
275
+ }
276
+
277
+ const insertAt = (rel: string): { node: DirNode; leaf: string } => {
278
+ const parts = rel.split('/')
279
+ let node = root
280
+ for (const seg of parts.slice(0, -1)) {
281
+ let next = node.dirs.get(seg)
282
+ if (next === undefined) {
283
+ next = emptyDir()
284
+ node.dirs.set(seg, next)
285
+ }
286
+ node = next
287
+ }
288
+ return { node, leaf: parts[parts.length - 1]! }
289
+ }
290
+
291
+ for (const graft of args.fileGrafts ?? []) {
292
+ const { node, leaf } = insertAt(graft.path)
293
+ node.files.set(leaf, { name: leaf, digest: graft.digest, is_executable: graft.isExecutable })
294
+ fileCount++
295
+ }
296
+ for (const graft of args.symlinkGrafts ?? []) {
297
+ const { node, leaf } = insertAt(graft.path)
298
+ node.symlinks.set(leaf, { name: leaf, target: graft.target })
299
+ }
300
+ for (const dir of args.ensureDirs ?? []) {
301
+ if (dir === '') continue
302
+ let node = root
303
+ for (const seg of dir.split('/')) {
304
+ let next = node.dirs.get(seg)
305
+ if (next === undefined) {
306
+ next = emptyDir()
307
+ node.dirs.set(seg, next)
308
+ }
309
+ node = next
310
+ }
311
+ }
312
+
313
+ const shadowed: string[] = []
314
+ for (const graft of args.treeGrafts ?? []) {
315
+ const { node, leaf } = insertAt(graft.path)
316
+ if (node.dirs.has(leaf)) shadowed.push(graft.path)
317
+ const canonical = canonicaliseTree(graft)
318
+ for (const b of canonical.blobs) {
319
+ if (!seen.has(b.digest.hash)) {
320
+ seen.add(b.digest.hash)
321
+ blobs.push(b)
322
+ }
323
+ }
324
+ node.rawDirs.set(leaf, canonical.root)
325
+ }
326
+
327
+ const rootDigest = serialise(root, blobs, seen)
328
+ return { root: rootDigest, blobs, fileCount, shadowed, moved }
329
+ }
330
+
331
+ /**
332
+ * Depth-first, children before parents: a Directory's digest covers its
333
+ * children's digests, so they must be finalised first. REAPI requires `files`
334
+ * and `directories` sorted by name — a server may reject an unsorted
335
+ * Directory, and two orderings of the same tree would otherwise produce two
336
+ * different action digests for identical inputs.
337
+ */
338
+ function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
339
+ const directories: DirectoryNode[] = []
340
+ const dirNames = [...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])].sort()
341
+ for (const name of dirNames) {
342
+ const sub = node.dirs.get(name)
343
+ directories.push({
344
+ name,
345
+ // A grafted subtree wins over a disk-built one of the same name: the
346
+ // graft is the upstream's AUTHORITATIVE output, the disk copy at best
347
+ // a stale materialisation of it.
348
+ digest: node.rawDirs.get(name) ?? serialise(sub!, blobs, seen),
349
+ })
350
+ }
351
+ const files = [...node.files.keys()].sort().map((n) => node.files.get(n)!)
352
+ const symlinks = [...node.symlinks.keys()].sort().map((n) => node.symlinks.get(n)!)
353
+ const dir: Directory = { files, directories, symlinks }
354
+ const data = encodeDirectory(dir)
355
+ const digest = sha256(data)
356
+ if (!seen.has(digest.hash)) {
357
+ seen.add(digest.hash)
358
+ blobs.push({ digest, data })
359
+ }
360
+ return digest
361
+ }
362
+
363
+ // --- minimal protobuf encoding for the messages whose DIGEST must match ---
364
+ //
365
+ // The action/command/directory digests are computed from the SERIALISED
366
+ // bytes, so they must be encoded exactly as the server would. proto-loader's
367
+ // runtime does not expose a stable "encode this message" for arbitrary types
368
+ // without a client call, so the four messages whose bytes are load-bearing
369
+ // are encoded here, field by field, per the REAPI schema.
370
+
371
+ // Arithmetic, not bit operators: those are 32-bit, and a size of 4 GiB or
372
+ // more encoded modulo 2^32 — a Digest naming other bytes (F-43). Exact to
373
+ // 2^53, past any file.
374
+ function varint(n: number): Uint8Array {
375
+ const out: number[] = []
376
+ let v = n
377
+ while (v >= 0x80) {
378
+ out.push((v % 0x80) | 0x80)
379
+ v = Math.floor(v / 0x80)
380
+ }
381
+ out.push(v)
382
+ return new Uint8Array(out)
383
+ }
384
+
385
+ function tag(field: number, wire: number): Uint8Array {
386
+ return varint((field << 3) | wire)
387
+ }
388
+
389
+ function lenField(field: number, payload: Uint8Array): Uint8Array {
390
+ return concat([tag(field, 2), varint(payload.length), payload])
391
+ }
392
+
393
+ // proto3 canonical encoding OMITS fields holding the default value, and every
394
+ // conformant implementation (Bazel's included) does so. Emitting an explicit
395
+ // zero would change the serialised bytes and therefore the DIGEST — most
396
+ // visibly for the empty blob, whose `size_bytes` is 0, so any tree containing
397
+ // an empty file would address differently from the server's view of it.
398
+ function strField(field: number, value: string): Uint8Array {
399
+ return value === '' ? EMPTY : lenField(field, new TextEncoder().encode(value))
400
+ }
401
+
402
+ /**
403
+ * A REPEATED string element. Unlike a singular field, every element of a
404
+ * repeated field is emitted even when it is the empty string — and REAPI
405
+ * gives `output_paths: [""]` a meaning (the entire working directory), so
406
+ * dropping it would silently discard the action's outputs.
407
+ */
408
+ function repStrField(field: number, value: string): Uint8Array {
409
+ return lenField(field, new TextEncoder().encode(value))
410
+ }
411
+
412
+ function boolField(field: number, value: boolean): Uint8Array {
413
+ return value ? concat([tag(field, 0), varint(1)]) : EMPTY
414
+ }
415
+
416
+ function intField(field: number, value: number): Uint8Array {
417
+ return value === 0 ? EMPTY : concat([tag(field, 0), varint(value)])
418
+ }
419
+
420
+ const EMPTY = new Uint8Array()
421
+
422
+ export function concat(parts: readonly Uint8Array[]): Uint8Array {
423
+ const total = parts.reduce((n, p) => n + p.length, 0)
424
+ const out = new Uint8Array(total)
425
+ let at = 0
426
+ for (const p of parts) {
427
+ out.set(p, at)
428
+ at += p.length
429
+ }
430
+ return out
431
+ }
432
+
433
+ /** `Digest { hash = 1 (string), size_bytes = 2 (int64) }` */
434
+ export function encodeDigest(d: Digest): Uint8Array {
435
+ return concat([strField(1, d.hash), intField(2, d.size_bytes)])
436
+ }
437
+
438
+ /** `FileNode { name = 1, digest = 2, is_executable = 4, node_properties = 6 }`; 3 and 5 are reserved. */
439
+ function encodeFileNode(f: FileNode): Uint8Array {
440
+ return concat([
441
+ strField(1, f.name),
442
+ lenField(2, encodeDigest(f.digest)),
443
+ boolField(4, f.is_executable),
444
+ ...(f.node_properties === undefined
445
+ ? []
446
+ : [lenField(6, encodeNodeProperties(f.node_properties))]),
447
+ ])
448
+ }
449
+
450
+ /**
451
+ * `NodeProperties { properties = 1, mtime = 2, unix_mode = 3 }`.
452
+ *
453
+ * `unix_mode` is a `google.protobuf.UInt32Value` — a WRAPPER message, so the
454
+ * value is nested (`{ value = 1 }`), not a bare varint. Same for `mtime` as a
455
+ * `Timestamp { seconds = 1, nanos = 2 }`. Getting either shape wrong changes
456
+ * the Directory bytes and therefore every digest above it.
457
+ */
458
+ function encodeNodeProperties(np: NodeProperties): Uint8Array {
459
+ const parts: Uint8Array[] = []
460
+ if (np.mtimeMs !== undefined) {
461
+ const seconds = Math.floor(np.mtimeMs / 1000)
462
+ const nanos = Math.round((np.mtimeMs - seconds * 1000) * 1e6)
463
+ parts.push(lenField(2, concat([intField(1, seconds), intField(2, nanos)])))
464
+ }
465
+ if (np.unixMode !== undefined) parts.push(lenField(3, intField(1, np.unixMode)))
466
+ return concat(parts)
467
+ }
468
+
469
+ /** `DirectoryNode { name = 1, digest = 2 }` */
470
+ function encodeDirectoryNode(d: DirectoryNode): Uint8Array {
471
+ return concat([strField(1, d.name), lenField(2, encodeDigest(d.digest))])
472
+ }
473
+
474
+ /** `Directory { files = 1, directories = 2, symlinks = 3 }` */
475
+ export function encodeDirectory(dir: Directory): Uint8Array {
476
+ return concat([
477
+ ...dir.files.map((f) => lenField(1, encodeFileNode(f))),
478
+ ...dir.directories.map((d) => lenField(2, encodeDirectoryNode(d))),
479
+ ...dir.symlinks.map((s) => lenField(3, concat([strField(1, s.name), strField(2, s.target)]))),
480
+ ])
481
+ }
482
+
483
+ /** REAPI `DigestFunction.Value`. SHA256 is the universal baseline. */
484
+ const DIGEST_FUNCTION = {
485
+ UNKNOWN: 0,
486
+ SHA256: 1,
487
+ SHA1: 2,
488
+ MD5: 3,
489
+ VSO: 4,
490
+ SHA384: 5,
491
+ SHA512: 6,
492
+ MURMUR3: 7,
493
+ BLAKE3: 8,
494
+ } as const
495
+ export type DigestFunctionName = keyof typeof DIGEST_FUNCTION
496
+
497
+ /** REAPI `Compressor.Value`. */
498
+ const COMPRESSOR = { IDENTITY: 0, ZSTD: 1, DEFLATE: 2, BROTLI: 3 } as const
499
+
500
+ /** Node.js hash names for the digest functions we can actually compute. */
501
+ const HASH_ALGO: Partial<Record<DigestFunctionName, string>> = {
502
+ SHA256: 'sha256',
503
+ SHA1: 'sha1',
504
+ MD5: 'md5',
505
+ SHA384: 'sha384',
506
+ SHA512: 'sha512',
507
+ BLAKE3: 'blake3',
508
+ }
509
+
510
+ /**
511
+ * Digest under a negotiated function. Servers advertise what they accept via
512
+ * `Capabilities.cache_capabilities.digest_functions`; mixing functions inside
513
+ * one action is invalid, so the choice is made once per client.
514
+ */
515
+ export function digestWith(fn: DigestFunctionName, data: Uint8Array): Digest {
516
+ const algo = HASH_ALGO[fn]
517
+ if (algo === undefined) throw new Error(`@vzn/vx-reapi: unsupported digest function ${fn}`)
518
+ return { hash: createHash(algo).update(data).digest('hex'), size_bytes: data.length }
519
+ }
520
+
521
+ /** `fn` as an incremental hasher, for a blob read as a stream; `undefined` when `canDigest` is false. */
522
+ export function hasherFor(fn: DigestFunctionName): Hash | undefined {
523
+ return canDigest(fn) ? createHash(HASH_ALGO[fn]!) : undefined
524
+ }
525
+
526
+ /** True when this build of Bun/Node can compute the function at all. */
527
+ export function canDigest(fn: DigestFunctionName): boolean {
528
+ const algo = HASH_ALGO[fn]
529
+ if (algo === undefined) return false
530
+ try {
531
+ createHash(algo)
532
+ return true
533
+ } catch {
534
+ return false
535
+ }
536
+ }
537
+
538
+ export interface NodeProperties {
539
+ /** POSIX mode bits, as REAPI's `unix_mode` (a `UInt32Value` wrapper). */
540
+ unixMode?: number
541
+ /** Modification time in ms since the epoch. */
542
+ mtimeMs?: number
543
+ }
544
+
545
+ const OUTPUT_DIRECTORY_FORMAT = {
546
+ TREE_ONLY: 0,
547
+ DIRECTORY_ONLY: 1,
548
+ TREE_AND_DIRECTORY: 2,
549
+ } as const
550
+
551
+ interface CommandSpec {
552
+ arguments: readonly string[]
553
+ environmentVariables: ReadonlyArray<{ name: string; value: string }>
554
+ outputPaths: readonly string[]
555
+ workingDirectory: string
556
+ platform: ReadonlyArray<{ name: string; value: string }>
557
+ /**
558
+ * DEPRECATED-in-v2.1 fields, still SET for v2.0 servers: a v2.1+ server
559
+ * reads `output_paths` and ignores these; a v2.0 server does the inverse
560
+ * (`output_paths` is an unknown field to it). Setting both is how one
561
+ * Command works against either generation. The digest stays consistent
562
+ * because both sides hash the bytes the CLIENT produced.
563
+ */
564
+ legacyOutputFiles?: readonly string[]
565
+ legacyOutputDirectories?: readonly string[]
566
+ /** `output_node_properties` — names of NodeProperties the client wants back. */
567
+ outputNodeProperties?: readonly string[]
568
+ /** `output_directory_format` — request Tree blobs, root digests, or both. */
569
+ outputDirectoryFormat?: number
570
+ }
571
+
572
+ /**
573
+ * `Command { arguments = 1, environment_variables = 2, output_files = 3,
574
+ * output_directories = 4, platform = 5, working_directory = 6,
575
+ * output_paths = 7, output_node_properties = 8,
576
+ * output_directory_format = 9 }`
577
+ *
578
+ * Env vars and every output list MUST be sorted per the spec — otherwise two
579
+ * identical commands hash differently and never share a cache entry across
580
+ * machines. Fields are emitted in FIELD-NUMBER ORDER because the digest is
581
+ * over these bytes and canonical encoders write ascending field numbers.
582
+ */
583
+ export function encodeCommand(c: CommandSpec): Uint8Array {
584
+ const env = [...c.environmentVariables].sort((a, b) =>
585
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
586
+ )
587
+ const platform = [...c.platform].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
588
+ return concat([
589
+ ...c.arguments.map((a) => repStrField(1, a)),
590
+ ...env.map((e) => lenField(2, concat([strField(1, e.name), strField(2, e.value)]))),
591
+ ...[...(c.legacyOutputFiles ?? [])].sort().map((p) => repStrField(3, p)),
592
+ ...[...(c.legacyOutputDirectories ?? [])].sort().map((p) => repStrField(4, p)),
593
+ ...(platform.length > 0
594
+ ? [
595
+ lenField(
596
+ 5,
597
+ concat(
598
+ platform.map((p) => lenField(1, concat([strField(1, p.name), strField(2, p.value)]))),
599
+ ),
600
+ ),
601
+ ]
602
+ : []),
603
+ ...(c.workingDirectory === '' ? [] : [strField(6, c.workingDirectory)]),
604
+ ...[...c.outputPaths].sort().map((p) => repStrField(7, p)),
605
+ ...[...(c.outputNodeProperties ?? [])].sort().map((n) => repStrField(8, n)),
606
+ ...(c.outputDirectoryFormat === undefined || c.outputDirectoryFormat === 0
607
+ ? []
608
+ : [intField(9, c.outputDirectoryFormat)]),
609
+ ])
610
+ }
611
+
612
+ /**
613
+ * `Action { command_digest = 1, input_root_digest = 2, timeout = 6,
614
+ * do_not_cache = 7 }`
615
+ */
616
+ export function encodeAction(a: {
617
+ commandDigest: Digest
618
+ inputRootDigest: Digest
619
+ timeoutSeconds?: number
620
+ doNotCache?: boolean
621
+ /** `salt` (field 9) — bytes that change the action digest without changing
622
+ * the work, so a caller can force a distinct cache entry. */
623
+ salt?: Uint8Array
624
+ /** `platform` (field 10) — REAPI v2.2 moved it here from `Command`. */
625
+ platform?: ReadonlyArray<{ name: string; value: string }>
626
+ }): Uint8Array {
627
+ const platform = [...(a.platform ?? [])].sort((x, y) =>
628
+ x.name < y.name ? -1 : x.name > y.name ? 1 : 0,
629
+ )
630
+ return concat([
631
+ lenField(1, encodeDigest(a.commandDigest)),
632
+ lenField(2, encodeDigest(a.inputRootDigest)),
633
+ ...(a.timeoutSeconds === undefined || a.timeoutSeconds === 0
634
+ ? []
635
+ : [lenField(6, intField(1, Math.floor(a.timeoutSeconds)))]),
636
+ boolField(7, a.doNotCache === true),
637
+ ...(a.salt === undefined || a.salt.length === 0 ? [] : [lenField(9, a.salt)]),
638
+ ...(platform.length === 0
639
+ ? []
640
+ : [
641
+ lenField(
642
+ 10,
643
+ concat(
644
+ platform.map((pr) =>
645
+ lenField(1, concat([strField(1, pr.name), strField(2, pr.value)])),
646
+ ),
647
+ ),
648
+ ),
649
+ ]),
650
+ ])
651
+ }
652
+
653
+ /** `Tree { root = 1, children = 2 }` — the encode half of `decodeTree`. */
654
+ export function encodeTree(root: Directory, children: readonly Directory[]): Uint8Array {
655
+ return concat([
656
+ lenField(1, encodeDirectory(root)),
657
+ ...children.map((c) => lenField(2, encodeDirectory(c))),
658
+ ])
659
+ }
660
+
661
+ /** `Tree { root = 1, children = 2 }` — the shape an OutputDirectory points at. */
662
+ export function decodeTree(buf: Uint8Array): { root?: Directory; children: Directory[] } {
663
+ const full = decodeTreeWithBytes(buf)
664
+ return { ...(full.root === undefined ? {} : { root: full.root }), children: full.children }
665
+ }
666
+
667
+ /**
668
+ * `decodeTree`, but each child also carries the RAW bytes it was decoded from.
669
+ *
670
+ * The only link between a `Directory` and its parent is a digest, and that
671
+ * digest was computed by the WORKER over ITS encoding. Re-deriving one by
672
+ * re-encoding our own parse is a guess: any byte-level difference — field
673
+ * order, an omitted default, node properties we drop — makes the lookup miss,
674
+ * and a miss is silent (the child is simply skipped). Hashing the bytes the
675
+ * worker actually sent is exact, needs no round trip, and cannot drift.
676
+ *
677
+ * Measured before this existed: of the 649 directories in one captured tree,
678
+ * only the 4 at the root resolved, so a walk two levels deep found almost
679
+ * nothing and reported the whole tree instead.
680
+ */
681
+ export function decodeTreeWithBytes(buf: Uint8Array): {
682
+ root?: Directory
683
+ children: Directory[]
684
+ childDigests: string[]
685
+ } {
686
+ const out: { root?: Directory; children: Directory[]; childDigests: string[] } = {
687
+ children: [],
688
+ childDigests: [],
689
+ }
690
+ let i = 0
691
+ while (i < buf.length) {
692
+ const [key, k] = readVarintAt(buf, i)
693
+ i = k
694
+ const wire = key & 7
695
+ if (wire !== 2) break
696
+ const [len, l] = readVarintAt(buf, i)
697
+ i = l
698
+ const slice = buf.subarray(i, i + len)
699
+ i += len
700
+ if (key >>> 3 === 1) out.root = decodeDirectory(slice)
701
+ else if (key >>> 3 === 2) {
702
+ out.children.push(decodeDirectory(slice))
703
+ out.childDigests.push(sha256(slice).hash)
704
+ }
705
+ }
706
+ return out
707
+ }
708
+
709
+ /** `Directory { files = 1, directories = 2, symlinks = 3 }` */
710
+ export function decodeDirectory(buf: Uint8Array): Directory {
711
+ const dir: Directory = { files: [], directories: [], symlinks: [] }
712
+ let i = 0
713
+ while (i < buf.length) {
714
+ const [key, k] = readVarintAt(buf, i)
715
+ i = k
716
+ const wire = key & 7
717
+ if (wire !== 2) break
718
+ const [len, l] = readVarintAt(buf, i)
719
+ i = l
720
+ const slice = buf.subarray(i, i + len)
721
+ i += len
722
+ const field = key >>> 3
723
+ if (field === 1) dir.files.push(decodeFileNode(slice))
724
+ else if (field === 2) dir.directories.push(decodeDirectoryNode(slice))
725
+ else if (field === 3) dir.symlinks.push(decodeSymlinkNode(slice))
726
+ }
727
+ return dir
728
+ }
729
+
730
+ function decodeFileNode(buf: Uint8Array): FileNode {
731
+ const f: FileNode = { name: '', digest: { hash: '', size_bytes: 0 }, is_executable: false }
732
+ let i = 0
733
+ while (i < buf.length) {
734
+ const [key, k] = readVarintAt(buf, i)
735
+ i = k
736
+ const field = key >>> 3
737
+ const wire = key & 7
738
+ if (wire === 2) {
739
+ const [len, l] = readVarintAt(buf, i)
740
+ i = l
741
+ const slice = buf.subarray(i, i + len)
742
+ i += len
743
+ if (field === 1) f.name = new TextDecoder().decode(slice)
744
+ else if (field === 2) f.digest = decodeDigestBytes(slice)
745
+ else if (field === 6) f.node_properties = decodeNodeProperties(slice)
746
+ } else if (wire === 0) {
747
+ const [v, n] = readVarintAt(buf, i)
748
+ i = n
749
+ if (field === 4) f.is_executable = v === 1
750
+ } else break
751
+ }
752
+ return f
753
+ }
754
+
755
+ /**
756
+ * The two `NodeProperties` fields vx reads back, the inverse of
757
+ * `encodeNodeProperties`: `mtime = 2` (a `Timestamp`) and `unix_mode = 3`
758
+ * (a `UInt32Value`). The executor honours `unix_mode` on materialise; before
759
+ * this the decoder dropped the field, and that branch never ran (item 820).
760
+ */
761
+ function decodeNodeProperties(buf: Uint8Array): NodeProperties {
762
+ const np: NodeProperties = {}
763
+ let i = 0
764
+ while (i < buf.length) {
765
+ const [key, k] = readVarintAt(buf, i)
766
+ i = k
767
+ if ((key & 7) !== 2) break
768
+ const [len, l] = readVarintAt(buf, i)
769
+ i = l
770
+ const slice = buf.subarray(i, i + len)
771
+ i += len
772
+ const inner = varintFields(slice)
773
+ if (key >>> 3 === 2) np.mtimeMs = (inner.get(1) ?? 0) * 1000 + (inner.get(2) ?? 0) / 1e6
774
+ else if (key >>> 3 === 3) np.unixMode = inner.get(1) ?? 0
775
+ }
776
+ return np
777
+ }
778
+
779
+ /** A message of varint fields only (`Timestamp`, `UInt32Value`), by field number. */
780
+ function varintFields(buf: Uint8Array): Map<number, number> {
781
+ const out = new Map<number, number>()
782
+ let i = 0
783
+ while (i < buf.length) {
784
+ const [key, k] = readVarintAt(buf, i)
785
+ i = k
786
+ if ((key & 7) !== 0) break
787
+ const [v, n] = readVarintAt(buf, i)
788
+ i = n
789
+ out.set(key >>> 3, v)
790
+ }
791
+ return out
792
+ }
793
+
794
+ function decodeDirectoryNode(buf: Uint8Array): DirectoryNode {
795
+ const d: DirectoryNode = { name: '', digest: { hash: '', size_bytes: 0 } }
796
+ let i = 0
797
+ while (i < buf.length) {
798
+ const [key, k] = readVarintAt(buf, i)
799
+ i = k
800
+ if ((key & 7) !== 2) break
801
+ const [len, l] = readVarintAt(buf, i)
802
+ i = l
803
+ const slice = buf.subarray(i, i + len)
804
+ i += len
805
+ if (key >>> 3 === 1) d.name = new TextDecoder().decode(slice)
806
+ else if (key >>> 3 === 2) d.digest = decodeDigestBytes(slice)
807
+ }
808
+ return d
809
+ }
810
+
811
+ function decodeSymlinkNode(buf: Uint8Array): SymlinkNode {
812
+ const sl: SymlinkNode = { name: '', target: '' }
813
+ let i = 0
814
+ while (i < buf.length) {
815
+ const [key, k] = readVarintAt(buf, i)
816
+ i = k
817
+ if ((key & 7) !== 2) break
818
+ const [len, l] = readVarintAt(buf, i)
819
+ i = l
820
+ const slice = buf.subarray(i, i + len)
821
+ i += len
822
+ if (key >>> 3 === 1) sl.name = new TextDecoder().decode(slice)
823
+ else if (key >>> 3 === 2) sl.target = new TextDecoder().decode(slice)
824
+ }
825
+ return sl
826
+ }
827
+
828
+ function decodeDigestBytes(buf: Uint8Array): Digest {
829
+ const d: Digest = { hash: '', size_bytes: 0 }
830
+ let i = 0
831
+ while (i < buf.length) {
832
+ const [key, k] = readVarintAt(buf, i)
833
+ i = k
834
+ const field = key >>> 3
835
+ const wire = key & 7
836
+ if (wire === 2) {
837
+ const [len, l] = readVarintAt(buf, i)
838
+ i = l
839
+ if (field === 1) d.hash = new TextDecoder().decode(buf.subarray(i, i + len))
840
+ i += len
841
+ } else if (wire === 0) {
842
+ const [v, n] = readVarintAt(buf, i)
843
+ i = n
844
+ if (field === 2) d.size_bytes = v
845
+ } else break
846
+ }
847
+ return d
848
+ }
849
+
850
+ function readVarintAt(buf: Uint8Array, at: number): [number, number] {
851
+ let result = 0
852
+ let low = 0
853
+ let shift = 0
854
+ let i = at
855
+ for (;;) {
856
+ const byte = buf[i++]
857
+ if (byte === undefined) break
858
+ // Added, not OR-ed: a size of 4 GiB or more wrapped to 32 bits (F-43).
859
+ // Past 2^53 the value is a negative int32 sent as ten bytes (an exit
860
+ // code): its low 32 bits, which `| 0` reads back as the negative.
861
+ result += (byte & 0x7f) * 2 ** shift
862
+ if (shift < 32) low |= (byte & 0x7f) << shift
863
+ if ((byte & 0x80) === 0) break
864
+ shift += 7
865
+ }
866
+ return [result <= Number.MAX_SAFE_INTEGER ? result : low >>> 0, i]
867
+ }