@namzu/sandbox 18.1.1 → 20.0.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +35 -5
  3. package/dist/backends/docker/index.d.ts +46 -1
  4. package/dist/backends/docker/index.d.ts.map +1 -1
  5. package/dist/backends/docker/index.js +187 -51
  6. package/dist/backends/docker/index.js.map +1 -1
  7. package/dist/egress/index.d.ts +1 -0
  8. package/dist/egress/index.d.ts.map +1 -1
  9. package/dist/egress/index.js +1 -0
  10. package/dist/egress/index.js.map +1 -1
  11. package/dist/egress/profile-wiring.d.ts +40 -0
  12. package/dist/egress/profile-wiring.d.ts.map +1 -0
  13. package/dist/egress/profile-wiring.js +75 -0
  14. package/dist/egress/profile-wiring.js.map +1 -0
  15. package/dist/egress/profile.d.ts +153 -0
  16. package/dist/egress/profile.d.ts.map +1 -0
  17. package/dist/egress/profile.js +244 -0
  18. package/dist/egress/profile.js.map +1 -0
  19. package/dist/egress/proxy.d.ts +16 -0
  20. package/dist/egress/proxy.d.ts.map +1 -1
  21. package/dist/egress/proxy.js +37 -3
  22. package/dist/egress/proxy.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +12 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/seed/index.d.ts +165 -0
  28. package/dist/seed/index.d.ts.map +1 -0
  29. package/dist/seed/index.js +505 -0
  30. package/dist/seed/index.js.map +1 -0
  31. package/dist/testing/sandbox-conformance.d.ts +30 -1
  32. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  33. package/dist/testing/sandbox-conformance.js +18 -0
  34. package/dist/testing/sandbox-conformance.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/backends/docker/index.ts +246 -51
  37. package/src/egress/index.ts +1 -0
  38. package/src/egress/profile-wiring.ts +125 -0
  39. package/src/egress/profile.ts +380 -0
  40. package/src/egress/proxy.ts +53 -3
  41. package/src/index.ts +54 -5
  42. package/src/seed/index.ts +710 -0
  43. package/src/testing/sandbox-conformance.ts +52 -6
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Sandbox seeds: git repositories a host wants present inside a sandbox,
3
+ * prepared by an idempotent step the caller runs, and prepared once on storage
4
+ * that outlives the sandbox (a kubernetes workspace's disk, a docker scratch
5
+ * bind the host reuses).
6
+ *
7
+ * The idea is ax's workspace repositories, prepared once with a marker on a
8
+ * durable disk; the code is this package's own. It is level-triggered in the
9
+ * sense that matters: every call looks at what is there and does only what is
10
+ * missing, so running it after every create or resume is the intended use.
11
+ *
12
+ * Everything runs through `Sandbox.exec`, so a file-root jail on the backend
13
+ * does not matter, and the guest needs `sh`, `git`, `find`, `mkdir`, `mktemp`,
14
+ * `rm` and a `mv` that takes `-T` (GNU coreutils; checked by a probe, not by
15
+ * name).
16
+ *
17
+ * WHAT IS TRUSTED, AND WHAT IS NOT. The seed marker lives in guest-writable
18
+ * storage, so an agent can forge it. It is never the reason a repository is
19
+ * skipped: every call checks each repository directly (it exists, its origin is
20
+ * the seed's URL, and the pinned or recorded commit is an ancestor of its
21
+ * HEAD), and the marker only remembers which commit a `ref` resolved to, and
22
+ * under which `ref`. A marker value that is not a commit id is ignored rather
23
+ * than passed to git.
24
+ *
25
+ * A CHANGED `ref` IS NOT THE SAME SEED. The recorded commit is used only when
26
+ * it was recorded under what the seed names now: the same `ref`, the default
27
+ * branch for none, or a pin (a pin's record never stands in for a `ref` or the
28
+ * default branch once the pin is dropped). Otherwise the check looks in the
29
+ * repository itself: the ref must exist there (as a remote-tracking branch or
30
+ * a tag; `refs/remotes/origin/HEAD` for the default branch) and name exactly
31
+ * HEAD. Sharing a line of history with HEAD is not enough, because the ref is
32
+ * often there without having been checked out: a clone deeper than 1 carries
33
+ * the tags in its history, and a pinned commit's full clone carries every
34
+ * remote branch. So a checkout of another branch, tag or commit is drift like
35
+ * any other, and a seed whose `ref` moved never reports the old checkout as
36
+ * `present`. The cost is on the safe side: with no record under the ref, local
37
+ * commits on top of it, or a `--branch` clone whose `ref` is then dropped
38
+ * (such a clone has no `origin/HEAD`), are drift too. A digest match is never
39
+ * a reason to skip a check either.
40
+ *
41
+ * NO CREDENTIAL ENTERS THE GUEST. URLs with a user name or password, `ssh://`
42
+ * and `git@host:path` are refused, since each would put a secret or a private
43
+ * key inside the sandbox. `https://` is for public repositories. `http://` is
44
+ * for one documented path: on the docker backend the egress proxy upgrades a
45
+ * plain request to HTTPS and stamps `brokeredCredentials` for the host, so a
46
+ * private repository can be cloned with no token in the guest. On every other
47
+ * backend private repositories are out of scope.
48
+ *
49
+ * NOTHING IS EVER DELETED OR RE-CLONED BUT THIS CALL'S OWN WORK. A repository
50
+ * whose origin or history no longer matches is drift: refused by default, or
51
+ * reported. A clone lands in `<dir>.namzu-partial-<nonce>` and is moved into
52
+ * place with `mv -T`, so two hosts preparing the same disk never see each
53
+ * other's half-written clone; the loser of that race removes only its own
54
+ * partial. Partials older than an hour, which only a crashed call leaves, are
55
+ * removed at the end, after this call's own clones have finished, so a peer's
56
+ * clone that is still running is left alone.
57
+ */
58
+
59
+ import { createHash, randomBytes } from 'node:crypto'
60
+
61
+ import type { Sandbox, SandboxExecResult } from '@namzu/sdk'
62
+
63
+ /** One repository of a {@link SandboxSeed}. */
64
+ export interface SandboxSeedRepository {
65
+ /** A DNS-1123 label, unique in the seed. The default directory under the seed root. */
66
+ readonly name: string
67
+ /**
68
+ * `https://` for a public repository, or `http://` for a host the docker
69
+ * egress proxy brokers a credential for (see the module doc). No user name
70
+ * or password, no `ssh://`, no `git@host:path`.
71
+ */
72
+ readonly url: string
73
+ /**
74
+ * A branch or tag. Resolved once, when cloned, and the commit recorded with
75
+ * the ref. Changing it later is drift unless HEAD on disk is exactly the
76
+ * commit the new ref names in the repository (see the module doc).
77
+ */
78
+ readonly ref?: string
79
+ /** A full commit id to pin. Wins over `ref`, and is checked on every call. */
80
+ readonly commit?: string
81
+ /**
82
+ * Directory relative to the seed root. Default `name`. No `..`, not
83
+ * absolute, not under `.namzu/` (the marker's directory), and neither
84
+ * inside nor containing another repository's directory.
85
+ */
86
+ readonly dir?: string
87
+ /**
88
+ * Clone depth. Default 1. Refused together with `commit`, because a pinned
89
+ * commit is fetched with its history so it can be checked out and later
90
+ * proved an ancestor of HEAD.
91
+ */
92
+ readonly depth?: number
93
+ }
94
+
95
+ /** A named set of repositories. */
96
+ export interface SandboxSeed {
97
+ /** A DNS-1123 label. Names the marker file under `<root>/.namzu/seed/`. */
98
+ readonly name: string
99
+ readonly repositories: readonly SandboxSeedRepository[]
100
+ }
101
+
102
+ /** What one repository was found or left as. */
103
+ export interface SandboxSeedRepositoryReport {
104
+ readonly name: string
105
+ /**
106
+ * `cloned` by this call; `present` and matching the seed; `drifted`, its
107
+ * origin, `ref` or history no longer matching, reported under
108
+ * `onDrift: 'report'` and left exactly as it was.
109
+ */
110
+ readonly status: 'cloned' | 'present' | 'drifted'
111
+ /** The repository's HEAD commit. */
112
+ readonly commit: string
113
+ }
114
+
115
+ /** What {@link ensureSandboxSeed} found and did. */
116
+ export interface SandboxSeedReport {
117
+ /** {@link sandboxSeedDigest} of the seed. */
118
+ readonly digest: string
119
+ readonly repositories: readonly SandboxSeedRepositoryReport[]
120
+ }
121
+
122
+ /** Options for {@link ensureSandboxSeed}. */
123
+ export interface EnsureSandboxSeedOptions {
124
+ /**
125
+ * Absolute directory inside the sandbox the repositories go under.
126
+ * Required, because the right place depends on the backend: on docker the
127
+ * sandbox's working root is the outputs bind the host collects, so a
128
+ * default there would put repositories in the user's outputs. Use the
129
+ * kubernetes workspace template's disk mount, or `layout.scratch` on docker.
130
+ */
131
+ readonly root: string
132
+ /** Cancels the call; handed to every `exec`. */
133
+ readonly signal?: AbortSignal
134
+ /**
135
+ * Per-`exec` timeout in milliseconds, handed to the backend. A clone of a
136
+ * large repository needs one sized for it; unset leaves the backend's own.
137
+ */
138
+ readonly timeoutMs?: number
139
+ /** `'refuse'` (the default) throws on drift before cloning anything; `'report'` records it. */
140
+ readonly onDrift?: 'refuse' | 'report'
141
+ }
142
+
143
+ /** Why a seed could not be prepared. */
144
+ export type SandboxSeedErrorCode = 'invalid' | 'tool-missing' | 'clone-failed' | 'drift'
145
+
146
+ /** A seed refused, or a step that failed. `repository` names the repository when there is one. */
147
+ export class SandboxSeedError extends Error {
148
+ override readonly name = 'SandboxSeedError'
149
+
150
+ constructor(
151
+ readonly code: SandboxSeedErrorCode,
152
+ message: string,
153
+ readonly repository?: string,
154
+ ) {
155
+ super(`sandbox seed: ${message}`)
156
+ }
157
+ }
158
+
159
+ const DNS_1123_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/
160
+ const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
161
+ const REF = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/
162
+ const PATH_SEGMENT = /^[A-Za-z0-9._-]+$/
163
+
164
+ /** The check's drift states, and what each says about the repository. */
165
+ const DRIFT_STATES: ReadonlyMap<string, string> = new Map([
166
+ ['origin', 'has a different origin URL'],
167
+ ['history', 'no longer contains its pinned or recorded commit'],
168
+ [
169
+ 'ref',
170
+ "does not hold the seed's ref (the default branch when it names none): with no record under that ref, HEAD must be exactly the commit the ref names in the repository, and it is another branch, tag or commit",
171
+ ],
172
+ [
173
+ 'pin',
174
+ 'is not checked out at its pinned commit: with no record of that pin, HEAD must be exactly the pinned commit, and it is another commit',
175
+ ],
176
+ ['occupied', 'is a directory that is not a git repository'],
177
+ ])
178
+
179
+ /**
180
+ * What CHECK looks up for a seed that names no `ref` and has no record: the
181
+ * remote's default branch, as `refs/remotes/origin/HEAD` (which a clone with
182
+ * no `--branch` leaves). It starts with ':', which no `ref` the seed accepts
183
+ * can, so it never collides with a branch or tag name.
184
+ */
185
+ const DEFAULT_BRANCH = ':default'
186
+
187
+ /**
188
+ * The marker's `refs` entry for a repository: what its recorded commit was
189
+ * resolved from. A pinned repository records `:commit`, so dropping the pin
190
+ * never lets the pinned commit stand in for the default branch or a `ref`.
191
+ */
192
+ function recordedFrom(repo: SandboxSeedRepository): string {
193
+ return repo.commit !== undefined ? ':commit' : (repo.ref ?? '')
194
+ }
195
+
196
+ /** How old a partial clone must be before a later call removes it. */
197
+ const STALE_PARTIAL_MINUTES = 60
198
+
199
+ function refuseUrl(url: unknown, where: string): string {
200
+ if (typeof url !== 'string' || url.length === 0) {
201
+ throw new SandboxSeedError('invalid', `${where} is not a URL`)
202
+ }
203
+ if (/^[^/]*@[^/]*:/.test(url) && !url.includes('://')) {
204
+ throw new SandboxSeedError(
205
+ 'invalid',
206
+ `${where} is an scp-style SSH address; SSH would need a private key inside the sandbox, so only https:// (and http:// for a proxy-brokered host) is accepted`,
207
+ )
208
+ }
209
+ let parsed: URL
210
+ try {
211
+ parsed = new URL(url)
212
+ } catch {
213
+ throw new SandboxSeedError('invalid', `${where} is not a URL`)
214
+ }
215
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
216
+ throw new SandboxSeedError(
217
+ 'invalid',
218
+ `${where} uses ${parsed.protocol.replace(/:$/, '')}; only https:// (and http:// for a proxy-brokered host) is accepted, because other transports would need a credential or a key inside the sandbox`,
219
+ )
220
+ }
221
+ if (parsed.username !== '' || parsed.password !== '') {
222
+ throw new SandboxSeedError(
223
+ 'invalid',
224
+ `${where} carries a user name or password, which would put a credential inside the sandbox; on docker, broker it with brokeredCredentials and an http:// URL instead`,
225
+ )
226
+ }
227
+ if (/\s/.test(url)) throw new SandboxSeedError('invalid', `${where} contains whitespace`)
228
+ return url
229
+ }
230
+
231
+ function refuseDir(dir: string, where: string): string {
232
+ const segments = dir.split('/')
233
+ if (
234
+ dir.startsWith('/') ||
235
+ segments.some((segment) => segment === '..' || segment === '.' || !PATH_SEGMENT.test(segment))
236
+ ) {
237
+ throw new SandboxSeedError(
238
+ 'invalid',
239
+ `${where} ${JSON.stringify(dir)} must be a relative path of plain segments (letters, digits, '.', '_', '-'), with no '..'`,
240
+ )
241
+ }
242
+ if (segments[0] === '.namzu' || segments.some((segment) => segment.includes('.namzu-partial-'))) {
243
+ throw new SandboxSeedError(
244
+ 'invalid',
245
+ `${where} ${JSON.stringify(dir)} is reserved: .namzu/ holds the seed marker, and '.namzu-partial-' names a clone in progress`,
246
+ )
247
+ }
248
+ return dir
249
+ }
250
+
251
+ /**
252
+ * Validate a seed and return it frozen. Refused with `SandboxSeedError`
253
+ * (`code: 'invalid'`): a name that is not a DNS-1123 label, no repositories,
254
+ * a repository name repeated, a URL outside the rules in the module doc, a
255
+ * `ref` that is not a plain branch or tag name, a `commit` that is not a full
256
+ * commit id, a `dir` that is absolute, climbs or is under `.namzu/`, two
257
+ * repositories in one directory or one inside the other, and `depth` that is not a positive integer or is set beside
258
+ * `commit`.
259
+ */
260
+ export function defineSandboxSeed(input: SandboxSeed): SandboxSeed {
261
+ if (typeof input?.name !== 'string' || !DNS_1123_LABEL.test(input.name)) {
262
+ throw new SandboxSeedError(
263
+ 'invalid',
264
+ `name ${JSON.stringify(input?.name)} is not a DNS-1123 label`,
265
+ )
266
+ }
267
+ if (!Array.isArray(input.repositories) || input.repositories.length === 0) {
268
+ throw new SandboxSeedError('invalid', 'repositories must list at least one repository')
269
+ }
270
+ const names = new Set<string>()
271
+ const dirs: string[] = []
272
+ const repositories = input.repositories.map((repo, index): SandboxSeedRepository => {
273
+ const where = `repositories[${index}]`
274
+ if (typeof repo?.name !== 'string' || !DNS_1123_LABEL.test(repo.name)) {
275
+ throw new SandboxSeedError(
276
+ 'invalid',
277
+ `${where}.name ${JSON.stringify(repo?.name)} is not a DNS-1123 label`,
278
+ )
279
+ }
280
+ if (names.has(repo.name)) {
281
+ throw new SandboxSeedError(
282
+ 'invalid',
283
+ `${where}.name ${JSON.stringify(repo.name)} is listed twice`,
284
+ )
285
+ }
286
+ names.add(repo.name)
287
+ const url = refuseUrl(repo.url, `${where}.url`)
288
+ if (
289
+ repo.ref !== undefined &&
290
+ (typeof repo.ref !== 'string' || !REF.test(repo.ref) || repo.ref.includes('..'))
291
+ ) {
292
+ throw new SandboxSeedError(
293
+ 'invalid',
294
+ `${where}.ref ${JSON.stringify(repo.ref)} is not a branch or tag name`,
295
+ )
296
+ }
297
+ if (
298
+ repo.commit !== undefined &&
299
+ (typeof repo.commit !== 'string' || !COMMIT_ID.test(repo.commit))
300
+ ) {
301
+ throw new SandboxSeedError(
302
+ 'invalid',
303
+ `${where}.commit ${JSON.stringify(repo.commit)} is not a full lowercase commit id`,
304
+ )
305
+ }
306
+ if (repo.depth !== undefined) {
307
+ if (!Number.isInteger(repo.depth) || repo.depth < 1) {
308
+ throw new SandboxSeedError(
309
+ 'invalid',
310
+ `${where}.depth ${JSON.stringify(repo.depth)} is not a positive integer`,
311
+ )
312
+ }
313
+ if (repo.commit !== undefined) {
314
+ throw new SandboxSeedError(
315
+ 'invalid',
316
+ `${where}.depth is set beside commit; a pinned commit is fetched with its history`,
317
+ )
318
+ }
319
+ }
320
+ const dir = refuseDir(repo.dir ?? repo.name, `${where}.dir`)
321
+ if (dirs.includes(dir)) {
322
+ throw new SandboxSeedError(
323
+ 'invalid',
324
+ `${where}.dir ${JSON.stringify(dir)} is used by another repository`,
325
+ )
326
+ }
327
+ // A clone creates its parent directories, so a repository inside
328
+ // another's directory would occupy it before that one is cloned.
329
+ const nested = dirs.find((other) => other.startsWith(`${dir}/`) || dir.startsWith(`${other}/`))
330
+ if (nested !== undefined) {
331
+ throw new SandboxSeedError(
332
+ 'invalid',
333
+ `${where}.dir ${JSON.stringify(dir)} and ${JSON.stringify(nested)} are nested; each repository needs a directory of its own, neither inside the other`,
334
+ )
335
+ }
336
+ dirs.push(dir)
337
+ return Object.freeze({
338
+ name: repo.name,
339
+ url,
340
+ ...(repo.ref !== undefined ? { ref: repo.ref } : {}),
341
+ ...(repo.commit !== undefined ? { commit: repo.commit } : {}),
342
+ ...(repo.dir !== undefined ? { dir: repo.dir } : {}),
343
+ ...(repo.depth !== undefined ? { depth: repo.depth } : {}),
344
+ })
345
+ })
346
+ return Object.freeze({ name: input.name, repositories: Object.freeze(repositories) })
347
+ }
348
+
349
+ /**
350
+ * A stable digest of the seed's content: SHA-256 over its normalised form,
351
+ * hex. Two seeds with the same repositories in the same order have the same
352
+ * digest whatever key order they were written in.
353
+ */
354
+ export function sandboxSeedDigest(seed: SandboxSeed): string {
355
+ const defined = defineSandboxSeed(seed)
356
+ const canonical = {
357
+ name: defined.name,
358
+ repositories: defined.repositories.map((repo) => [
359
+ repo.name,
360
+ repo.url,
361
+ repo.ref ?? null,
362
+ repo.commit ?? null,
363
+ repo.dir ?? repo.name,
364
+ repo.depth ?? null,
365
+ ]),
366
+ }
367
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
368
+ }
369
+
370
+ type Exec = (script: string, args: readonly string[]) => Promise<SandboxExecResult>
371
+
372
+ function tail(text: string): string {
373
+ const trimmed = text.trim()
374
+ return trimmed.length > 800 ? `…${trimmed.slice(-800)}` : trimmed
375
+ }
376
+
377
+ const PREFLIGHT = `
378
+ for tool in git find mkdir mktemp rm; do
379
+ command -v "$tool" >/dev/null 2>&1 || { printf 'missing %s\\n' "$tool"; exit 3; }
380
+ done
381
+ mkdir -p "$1/.namzu/seed" || { printf 'unwritable %s\\n' "$1"; exit 4; }
382
+ probe=$(mktemp -d "$1/.namzu/seed/probe.XXXXXX") || { printf 'unwritable %s\\n' "$1"; exit 4; }
383
+ mkdir "$probe/a"
384
+ if mv -T "$probe/a" "$probe/b" 2>/dev/null; then ok=1; else ok=0; fi
385
+ rm -rf "$probe"
386
+ [ "$ok" = 1 ] || { printf 'missing mv -T\\n'; exit 3; }
387
+ `
388
+
389
+ // Arguments, per repository: dir, url, expected commit ('' for none), ref to
390
+ // find in the repository when there is no expected commit ('' for none,
391
+ // DEFAULT_BRANCH for the remote's default branch), and '1' when the expected
392
+ // commit is a pin the marker has no record of, which HEAD must then equal
393
+ // exactly (the ancestor rule holds only for a pin this call recorded). A ref found that way must
394
+ // be exactly HEAD: sharing a line of history with HEAD is not holding it, since
395
+ // a clone deeper than 1 carries the tags in its history and a pinned commit's
396
+ // full clone carries every remote branch. Prints '<state> <head>', and for
397
+ // 'present' the commit it held HEAD to ('-' for none).
398
+ const CHECK = `
399
+ while [ "$#" -ge 5 ]; do
400
+ dir=$1; url=$2; want=$3; ref=$4; exact=$5; shift 5
401
+ if [ ! -e "$dir" ]; then printf 'missing -\\n'; continue; fi
402
+ if [ ! -e "$dir/.git" ]; then printf 'occupied -\\n'; continue; fi
403
+ origin=$(git -C "$dir" config --get remote.origin.url 2>/dev/null || true)
404
+ head=$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || printf -- '-')
405
+ if [ "$origin" != "$url" ]; then printf 'origin %s\\n' "$head"; continue; fi
406
+ if [ -z "$want" ] && [ -n "$ref" ]; then
407
+ if [ "$ref" = "${DEFAULT_BRANCH}" ]; then
408
+ tip=$(git -C "$dir" rev-parse --verify --quiet "refs/remotes/origin/HEAD^{commit}" 2>/dev/null || true)
409
+ else
410
+ tip=$(git -C "$dir" rev-parse --verify --quiet "refs/remotes/origin/$ref^{commit}" 2>/dev/null ||
411
+ git -C "$dir" rev-parse --verify --quiet "refs/tags/$ref^{commit}" 2>/dev/null || true)
412
+ fi
413
+ if [ -z "$tip" ] || [ "$tip" != "$head" ]; then printf 'ref %s\\n' "$head"; continue; fi
414
+ want=$tip
415
+ elif [ -n "$want" ] && [ "$exact" = 1 ]; then
416
+ if [ "$want" != "$head" ]; then printf 'pin %s\\n' "$head"; continue; fi
417
+ elif [ -n "$want" ] && ! git -C "$dir" merge-base --is-ancestor "$want" HEAD 2>/dev/null; then
418
+ printf 'history %s\\n' "$head"; continue
419
+ fi
420
+ printf 'present %s %s\\n' "$head" "\${want:--}"
421
+ done
422
+ `
423
+
424
+ // Arguments: dir, url, ref ('' for default), commit ('' for none), depth ('' for full), nonce.
425
+ const CLONE = `
426
+ set -e
427
+ dir=$1; url=$2; ref=$3; commit=$4; depth=$5; nonce=$6
428
+ mkdir -p "$(dirname "$dir")"
429
+ partial="$dir.namzu-partial-$nonce"
430
+ set -- clone --quiet
431
+ [ -n "$depth" ] && set -- "$@" --depth "$depth"
432
+ [ -n "$ref" ] && set -- "$@" --branch "$ref"
433
+ git -c credential.helper= "$@" -- "$url" "$partial"
434
+ if [ -n "$commit" ]; then git -C "$partial" -c advice.detachedHead=false checkout --quiet --detach "$commit"; fi
435
+ head=$(git -C "$partial" rev-parse HEAD)
436
+ if mv -T "$partial" "$dir" 2>/dev/null; then printf 'cloned %s\\n' "$head"; else rm -rf -- "$partial"; printf 'raced %s\\n' "$head"; fi
437
+ `
438
+
439
+ // Arguments: marker path, content, this call's nonce, then the parent
440
+ // directories to sweep. The temporary name carries the nonce, not the shell's
441
+ // PID: two sandboxes sharing one disk often run this as the same PID, each in
442
+ // its own PID namespace.
443
+ const FINISH = `
444
+ set -e
445
+ marker=$1; content=$2; nonce=$3; shift 3
446
+ tmp="$marker.tmp.$nonce"
447
+ if ! { printf '%s' "$content" > "$tmp" && mv -f -- "$tmp" "$marker"; }; then
448
+ rm -f -- "$tmp"
449
+ exit 1
450
+ fi
451
+ for parent in "$@"; do
452
+ [ -d "$parent" ] || continue
453
+ find "$parent" -mindepth 1 -maxdepth 1 -type d -name '*.namzu-partial-*' -mmin +${STALE_PARTIAL_MINUTES} -exec rm -rf -- {} + 2>/dev/null || true
454
+ done
455
+ `
456
+
457
+ interface Marker {
458
+ readonly commits: Readonly<Record<string, string>>
459
+ /** What each commit was resolved from: the `ref`, `''` for the default branch, `:commit` for a pin. */
460
+ readonly refs: Readonly<Record<string, string>>
461
+ }
462
+
463
+ /**
464
+ * The recorded commit of each repository whose record was made under the ref
465
+ * the seed names now. A record made under another ref says nothing about this
466
+ * one, so it is dropped and the repository is checked against the ref itself.
467
+ */
468
+ function readMarker(raw: string, seed: SandboxSeed): Map<string, string> {
469
+ const commits = new Map<string, string>()
470
+ let parsed: unknown
471
+ try {
472
+ parsed = JSON.parse(raw)
473
+ } catch {
474
+ return commits
475
+ }
476
+ const recorded = (parsed as Partial<Marker> | null)?.commits
477
+ const refs = (parsed as Partial<Marker> | null)?.refs
478
+ if (recorded === null || typeof recorded !== 'object') return commits
479
+ if (refs === null || typeof refs !== 'object') return commits
480
+ for (const repo of seed.repositories) {
481
+ const value = (recorded as Record<string, unknown>)[repo.name]
482
+ const ref = (refs as Record<string, unknown>)[repo.name]
483
+ if (ref !== recordedFrom(repo)) continue
484
+ // Guest-writable: a value that is not a commit id is ignored rather
485
+ // than handed to git, where it could be read as an option.
486
+ if (typeof value === 'string' && COMMIT_ID.test(value)) commits.set(repo.name, value)
487
+ }
488
+ return commits
489
+ }
490
+
491
+ /**
492
+ * The ref CHECK looks for in the repository: none when a commit is pinned or
493
+ * recorded, otherwise the seed's `ref` or, with none, the default branch.
494
+ */
495
+ function refToFind(repo: SandboxSeedRepository, want: string): string {
496
+ return want === '' ? (repo.ref ?? DEFAULT_BRANCH) : ''
497
+ }
498
+
499
+ function joinPath(root: string, dir: string): string {
500
+ return `${root.replace(/\/+$/, '')}/${dir}`
501
+ }
502
+
503
+ function parentOf(path: string): string {
504
+ return path.slice(0, path.lastIndexOf('/')) || '/'
505
+ }
506
+
507
+ /**
508
+ * Make the seed's repositories present under `options.root`, doing only what
509
+ * is missing, and report what was found. See the module doc for what is
510
+ * trusted, what is refused and what is never deleted.
511
+ */
512
+ export async function ensureSandboxSeed(
513
+ sandbox: Sandbox,
514
+ seed: SandboxSeed,
515
+ options: EnsureSandboxSeedOptions,
516
+ ): Promise<SandboxSeedReport> {
517
+ const defined = defineSandboxSeed(seed)
518
+ const root = options?.root
519
+ if (
520
+ typeof root !== 'string' ||
521
+ !root.startsWith('/') ||
522
+ root.split('/').includes('..') ||
523
+ /\s/.test(root)
524
+ ) {
525
+ throw new SandboxSeedError(
526
+ 'invalid',
527
+ `root ${JSON.stringify(root)} must be an absolute path inside the sandbox with no '..' and no whitespace; it is required because the right place depends on the backend (a kubernetes workspace's disk mount, layout.scratch on docker)`,
528
+ )
529
+ }
530
+ const onDrift = options.onDrift ?? 'refuse'
531
+ const digest = sandboxSeedDigest(defined)
532
+ const markerPath = joinPath(root, `.namzu/seed/${defined.name}.json`)
533
+
534
+ const exec: Exec = async (script, args) => {
535
+ options.signal?.throwIfAborted()
536
+ return await sandbox.exec('sh', ['-c', script, 'namzu-seed', ...args], {
537
+ env: { GIT_TERMINAL_PROMPT: '0' },
538
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
539
+ ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
540
+ })
541
+ }
542
+
543
+ // 1. The tools, by probe. A guest with no `sh` at all fails the exec itself.
544
+ let preflight: SandboxExecResult
545
+ try {
546
+ preflight = await exec(PREFLIGHT, [root])
547
+ } catch (error) {
548
+ options.signal?.throwIfAborted()
549
+ throw new SandboxSeedError(
550
+ 'tool-missing',
551
+ `could not run sh in the sandbox (${error instanceof Error ? error.message : String(error)}); the image needs sh, git, find, mkdir, mktemp, rm and GNU mv`,
552
+ )
553
+ }
554
+ if (preflight.exitCode !== 0) {
555
+ const line = preflight.stdout.trim()
556
+ const missing = line.startsWith('missing ') ? line.slice('missing '.length) : undefined
557
+ if (missing !== undefined || preflight.exitCode === 127) {
558
+ throw new SandboxSeedError(
559
+ 'tool-missing',
560
+ `the sandbox image has no ${missing ?? 'sh'}; it needs sh, git, find, mkdir, mktemp, rm and a mv that takes -T (GNU coreutils)`,
561
+ )
562
+ }
563
+ throw new SandboxSeedError(
564
+ 'invalid',
565
+ `root ${JSON.stringify(root)} is not writable in the sandbox: ${tail(preflight.stdout + preflight.stderr)}`,
566
+ )
567
+ }
568
+
569
+ // 2. What is there. The marker is read for the commits refs resolved to, and
570
+ // only for those; every repository is then checked directly.
571
+ const markerRead = await exec('cat -- "$1" 2>/dev/null || true', [markerPath])
572
+ const recorded = readMarker(markerRead.stdout, defined)
573
+ const paths = defined.repositories.map((repo) => joinPath(root, repo.dir ?? repo.name))
574
+ const expected = defined.repositories.map((repo) => repo.commit ?? recorded.get(repo.name) ?? '')
575
+ const checkArgs: string[] = []
576
+ defined.repositories.forEach((repo, index) => {
577
+ const want = expected[index] as string
578
+ const unrecordedPin = repo.commit !== undefined && recorded.get(repo.name) !== repo.commit
579
+ checkArgs.push(
580
+ paths[index] as string,
581
+ repo.url,
582
+ want,
583
+ refToFind(repo, want),
584
+ unrecordedPin ? '1' : '',
585
+ )
586
+ })
587
+ const checked = await exec(CHECK, checkArgs)
588
+ if (checked.exitCode !== 0) {
589
+ throw new SandboxSeedError(
590
+ 'invalid',
591
+ `could not inspect the seed root: ${tail(checked.stderr)}`,
592
+ )
593
+ }
594
+ const lines = checked.stdout.trim().split('\n')
595
+ const states = defined.repositories.map((repo, index) => {
596
+ const [state, head, held] = (lines[index] ?? '').split(' ')
597
+ if (!DRIFT_STATES.has(state ?? '') && state !== 'missing' && state !== 'present') {
598
+ throw new SandboxSeedError('invalid', `unreadable check result for ${repo.name}`, repo.name)
599
+ }
600
+ return { state, head: head ?? '-', held: held !== undefined && held !== '-' ? held : undefined }
601
+ })
602
+
603
+ // 3. Drift is refused before anything is cloned, so a refusal leaves the
604
+ // root exactly as it was found.
605
+ const drifted = defined.repositories.filter((_, index) =>
606
+ DRIFT_STATES.has(states[index]?.state ?? ''),
607
+ )
608
+ if (drifted.length > 0 && onDrift === 'refuse') {
609
+ throw new SandboxSeedError(
610
+ 'drift',
611
+ `${drifted
612
+ .map((repo) => {
613
+ const state = states[defined.repositories.indexOf(repo)]?.state ?? ''
614
+ return `${repo.name} ${DRIFT_STATES.get(state)}`
615
+ })
616
+ .join('; ')}. Nothing was changed; resolve it by hand, or pass onDrift: 'report'`,
617
+ drifted[0]?.name,
618
+ )
619
+ }
620
+
621
+ // 4. Clone what is missing, each into its own partial directory.
622
+ const reports: SandboxSeedRepositoryReport[] = []
623
+ const commits: Record<string, string> = {}
624
+ const refs: Record<string, string> = {}
625
+ const record = (repo: SandboxSeedRepository, commit: string): void => {
626
+ commits[repo.name] = commit
627
+ refs[repo.name] = recordedFrom(repo)
628
+ }
629
+ for (const [index, repo] of defined.repositories.entries()) {
630
+ const state = states[index] as { state: string; head: string; held: string | undefined }
631
+ const path = paths[index] as string
632
+ if (state.state === 'present') {
633
+ reports.push({ name: repo.name, status: 'present', commit: state.head })
634
+ record(repo, state.held ?? state.head)
635
+ continue
636
+ }
637
+ if (DRIFT_STATES.has(state.state)) {
638
+ reports.push({ name: repo.name, status: 'drifted', commit: state.head })
639
+ const known = recorded.get(repo.name)
640
+ if (known !== undefined) record(repo, known)
641
+ continue
642
+ }
643
+ const nonce = randomBytes(6).toString('hex')
644
+ const depth = repo.commit !== undefined ? '' : String(repo.depth ?? 1)
645
+ const cloned = await exec(CLONE, [
646
+ path,
647
+ repo.url,
648
+ repo.ref ?? '',
649
+ repo.commit ?? '',
650
+ depth,
651
+ nonce,
652
+ ])
653
+ const [outcome, head] = cloned.stdout.trim().split('\n').at(-1)?.split(' ') ?? []
654
+ if (
655
+ cloned.exitCode !== 0 ||
656
+ (outcome !== 'cloned' && outcome !== 'raced') ||
657
+ head === undefined
658
+ ) {
659
+ throw new SandboxSeedError(
660
+ 'clone-failed',
661
+ `cloning ${repo.name} from ${repo.url} failed (exit ${cloned.exitCode}${cloned.timedOut ? ', timed out' : ''}): ${tail(cloned.stderr)}`,
662
+ repo.name,
663
+ )
664
+ }
665
+ if (outcome === 'cloned') {
666
+ reports.push({ name: repo.name, status: 'cloned', commit: head })
667
+ record(repo, repo.commit ?? head)
668
+ continue
669
+ }
670
+ // Something took the directory while this call cloned: a peer moved
671
+ // its clone into place first. Ours is gone; check what is there the
672
+ // same way any present repository is checked, and say what it is.
673
+ const want = repo.commit ?? ''
674
+ const recheck = await exec(CHECK, [
675
+ path,
676
+ repo.url,
677
+ want,
678
+ refToFind(repo, want),
679
+ repo.commit !== undefined ? '1' : '',
680
+ ])
681
+ const [again, peerHead, held] = recheck.stdout.trim().split(' ')
682
+ if (again !== 'present') {
683
+ const what = DRIFT_STATES.get(again ?? '') ?? 'could not be checked'
684
+ if (onDrift === 'refuse') {
685
+ throw new SandboxSeedError(
686
+ 'drift',
687
+ `${repo.name} appeared while this call was cloning it, and ${what}. This call's own clone was removed; nothing else was changed`,
688
+ repo.name,
689
+ )
690
+ }
691
+ reports.push({ name: repo.name, status: 'drifted', commit: peerHead ?? '-' })
692
+ continue
693
+ }
694
+ reports.push({ name: repo.name, status: 'present', commit: peerHead ?? '-' })
695
+ record(repo, repo.commit ?? (held !== undefined && held !== '-' ? held : (peerHead ?? head)))
696
+ }
697
+
698
+ // 5. The marker, written whole and moved into place; then stale partials.
699
+ const content = JSON.stringify({ seed: defined.name, digest, commits, refs })
700
+ const parents = [...new Set(paths.map(parentOf))]
701
+ const finishNonce = randomBytes(6).toString('hex')
702
+ const finished = await exec(FINISH, [markerPath, content, finishNonce, ...parents])
703
+ if (finished.exitCode !== 0) {
704
+ throw new SandboxSeedError(
705
+ 'invalid',
706
+ `could not write the seed marker: ${tail(finished.stderr)}`,
707
+ )
708
+ }
709
+ return { digest, repositories: reports }
710
+ }