@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.
- package/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- 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
|
+
}
|