@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/LICENSE +21 -0
- package/README.md +502 -0
- package/index.ts +4 -0
- package/package.json +45 -6
- package/protos/build/bazel/remote/execution/v2/remote_execution.proto +2516 -0
- package/protos/build/bazel/semver/semver.proto +41 -0
- package/protos/google/api/annotations.proto +31 -0
- package/protos/google/api/client.proto +598 -0
- package/protos/google/api/field_behavior.proto +104 -0
- package/protos/google/api/http.proto +370 -0
- package/protos/google/api/launch_stage.proto +72 -0
- package/protos/google/bytestream/bytestream.proto +178 -0
- package/protos/google/longrunning/operations.proto +265 -0
- package/protos/google/rpc/code.proto +186 -0
- package/protos/google/rpc/status.proto +48 -0
- package/src/cache.ts +199 -0
- package/src/executor.ts +1805 -0
- package/src/index.ts +277 -0
- package/src/merkle.ts +867 -0
- package/src/wire.ts +1658 -0
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
|
+
}
|