@magland/mochi 0.3.1

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 (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
@@ -0,0 +1,598 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.registerBackupApi = registerBackupApi;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const git_1 = require("../git");
41
+ const limit_1 = require("../limit");
42
+ const layout_1 = require("../layout");
43
+ const ops_1 = require("../ops");
44
+ const perms_1 = require("../perms");
45
+ const scan_1 = require("../scan");
46
+ const lfsstore_1 = require("../lfsstore");
47
+ const auth_1 = require("./auth");
48
+ // The two routes a backup needs, and nothing else.
49
+ //
50
+ // A vault is a directory, so most of it is copied by copying files. The half
51
+ // that already has a good incremental transport is the repositories: a mirror
52
+ // and `git fetch` move only the objects the far end lacks, over the anonymous
53
+ // smart-HTTP endpoint every clone already uses. What had no transport at all is
54
+ // everything beside the repositories - issues, pull requests, releases, sites,
55
+ // run history, LFS objects on the volume, and the state files at the vault root
56
+ // - which on a Fly volume cannot be reached without a shell.
57
+ //
58
+ // So: a manifest saying what is there, and a bulk read of named files. Both are
59
+ // deliberately dumb. There is no tar, no archive format, and no server-side
60
+ // notion of what a previous backup held; the client decides what it needs by
61
+ // comparing the manifest against what it already has, which is what keeps the
62
+ // server side to one file and the whole protocol inspectable with curl.
63
+ //
64
+ // See docs/backup.md for the client's half and for what a backup does not
65
+ // promise.
66
+ /** The state files at the vault root. Nothing else there belongs to a vault. */
67
+ const ROOT_FILES = ['vault.json', 'config.json', 'runners.json', 'redirects.json', '.secret'];
68
+ /** Which of those `--no-secrets` leaves out. config.json holds no credential. */
69
+ const SECRET_FILES = new Set(['vault.json', 'runners.json', '.secret']);
70
+ /**
71
+ * The files inside a bare repository that git's own transport leaves behind. A
72
+ * mirror clone carries objects and refs; it writes its own default description
73
+ * and its own config, so a backup that relied on it alone would lose every
74
+ * repository's description, its `mochi.forkedFrom`, and the `receive.*`
75
+ * settings a repository is created with. mochi.json is the worst of the
76
+ * three to lose: it holds the private flag and the collaborators, so a
77
+ * restore without it would serve every private repository as public.
78
+ */
79
+ const REPO_FILES = ['description', 'config', 'mochi.json'];
80
+ /** What a caller may ask to have left out, as `?exclude=runs,sites`. */
81
+ const EXCLUDABLE = new Set(['runs', 'sites', 'lfs', 'secrets']);
82
+ /** At most this many paths in one fetch, so a request cannot become a whole vault. */
83
+ const MAX_FETCH_PATHS = 2000;
84
+ /**
85
+ * At most this many bytes of file in one fetch. A request naming a single path
86
+ * is exempt, since otherwise a file larger than the cap could never be fetched
87
+ * at all; the cap is there to bound how much one request holds open, and one
88
+ * file is the smallest a request can be.
89
+ */
90
+ const MAX_FETCH_BYTES = 64 * 1024 * 1024;
91
+ /** A file writeFileAtomic is in the middle of writing. Not part of the vault yet. */
92
+ function isTempName(name) {
93
+ return /\.tmp-\d+$/.test(name);
94
+ }
95
+ /**
96
+ * A file's SHA-256, read in chunks. Bounded memory rather than one Buffer per
97
+ * file: this runs on a 512mb machine, and an LFS object on the volume can be
98
+ * gigabytes, so reading a whole file to hash it would be the one place a backup
99
+ * could take the vault down.
100
+ */
101
+ function sha256File(file) {
102
+ const h = crypto.createHash('sha256');
103
+ const fd = fs.openSync(file, 'r');
104
+ try {
105
+ const buf = Buffer.allocUnsafe(1 << 16);
106
+ for (;;) {
107
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
108
+ if (n === 0)
109
+ break;
110
+ h.update(buf.subarray(0, n));
111
+ }
112
+ }
113
+ finally {
114
+ fs.closeSync(fd);
115
+ }
116
+ return h.digest('hex');
117
+ }
118
+ /**
119
+ * Write, waiting for the socket when it is full. A vault with a hundred
120
+ * thousand files produces a manifest larger than any socket buffer, and the
121
+ * point of streaming it is that the 512mb machine serving it never holds the
122
+ * whole thing.
123
+ */
124
+ async function send(res, chunk) {
125
+ if (res.writableEnded || res.destroyed)
126
+ return false;
127
+ if (res.write(chunk))
128
+ return true;
129
+ await new Promise((resolve) => {
130
+ const done = () => {
131
+ res.off('drain', done);
132
+ res.off('close', done);
133
+ resolve();
134
+ };
135
+ res.once('drain', done);
136
+ res.once('close', done);
137
+ });
138
+ return !res.writableEnded && !res.destroyed;
139
+ }
140
+ /**
141
+ * The vault-relative path a caller named, as an absolute path, or null if it is
142
+ * not a path inside the vault. Refused rather than normalized: a caller sending
143
+ * `..` or an absolute path has a bug, and answering it with some other file
144
+ * would hide the bug rather than the file.
145
+ *
146
+ * Containment is re-checked against the real path when the file is opened,
147
+ * which is what catches a symlink pointing out of the vault.
148
+ */
149
+ function vaultPath(root, p) {
150
+ if (typeof p !== 'string' || p === '' || p.length > 1024)
151
+ return null;
152
+ if (p.includes('\\') || p.includes('\0') || p.startsWith('/'))
153
+ return null;
154
+ const segments = p.split('/');
155
+ if (segments.some((s) => s === '' || s === '.' || s === '..'))
156
+ return null;
157
+ return path.join(root, ...segments);
158
+ }
159
+ /** One `kind:"file"` line, or null for something that is not a file of the vault. */
160
+ function fileLine(root, abs, opts) {
161
+ let st;
162
+ try {
163
+ st = fs.lstatSync(abs);
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ // A symlink is not copied. A vault does not contain any, and following one
169
+ // would make the backup's shape depend on what it points at.
170
+ if (!st.isFile())
171
+ return null;
172
+ const rel = path.relative(root, abs).split(path.sep).join('/');
173
+ const line = {
174
+ kind: 'file',
175
+ path: rel,
176
+ size: st.size,
177
+ mtime: Math.floor(st.mtimeMs),
178
+ mode: st.mode & 0o777,
179
+ };
180
+ if (opts.hash) {
181
+ try {
182
+ line.sha256 = sha256File(abs);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ }
188
+ return JSON.stringify(line);
189
+ }
190
+ /** How many bytes a bare repository occupies, as git already counts it. */
191
+ async function repoBytes(dir) {
192
+ try {
193
+ const out = (await (0, git_1.execGit)(dir, ['count-objects', '-v'])).toString();
194
+ let kib = 0;
195
+ for (const line of out.split('\n')) {
196
+ const m = line.match(/^(size|size-pack):\s*(\d+)/);
197
+ if (m)
198
+ kib += parseInt(m[2], 10);
199
+ }
200
+ return kib * 1024;
201
+ }
202
+ catch {
203
+ return 0;
204
+ }
205
+ }
206
+ /**
207
+ * A digest over every ref, what it points at, and where HEAD points, which
208
+ * changes on any push and on nothing else. A repository whose digest a client
209
+ * already has is one it can skip without a handshake, and skipping is what keeps
210
+ * a nightly backup of a hundred quiet repositories to a single request.
211
+ *
212
+ * HEAD is in the digest because it is the default branch, and changing it moves
213
+ * no ref at all: a digest over the refs alone would let a repository whose
214
+ * default branch was changed be skipped forever, and the backup would keep
215
+ * naming the old one. It is read from the file rather than asked of git, since
216
+ * this runs once per repository per manifest.
217
+ */
218
+ async function refsDigest(dir) {
219
+ const out = await (0, git_1.execGit)(dir, ['for-each-ref', '--format=%(refname) %(objectname)']);
220
+ let head = '';
221
+ try {
222
+ head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim();
223
+ }
224
+ catch {
225
+ // A repository with no readable HEAD is odd but not this function's problem.
226
+ }
227
+ return crypto.createHash('sha256').update(out).update(`HEAD ${head}\n`).digest('hex');
228
+ }
229
+ function registerBackupApi(app, root, limiter, gates) {
230
+ // The manifest necessarily names vault.json and .secret, and a fetch will
231
+ // hand over their contents, so nothing narrower than site admin is enough.
232
+ // A restricted (token-scoped) token is refused by isSiteAdmin whatever its
233
+ // user's standing, which is the behaviour wanted here.
234
+ function requireVaultAdmin(req, res) {
235
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
236
+ if (!auth)
237
+ return null;
238
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
239
+ (0, auth_1.apiError)(res, 403, 'a backup needs a site admin, with an unrestricted token');
240
+ return null;
241
+ }
242
+ return auth;
243
+ }
244
+ function sendBusy(res) {
245
+ res.setHeader('Retry-After', String(limit_1.BUSY_RETRY_SECONDS));
246
+ (0, auth_1.apiError)(res, 503, 'the vault is busy; try again shortly');
247
+ }
248
+ function exclusions(req, res) {
249
+ const raw = typeof req.query.exclude === 'string' ? req.query.exclude : '';
250
+ const names = raw
251
+ .split(',')
252
+ .map((s) => s.trim())
253
+ .filter((s) => s.length > 0);
254
+ const unknown = names.filter((n) => !EXCLUDABLE.has(n));
255
+ if (unknown.length) {
256
+ (0, auth_1.apiError)(res, 400, `unknown exclusion${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}; one of: ${[...EXCLUDABLE].join(', ')}`);
257
+ return null;
258
+ }
259
+ return new Set(names);
260
+ }
261
+ app.get('/api/backup/manifest', async (req, res) => {
262
+ if (!requireVaultAdmin(req, res))
263
+ return;
264
+ const exclude = exclusions(req, res);
265
+ if (!exclude)
266
+ return;
267
+ const opts = { hash: req.query.hash === '1', exclude };
268
+ // The same gate a file listing and a source archive hold, so that a backup
269
+ // in progress cannot crowd out a push.
270
+ const release = await gates.tree.enter();
271
+ if (!release) {
272
+ sendBusy(res);
273
+ return;
274
+ }
275
+ res.type('application/x-ndjson');
276
+ // A manifest is a walk of a live tree and is never worth a cache.
277
+ res.set('Cache-Control', 'no-store');
278
+ const counts = { files: 0, bytes: 0, repos: 0 };
279
+ try {
280
+ // Which LFS backend is live is decided from the environment, so the
281
+ // client cannot infer it from the vault's files. A vault using a bucket
282
+ // has objects that are not in the vault at all, and a backup that did not
283
+ // say so would look complete while missing them.
284
+ let lfs = 'volume';
285
+ try {
286
+ lfs = (0, lfsstore_1.createLfsStore)(root).store.kind === 's3' ? 'bucket' : 'volume';
287
+ }
288
+ catch {
289
+ // A partially configured bucket throws at startup, so a serving vault
290
+ // never reaches this; report the honest "unknown" if it somehow does.
291
+ lfs = 'unknown';
292
+ }
293
+ if (!(await send(res, JSON.stringify({ kind: 'vault', lfs, excluded: [...exclude] }) + '\n')))
294
+ return;
295
+ for (const name of ROOT_FILES) {
296
+ if (exclude.has('secrets') && SECRET_FILES.has(name))
297
+ continue;
298
+ const line = fileLine(root, path.join(root, name), opts);
299
+ if (!line)
300
+ continue;
301
+ counts.files++;
302
+ counts.bytes += JSON.parse(line).size;
303
+ if (!(await send(res, line + '\n')))
304
+ return;
305
+ }
306
+ const walkFiles = async (dir) => {
307
+ let entries;
308
+ try {
309
+ entries = fs.readdirSync(dir, { withFileTypes: true });
310
+ }
311
+ catch {
312
+ return true;
313
+ }
314
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
315
+ if (e.isSymbolicLink() || isTempName(e.name))
316
+ continue;
317
+ const abs = path.join(dir, e.name);
318
+ if (e.isDirectory()) {
319
+ if (!(await walkFiles(abs)))
320
+ return false;
321
+ continue;
322
+ }
323
+ const line = fileLine(root, abs, opts);
324
+ if (!line)
325
+ continue;
326
+ counts.files++;
327
+ counts.bytes += JSON.parse(line).size;
328
+ if (!(await send(res, line + '\n')))
329
+ return false;
330
+ }
331
+ return true;
332
+ };
333
+ let collections;
334
+ try {
335
+ collections = fs.readdirSync((0, layout_1.collectionsDir)(root), { withFileTypes: true });
336
+ }
337
+ catch {
338
+ collections = [];
339
+ }
340
+ for (const c of collections.sort((a, b) => a.name.localeCompare(b.name))) {
341
+ if (!c.isDirectory() || c.isSymbolicLink() || !(0, scan_1.isValidName)(c.name))
342
+ continue;
343
+ // Whatever the collection keeps of its own, beside its repositories.
344
+ // There is nothing there today; it is walked as ordinary files so that
345
+ // the first thing put there is backed up without this being revisited.
346
+ let ownEntries;
347
+ try {
348
+ ownEntries = fs.readdirSync((0, layout_1.collectionDir)(root, c.name), { withFileTypes: true });
349
+ }
350
+ catch {
351
+ ownEntries = [];
352
+ }
353
+ for (const e of ownEntries.sort((a, b) => a.name.localeCompare(b.name))) {
354
+ if (e.name === layout_1.REPOS_DIR || e.isSymbolicLink() || isTempName(e.name))
355
+ continue;
356
+ const abs = path.join((0, layout_1.collectionDir)(root, c.name), e.name);
357
+ if (e.isDirectory()) {
358
+ if (!(await walkFiles(abs)))
359
+ return;
360
+ continue;
361
+ }
362
+ const line = fileLine(root, abs, opts);
363
+ if (!line)
364
+ continue;
365
+ counts.files++;
366
+ counts.bytes += JSON.parse(line).size;
367
+ if (!(await send(res, line + '\n')))
368
+ return;
369
+ }
370
+ const repos = (0, layout_1.reposDir)(root, c.name);
371
+ let entries;
372
+ try {
373
+ entries = fs.readdirSync(repos, { withFileTypes: true });
374
+ }
375
+ catch {
376
+ continue;
377
+ }
378
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
379
+ if (e.isSymbolicLink() || isTempName(e.name))
380
+ continue;
381
+ const abs = path.join(repos, e.name);
382
+ const rel = `${layout_1.COLLECTIONS_DIR}/${c.name}/${layout_1.REPOS_DIR}/${e.name}`;
383
+ if (!e.isDirectory()) {
384
+ const line = fileLine(root, abs, opts);
385
+ if (!line)
386
+ continue;
387
+ counts.files++;
388
+ counts.bytes += JSON.parse(line).size;
389
+ if (!(await send(res, line + '\n')))
390
+ return;
391
+ continue;
392
+ }
393
+ // A repository's contents are not enumerated: git is their
394
+ // transport, and listing a hundred thousand loose objects as files
395
+ // would be both enormous and the wrong way to move them.
396
+ if ((0, scan_1.isValidName)(e.name) && (0, scan_1.isBareRepo)(abs)) {
397
+ let refs;
398
+ try {
399
+ refs = await refsDigest(abs);
400
+ }
401
+ catch {
402
+ // A directory that looks like a repository but cannot be read is
403
+ // reported as nothing rather than failing the whole manifest.
404
+ continue;
405
+ }
406
+ counts.repos++;
407
+ const line = JSON.stringify({
408
+ kind: 'repo',
409
+ path: rel,
410
+ collection: c.name,
411
+ repo: e.name.replace(/\.git$/, ''),
412
+ refs,
413
+ packed: await repoBytes(abs),
414
+ });
415
+ if (!(await send(res, line + '\n')))
416
+ return;
417
+ // A few files inside the repository are named all the same,
418
+ // because a mirror clone does not carry them and they are not git
419
+ // data: the description, which every listing shows; the config,
420
+ // which holds the fork parent and the receive protections a
421
+ // repository was created with; and mochi.json, which holds
422
+ // the private flag and the collaborators. Restoring a vault whose
423
+ // private repositories had come back public would be far worse
424
+ // than a poor restore.
425
+ for (const inside of REPO_FILES) {
426
+ const fileEntry = fileLine(root, path.join(abs, inside), opts);
427
+ if (!fileEntry)
428
+ continue;
429
+ counts.files++;
430
+ counts.bytes += JSON.parse(fileEntry).size;
431
+ if (!(await send(res, fileEntry + '\n')))
432
+ return;
433
+ }
434
+ continue;
435
+ }
436
+ if (exclude.has('runs') && e.name.endsWith('.runs'))
437
+ continue;
438
+ if (exclude.has('sites') && e.name.endsWith('.site'))
439
+ continue;
440
+ if (exclude.has('lfs') && e.name.endsWith('.lfs'))
441
+ continue;
442
+ if (!(await walkFiles(abs)))
443
+ return;
444
+ }
445
+ }
446
+ await send(res, JSON.stringify({ kind: 'end', ...counts }) + '\n');
447
+ res.end();
448
+ }
449
+ catch (e) {
450
+ // The status is long gone by the time a walk fails, so the failure is
451
+ // reported in the stream: a client that never saw an "end" line knows the
452
+ // manifest is incomplete, and this says why.
453
+ console.error(e);
454
+ await send(res, JSON.stringify({ kind: 'error', error: 'the manifest could not be completed' }) + '\n');
455
+ res.end();
456
+ }
457
+ finally {
458
+ release();
459
+ }
460
+ });
461
+ // The bytes of the paths named, as a length-prefixed sequence.
462
+ //
463
+ // Not a tar. A length-prefixed stream needs no tar on either side, has no
464
+ // symlink, ownership, or path-traversal edge cases to get wrong, and lets a
465
+ // file that vanished between the manifest and the fetch be reported in the
466
+ // end line rather than aborting the transfer. That last case is not
467
+ // hypothetical: run history is trimmed by CI retention while a backup of it
468
+ // is in flight.
469
+ app.post('/api/backup/fetch', async (req, res) => {
470
+ if (!requireVaultAdmin(req, res))
471
+ return;
472
+ const body = (req.body ?? {});
473
+ const paths = body.paths;
474
+ if (!Array.isArray(paths) || paths.length === 0) {
475
+ (0, auth_1.apiError)(res, 400, '"paths" must be a non-empty list of vault-relative paths');
476
+ return;
477
+ }
478
+ if (paths.length > MAX_FETCH_PATHS) {
479
+ (0, auth_1.apiError)(res, 400, `at most ${MAX_FETCH_PATHS} paths per request; ask for fewer`);
480
+ return;
481
+ }
482
+ const absolute = [];
483
+ for (const p of paths) {
484
+ const abs = vaultPath(root, p);
485
+ if (!abs) {
486
+ (0, auth_1.apiError)(res, 400, `not a path inside the vault: ${typeof p === 'string' ? p : typeof p}`);
487
+ return;
488
+ }
489
+ absolute.push(abs);
490
+ }
491
+ let rootReal;
492
+ try {
493
+ rootReal = fs.realpathSync(root);
494
+ }
495
+ catch {
496
+ (0, auth_1.apiError)(res, 500, 'the vault directory could not be read');
497
+ return;
498
+ }
499
+ // Sized before anything is sent, so that a request over the cap is a 400
500
+ // naming the limit rather than a truncated stream. A path that has since
501
+ // vanished simply weighs nothing and is reported as missing below.
502
+ let total = 0;
503
+ for (const abs of absolute) {
504
+ try {
505
+ total += fs.statSync(abs).size;
506
+ }
507
+ catch {
508
+ // missing; reported in the end line
509
+ }
510
+ }
511
+ if (absolute.length > 1 && total > MAX_FETCH_BYTES) {
512
+ (0, auth_1.apiError)(res, 400, `the paths named come to ${total} bytes, over the ${MAX_FETCH_BYTES} byte limit for one request; ask for fewer`);
513
+ return;
514
+ }
515
+ const release = await gates.tree.enter();
516
+ if (!release) {
517
+ sendBusy(res);
518
+ return;
519
+ }
520
+ res.type('application/octet-stream');
521
+ res.set('Cache-Control', 'no-store');
522
+ const missing = [];
523
+ try {
524
+ for (let i = 0; i < absolute.length; i++) {
525
+ const abs = absolute[i];
526
+ const rel = paths[i];
527
+ // The size is taken from the open descriptor rather than from a stat
528
+ // before it, so the length prefix cannot disagree with the bytes that
529
+ // follow. Everything in a vault is written by rename, so an open
530
+ // descriptor's contents no longer change.
531
+ let fd;
532
+ try {
533
+ const st = fs.lstatSync(abs);
534
+ if (!st.isFile())
535
+ throw new Error('not a file');
536
+ fd = fs.openSync(abs, 'r');
537
+ }
538
+ catch {
539
+ missing.push(rel);
540
+ continue;
541
+ }
542
+ let size;
543
+ try {
544
+ size = fs.fstatSync(fd).size;
545
+ // Checked with the descriptor in hand: a symlink that was swapped in
546
+ // between the lstat and the open resolves here, not there.
547
+ if (!(0, ops_1.containedIn)(rootReal, abs))
548
+ throw new Error('outside the vault');
549
+ }
550
+ catch {
551
+ fs.closeSync(fd);
552
+ missing.push(rel);
553
+ continue;
554
+ }
555
+ if (!(await send(res, JSON.stringify({ path: rel, size }) + '\n'))) {
556
+ fs.closeSync(fd);
557
+ return;
558
+ }
559
+ let sent = 0;
560
+ const stream = fs.createReadStream('', { fd, autoClose: true, highWaterMark: 1 << 20 });
561
+ try {
562
+ for await (const chunk of stream) {
563
+ const buf = chunk;
564
+ // Never more than the declared length, whatever the file does.
565
+ const room = size - sent;
566
+ const piece = buf.length > room ? buf.subarray(0, room) : buf;
567
+ if (piece.length === 0)
568
+ break;
569
+ sent += piece.length;
570
+ if (!(await send(res, piece))) {
571
+ stream.destroy();
572
+ return;
573
+ }
574
+ }
575
+ }
576
+ catch {
577
+ // A read that failed part way leaves the frame short, which no client
578
+ // can recover from, so the stream ends here rather than lying.
579
+ stream.destroy();
580
+ res.end();
581
+ return;
582
+ }
583
+ // A file truncated after it was opened would otherwise leave the frame
584
+ // short. Padding keeps the framing honest; the client's next run sees
585
+ // the new size and fetches it again.
586
+ if (sent < size) {
587
+ if (!(await send(res, Buffer.alloc(size - sent))))
588
+ return;
589
+ }
590
+ }
591
+ await send(res, JSON.stringify({ end: true, missing }) + '\n');
592
+ res.end();
593
+ }
594
+ finally {
595
+ release();
596
+ }
597
+ });
598
+ }