@vincentt-xr/cli 0.20.0 → 0.22.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.
- package/MIGRATION.md +30 -0
- package/dist/cli/index.js +86 -12
- package/dist/domain/api.js +16 -12
- package/dist/domain/command.js +9 -0
- package/dist/dx/command.d.ts +74 -18
- package/dist/dx/command.js +140 -86
- package/dist/dx/consent.d.ts +25 -6
- package/dist/dx/consent.js +64 -35
- package/dist/dx/enablement.d.ts +63 -11
- package/dist/dx/enablement.js +93 -21
- package/dist/dx/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- package/dist/dx/reportIo.js +6 -4
- package/dist/dx/send.js +6 -4
- package/dist/lib/backend.js +5 -10
- package/dist/lib/exitCodes.d.ts +6 -0
- package/dist/lib/exitCodes.js +6 -0
- package/dist/lib/terminalText.d.ts +6 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.d.ts +28 -2
- package/dist/lib/version.js +30 -3
- package/dist/lib/whoami.js +3 -0
- package/dist/library/assetsCommand.js +4 -0
- package/dist/library/client.js +6 -8
- package/dist/login/authBackend.js +18 -15
- package/dist/login/loginCommand.js +31 -4
- package/dist/login/pkceLogin.d.ts +10 -0
- package/dist/login/pkceLogin.js +24 -1
- package/dist/platform/releaseFetch.js +36 -69
- package/dist/preview/api.js +9 -12
- package/dist/preview/command.d.ts +3 -2
- package/dist/preview/command.js +10 -5
- package/dist/preview/copy.d.ts +13 -1
- package/dist/preview/copy.js +31 -3
- package/dist/preview/proxy.js +5 -39
- package/dist/preview/runner.d.ts +31 -9
- package/dist/preview/runner.js +43 -15
- package/dist/publish/api.js +14 -14
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +74 -7
- package/dist/publish/copy.d.ts +15 -0
- package/dist/publish/copy.js +109 -19
- package/dist/publish/keyScan.d.ts +25 -0
- package/dist/publish/keyScan.js +88 -0
- package/dist/publish/manifest.d.ts +60 -19
- package/dist/publish/manifest.js +290 -71
- package/dist/publish/upload.d.ts +18 -0
- package/dist/publish/upload.js +38 -0
- package/dist/publish/weight.d.ts +23 -0
- package/dist/publish/weight.js +113 -0
- package/dist/shared/config.d.ts +6 -6
- package/dist/shared/credentialNames.d.ts +17 -0
- package/dist/shared/credentialNames.js +72 -0
- package/dist/shared/http.d.ts +34 -0
- package/dist/shared/http.js +98 -2
- package/dist/shared/upgradeError.d.ts +62 -0
- package/dist/shared/upgradeError.js +215 -0
- package/dist/tunnel/caps.d.ts +19 -0
- package/dist/tunnel/caps.js +52 -0
- package/dist/tunnel/cbor.d.ts +39 -12
- package/dist/tunnel/cbor.js +84 -10
- package/dist/tunnel/client.d.ts +56 -2
- package/dist/tunnel/client.js +160 -44
- package/dist/tunnel/frame.d.ts +1 -1
- package/dist/tunnel/frame.js +8 -4
- package/dist/tunnel/index.d.ts +3 -2
- package/dist/tunnel/index.js +3 -2
- package/package.json +4 -3
package/dist/publish/manifest.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// every single PUT while both repos' unit suites stay green (§2.7's first
|
|
6
6
|
// contract trap).
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { PUBLISH_CREDENTIAL_NAMES, PUBLISH_STRIP, foldName, isCredentialName, } from "../shared/credentialNames.js";
|
|
11
|
+
import { createKeyScanner, credentialJson } from "./keyScan.js";
|
|
10
12
|
import { normalizePath, validatePathSet } from "./paths.js";
|
|
11
13
|
export class ManifestError extends Error {
|
|
12
14
|
reason;
|
|
@@ -27,6 +29,13 @@ const ALLOWED_ROOT_DIR = ".well-known";
|
|
|
27
29
|
const ALLOWED_ROOT_FILE = ".nojekyll";
|
|
28
30
|
const LFS_POINTER_MAX_BYTES = 1024;
|
|
29
31
|
const LFS_FIRST_LINE = "version https://git-lfs.github.com/spec/v1";
|
|
32
|
+
/** The early pass reads this much of every file. */
|
|
33
|
+
const HEAD_READ_BYTES = 4096;
|
|
34
|
+
/** A `.json` at most this size is read whole and parsed. */
|
|
35
|
+
const CREDENTIAL_JSON_MAX_BYTES = 65_536;
|
|
36
|
+
const READ_CONCURRENCY = 16;
|
|
37
|
+
const HASH_CHUNK_BYTES = 64 * 1024;
|
|
38
|
+
const OPEN_NOFOLLOW = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
|
30
39
|
function isInside(child, parent) {
|
|
31
40
|
const rel = path.relative(parent, child);
|
|
32
41
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
@@ -34,20 +43,35 @@ function isInside(child, parent) {
|
|
|
34
43
|
function hasDottedSegment(rel) {
|
|
35
44
|
return rel.split(/[\\/]/).some((seg) => seg.startsWith("."));
|
|
36
45
|
}
|
|
46
|
+
function isPublishCredentialName(name) {
|
|
47
|
+
return isCredentialName(foldName(name), PUBLISH_CREDENTIAL_NAMES, PUBLISH_STRIP);
|
|
48
|
+
}
|
|
49
|
+
function isJsonName(rel) {
|
|
50
|
+
return rel.toLowerCase().endsWith(".json");
|
|
51
|
+
}
|
|
37
52
|
/**
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
40
|
-
*
|
|
53
|
+
* The one walk. It records what it does not upload: a dotted name is an
|
|
54
|
+
* offender (a folder once, with a trailing slash, never descended), every
|
|
55
|
+
* symlink is resolved and judged against the real repo root, and a name on the
|
|
56
|
+
* credential list is recorded apart (a folder once, never descended).
|
|
41
57
|
*/
|
|
42
|
-
async function walkOutput(outReal, rootReal) {
|
|
43
|
-
const
|
|
44
|
-
|
|
58
|
+
async function walkOutput(outReal, rootReal, hooks) {
|
|
59
|
+
const walk = { files: [], offenders: [], credentialNames: [] };
|
|
60
|
+
let seq = 0;
|
|
61
|
+
const relOf = (full) => path.relative(outReal, full).split(path.sep).join("/");
|
|
62
|
+
// A read error's message carries the raw absolute path. It becomes a refusal
|
|
63
|
+
// naming the output-relative path, which the copy escapes.
|
|
64
|
+
const unreadable = (rel) => new ManifestError("file_unreadable", rel, "a path could not be read");
|
|
45
65
|
const visit = async (dir) => {
|
|
46
|
-
|
|
66
|
+
await hooks.beforeReadDir?.(dir);
|
|
67
|
+
const dirRel = dir === outReal ? "" : `${relOf(dir)}/`;
|
|
68
|
+
const entries = await fs
|
|
69
|
+
.readdir(dir, { withFileTypes: true })
|
|
70
|
+
.catch(() => Promise.reject(unreadable(dirRel)));
|
|
47
71
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
48
72
|
for (const entry of entries) {
|
|
49
73
|
const full = path.join(dir, entry.name);
|
|
50
|
-
const rel =
|
|
74
|
+
const rel = relOf(full);
|
|
51
75
|
const atRoot = dir === outReal;
|
|
52
76
|
const lower = entry.name.toLowerCase();
|
|
53
77
|
if (entry.name.startsWith(".")) {
|
|
@@ -55,17 +79,17 @@ async function walkOutput(outReal, rootReal) {
|
|
|
55
79
|
((lower === ALLOWED_ROOT_DIR && entry.isDirectory()) ||
|
|
56
80
|
(lower === ALLOWED_ROOT_FILE && entry.isFile()));
|
|
57
81
|
if (!allowed) {
|
|
58
|
-
offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
|
|
82
|
+
walk.offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
|
|
59
83
|
continue;
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
if (entry.isSymbolicLink()) {
|
|
63
87
|
const target = await symlinkTarget(full);
|
|
64
88
|
if (!isInside(target, rootReal)) {
|
|
65
|
-
offenders.push({ path: rel, target });
|
|
89
|
+
walk.offenders.push({ path: rel, target });
|
|
66
90
|
}
|
|
67
91
|
else if (hasDottedSegment(path.relative(rootReal, target))) {
|
|
68
|
-
offenders.push({
|
|
92
|
+
walk.offenders.push({
|
|
69
93
|
path: rel,
|
|
70
94
|
target: path.relative(rootReal, target).split(path.sep).join("/"),
|
|
71
95
|
});
|
|
@@ -73,17 +97,56 @@ async function walkOutput(outReal, rootReal) {
|
|
|
73
97
|
// A symlink that resolves cleanly inside is not uploaded, as before.
|
|
74
98
|
continue;
|
|
75
99
|
}
|
|
100
|
+
const nameMatched = isPublishCredentialName(entry.name);
|
|
76
101
|
if (entry.isDirectory()) {
|
|
102
|
+
if (nameMatched) {
|
|
103
|
+
walk.credentialNames.push({
|
|
104
|
+
seq: seq++,
|
|
105
|
+
offender: { path: `${rel}/`, why: "name" },
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
77
109
|
await visit(full);
|
|
78
110
|
}
|
|
79
111
|
else if (entry.isFile()) {
|
|
80
|
-
const stat = await fs.
|
|
81
|
-
|
|
112
|
+
const stat = await fs.lstat(full).catch(() => Promise.reject(unreadable(rel)));
|
|
113
|
+
const at = seq++;
|
|
114
|
+
if (nameMatched) {
|
|
115
|
+
walk.credentialNames.push({ seq: at, offender: { path: rel, why: "name" } });
|
|
116
|
+
}
|
|
117
|
+
walk.files.push({
|
|
118
|
+
rel,
|
|
119
|
+
abs: full,
|
|
120
|
+
size: stat.size,
|
|
121
|
+
dev: stat.dev,
|
|
122
|
+
ino: stat.ino,
|
|
123
|
+
seq: at,
|
|
124
|
+
nameMatched,
|
|
125
|
+
});
|
|
82
126
|
}
|
|
83
127
|
}
|
|
84
128
|
};
|
|
85
129
|
await visit(outReal);
|
|
86
|
-
return
|
|
130
|
+
return walk;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Files whose real parent folder is not inside the real output. The walk reads
|
|
134
|
+
* a folder by path, so a folder swapped for a symlink mid-walk is walked through
|
|
135
|
+
* the link; this catches it after the fact. A parent that cannot be resolved
|
|
136
|
+
* counts as escaped: it is never assumed inside.
|
|
137
|
+
*/
|
|
138
|
+
async function escapedFiles(files, outReal) {
|
|
139
|
+
const parents = new Map();
|
|
140
|
+
const inside = (dir) => {
|
|
141
|
+
let p = parents.get(dir);
|
|
142
|
+
if (p === undefined) {
|
|
143
|
+
p = fs.realpath(dir).then((real) => real === outReal || isInside(real, outReal), () => false);
|
|
144
|
+
parents.set(dir, p);
|
|
145
|
+
}
|
|
146
|
+
return p;
|
|
147
|
+
};
|
|
148
|
+
const verdicts = await mapLimit(files, READ_CONCURRENCY, (f) => inside(path.dirname(f.abs)));
|
|
149
|
+
return files.filter((_, i) => !verdicts[i]).map((f) => ({ path: f.rel }));
|
|
87
150
|
}
|
|
88
151
|
/** Where a symlink lands: its realpath, or its lexical target when it dangles. */
|
|
89
152
|
async function symlinkTarget(link) {
|
|
@@ -96,19 +159,93 @@ async function symlinkTarget(link) {
|
|
|
96
159
|
}
|
|
97
160
|
}
|
|
98
161
|
/** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
|
|
99
|
-
export
|
|
162
|
+
export function isLfsPointer(head, size) {
|
|
100
163
|
if (size > LFS_POINTER_MAX_BYTES)
|
|
101
164
|
return false;
|
|
102
|
-
const
|
|
103
|
-
|
|
165
|
+
const lines = Buffer.from(head.buffer, head.byteOffset, head.byteLength)
|
|
166
|
+
.toString("utf8")
|
|
167
|
+
.split("\n");
|
|
104
168
|
if (lines[0] !== LFS_FIRST_LINE)
|
|
105
169
|
return false;
|
|
106
170
|
return lines.some((l) => l.startsWith("oid sha256:"));
|
|
107
171
|
}
|
|
172
|
+
class UnreadableFile extends Error {
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Open a walked file without following a link, and confirm it is still the
|
|
176
|
+
* regular file the walk saw: the same device and inode before and after the
|
|
177
|
+
* open. Anything else throws UnreadableFile.
|
|
178
|
+
*/
|
|
179
|
+
async function openWalked(file) {
|
|
180
|
+
let handle;
|
|
181
|
+
try {
|
|
182
|
+
const before = await fs.lstat(file.abs);
|
|
183
|
+
if (!before.isFile() || before.dev !== file.dev || before.ino !== file.ino) {
|
|
184
|
+
throw new UnreadableFile();
|
|
185
|
+
}
|
|
186
|
+
handle = await fs.open(file.abs, OPEN_NOFOLLOW);
|
|
187
|
+
const after = await handle.stat();
|
|
188
|
+
if (!after.isFile() || after.dev !== file.dev || after.ino !== file.ino) {
|
|
189
|
+
throw new UnreadableFile();
|
|
190
|
+
}
|
|
191
|
+
return handle;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
await handle?.close().catch(() => undefined);
|
|
195
|
+
throw new UnreadableFile();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Exactly `length` bytes from offset 0; a short read is unreadable. */
|
|
199
|
+
async function readExactly(handle, length) {
|
|
200
|
+
const buf = Buffer.alloc(length);
|
|
201
|
+
let got = 0;
|
|
202
|
+
while (got < length) {
|
|
203
|
+
const { bytesRead } = await handle.read(buf, got, length - got, got);
|
|
204
|
+
if (bytesRead === 0)
|
|
205
|
+
throw new UnreadableFile();
|
|
206
|
+
got += bytesRead;
|
|
207
|
+
}
|
|
208
|
+
return buf;
|
|
209
|
+
}
|
|
108
210
|
/**
|
|
109
|
-
* The
|
|
110
|
-
*
|
|
211
|
+
* The early pass's read: the first `min(size, 4096)` bytes, or the whole file
|
|
212
|
+
* for a `.json` of at most 64 KB. Opens without following links.
|
|
111
213
|
*/
|
|
214
|
+
export async function readHead(file) {
|
|
215
|
+
const whole = isJsonName(file.rel) && file.size <= CREDENTIAL_JSON_MAX_BYTES;
|
|
216
|
+
const handle = await openWalked(file);
|
|
217
|
+
try {
|
|
218
|
+
return await readExactly(handle, whole ? file.size : Math.min(file.size, HEAD_READ_BYTES));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new UnreadableFile();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await handle.close().catch(() => undefined);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** The content rules over a head (or whole small JSON). */
|
|
228
|
+
function contentWhy(rel, size, bytes) {
|
|
229
|
+
const scanner = createKeyScanner();
|
|
230
|
+
scanner.push(bytes.subarray(0, HEAD_READ_BYTES).toString("latin1"));
|
|
231
|
+
if (scanner.end())
|
|
232
|
+
return "private_key";
|
|
233
|
+
if (isJsonName(rel) && size <= CREDENTIAL_JSON_MAX_BYTES)
|
|
234
|
+
return credentialJson(bytes);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
async function mapLimit(items, limit, fn) {
|
|
238
|
+
const out = new Array(items.length);
|
|
239
|
+
let next = 0;
|
|
240
|
+
const worker = async () => {
|
|
241
|
+
while (next < items.length) {
|
|
242
|
+
const i = next++;
|
|
243
|
+
out[i] = await fn(items[i]);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
112
249
|
/**
|
|
113
250
|
* Folders an output must never be, or sit inside: the repository's history and
|
|
114
251
|
* the Vincentt binding. Compared case-insensitively, as a default macOS volume
|
|
@@ -121,11 +258,11 @@ function forbiddenSegmentIn(rel) {
|
|
|
121
258
|
.find((seg) => FORBIDDEN_OUTPUT_SEGMENTS.has(seg.toLowerCase()));
|
|
122
259
|
}
|
|
123
260
|
/**
|
|
124
|
-
* The leak and LFS checks, in that order, before anything is hashed
|
|
125
|
-
* Returns the
|
|
126
|
-
*
|
|
261
|
+
* The leak, credential and LFS checks, in that order, before anything is hashed
|
|
262
|
+
* or sent. Returns the file list the checks passed, which is exactly the list
|
|
263
|
+
* the manifest is built from: what is checked is what is uploaded.
|
|
127
264
|
*/
|
|
128
|
-
export async function checkOutput(distDir, repoRoot) {
|
|
265
|
+
export async function checkOutput(distDir, repoRoot, hooks = {}) {
|
|
129
266
|
const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
|
|
130
267
|
const lexical = path.resolve(distDir);
|
|
131
268
|
const lexicalRoot = path.resolve(repoRoot);
|
|
@@ -143,7 +280,7 @@ export async function checkOutput(distDir, repoRoot) {
|
|
|
143
280
|
if (err.code === "ENOENT") {
|
|
144
281
|
throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
|
|
145
282
|
}
|
|
146
|
-
throw
|
|
283
|
+
throw new ManifestError("file_unreadable", "", "the output could not be read");
|
|
147
284
|
}
|
|
148
285
|
if (outReal === rootReal) {
|
|
149
286
|
throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
|
|
@@ -156,24 +293,63 @@ export async function checkOutput(distDir, repoRoot) {
|
|
|
156
293
|
if (hiddenParent !== undefined) {
|
|
157
294
|
throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
|
|
158
295
|
}
|
|
159
|
-
|
|
296
|
+
const outStat = await fs.stat(outReal).catch(() => {
|
|
297
|
+
throw new ManifestError("file_unreadable", "", "the output could not be read");
|
|
298
|
+
});
|
|
299
|
+
if (!outStat.isDirectory()) {
|
|
160
300
|
throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
|
|
161
301
|
}
|
|
162
|
-
const walk = await walkOutput(outReal, rootReal);
|
|
302
|
+
const walk = await walkOutput(outReal, rootReal, hooks);
|
|
163
303
|
const stats = {
|
|
164
304
|
files: walk.files.length,
|
|
165
305
|
bytes: walk.files.reduce((n, f) => n + f.size, 0),
|
|
166
306
|
};
|
|
307
|
+
const escaped = await escapedFiles(walk.files, outReal);
|
|
308
|
+
if (escaped.length > 0) {
|
|
309
|
+
throw new ManifestError("path_escaped", escaped[0].path, "files resolved outside the output", { paths: escaped, stats });
|
|
310
|
+
}
|
|
167
311
|
if (walk.offenders.length > 0) {
|
|
168
312
|
throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
|
|
169
313
|
paths: walk.offenders,
|
|
170
314
|
stats,
|
|
171
315
|
});
|
|
172
316
|
}
|
|
317
|
+
const heads = await mapLimit(walk.files, READ_CONCURRENCY, async (f) => {
|
|
318
|
+
try {
|
|
319
|
+
const head = await readHead(f);
|
|
320
|
+
return {
|
|
321
|
+
why: f.nameMatched ? undefined : contentWhy(f.rel, f.size, head),
|
|
322
|
+
lfs: isLfsPointer(head, f.size),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { unreadable: true };
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const credentials = [...walk.credentialNames];
|
|
173
330
|
const pointers = [];
|
|
174
|
-
|
|
175
|
-
|
|
331
|
+
walk.files.forEach((f, i) => {
|
|
332
|
+
const h = heads[i];
|
|
333
|
+
if ("unreadable" in h)
|
|
334
|
+
return;
|
|
335
|
+
if (h.why !== undefined)
|
|
336
|
+
credentials.push({ seq: f.seq, offender: { path: f.rel, why: h.why } });
|
|
337
|
+
if (h.lfs)
|
|
176
338
|
pointers.push({ path: f.rel });
|
|
339
|
+
});
|
|
340
|
+
if (credentials.length > 0) {
|
|
341
|
+
const paths = credentials.sort((a, b) => a.seq - b.seq).map((c) => c.offender);
|
|
342
|
+
throw new ManifestError("credential_file", paths[0].path, "credential files in the output", {
|
|
343
|
+
paths,
|
|
344
|
+
stats,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// A file the checks could not read fails closed; it is never assumed clean.
|
|
348
|
+
const unreadable = walk.files.find((_, i) => "unreadable" in heads[i]);
|
|
349
|
+
if (unreadable !== undefined) {
|
|
350
|
+
throw new ManifestError("file_unreadable", unreadable.rel, "a file could not be read", {
|
|
351
|
+
stats,
|
|
352
|
+
});
|
|
177
353
|
}
|
|
178
354
|
if (pointers.length > 0) {
|
|
179
355
|
throw new ManifestError("lfs_pointer", pointers[0].path, "Git LFS pointers in the output", {
|
|
@@ -181,69 +357,112 @@ export async function checkOutput(distDir, repoRoot) {
|
|
|
181
357
|
stats,
|
|
182
358
|
});
|
|
183
359
|
}
|
|
184
|
-
return walk.files;
|
|
360
|
+
return walk.files.map(({ rel, abs, size, dev, ino }) => ({ rel, abs, size, dev, ino }));
|
|
185
361
|
}
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Hash a walked file and re-run the content rules over the exact bytes hashed.
|
|
364
|
+
* The file is opened without following links and must still be the regular
|
|
365
|
+
* file the walk saw. Every read error throws `file_unreadable`; only a JSON
|
|
366
|
+
* parse failure is "not a match".
|
|
367
|
+
*/
|
|
368
|
+
export async function hashAndScan(absPath, walked) {
|
|
369
|
+
const unreadable = () => new ManifestError("file_unreadable", walked.rel, "a file changed while it was read");
|
|
370
|
+
let handle;
|
|
189
371
|
try {
|
|
190
|
-
|
|
372
|
+
handle = await openWalked({ ...walked, abs: absPath });
|
|
191
373
|
}
|
|
192
|
-
catch
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
374
|
+
catch {
|
|
375
|
+
throw unreadable();
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const { size } = await handle.stat();
|
|
379
|
+
const hash = createHash("sha256");
|
|
380
|
+
const scanner = createKeyScanner();
|
|
381
|
+
const keepJson = isJsonName(walked.rel) && size <= CREDENTIAL_JSON_MAX_BYTES;
|
|
382
|
+
const kept = [];
|
|
383
|
+
const chunk = Buffer.alloc(HASH_CHUNK_BYTES);
|
|
384
|
+
let read = 0;
|
|
385
|
+
for (;;) {
|
|
386
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, read);
|
|
387
|
+
if (bytesRead === 0)
|
|
388
|
+
break;
|
|
389
|
+
const piece = chunk.subarray(0, bytesRead);
|
|
390
|
+
hash.update(piece);
|
|
391
|
+
scanner.push(piece.toString("latin1"));
|
|
392
|
+
if (keepJson)
|
|
393
|
+
kept.push(Buffer.from(piece));
|
|
394
|
+
read += bytesRead;
|
|
395
|
+
if (read > size)
|
|
396
|
+
throw unreadable();
|
|
205
397
|
}
|
|
398
|
+
if (read !== size)
|
|
399
|
+
throw unreadable();
|
|
400
|
+
let why = scanner.end() ? "private_key" : undefined;
|
|
401
|
+
if (why === undefined && keepJson)
|
|
402
|
+
why = credentialJson(Buffer.concat(kept));
|
|
403
|
+
return { sha256: hash.digest("base64"), size, why };
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
if (err instanceof ManifestError)
|
|
407
|
+
throw err;
|
|
408
|
+
throw unreadable();
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
await handle.close().catch(() => undefined);
|
|
206
412
|
}
|
|
207
|
-
return out;
|
|
208
|
-
}
|
|
209
|
-
/** sha256 of a file, base64 std — the exact form `x-amz-checksum-sha256` wants. */
|
|
210
|
-
export async function hashFile(absPath) {
|
|
211
|
-
const hash = createHash("sha256");
|
|
212
|
-
const stream = createReadStream(absPath);
|
|
213
|
-
for await (const chunk of stream)
|
|
214
|
-
hash.update(chunk);
|
|
215
|
-
return hash.digest("base64");
|
|
216
413
|
}
|
|
217
414
|
/**
|
|
218
415
|
* Build the manifest for a dist directory. Every path is normalized before it is
|
|
219
416
|
* hashed, so the string this returns is the string the API validates, signs, and
|
|
220
417
|
* later lists — there is no second form anywhere.
|
|
221
418
|
*/
|
|
222
|
-
export async function buildManifest(distDir, opts
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
419
|
+
export async function buildManifest(distDir, opts) {
|
|
420
|
+
// The list the checks passed IS the upload list. A second walk could see a
|
|
421
|
+
// file created after the checks ran.
|
|
422
|
+
const walked = await checkOutput(distDir, opts.repoRoot, {
|
|
423
|
+
beforeReadDir: opts.beforeReadDir,
|
|
424
|
+
});
|
|
228
425
|
const normalized = [];
|
|
229
|
-
for (const
|
|
230
|
-
const result = normalizePath(rel);
|
|
426
|
+
for (const file of walked) {
|
|
427
|
+
const result = normalizePath(file.rel);
|
|
231
428
|
if (!result.ok) {
|
|
232
|
-
throw new ManifestError(result.reason, rel, `${rel}: ${result.reason}`);
|
|
429
|
+
throw new ManifestError(result.reason, file.rel, `${file.rel}: ${result.reason}`);
|
|
233
430
|
}
|
|
234
|
-
normalized.push({
|
|
431
|
+
normalized.push({ file, norm: result.path });
|
|
235
432
|
}
|
|
236
433
|
const pathError = validatePathSet(normalized.map((n) => n.norm));
|
|
237
434
|
if (pathError) {
|
|
238
435
|
throw new ManifestError(pathError.reason, pathError.path, `${pathError.path}: ${pathError.reason}`);
|
|
239
436
|
}
|
|
437
|
+
await opts.beforeHash?.(walked);
|
|
438
|
+
const stats = {
|
|
439
|
+
files: walked.length,
|
|
440
|
+
bytes: walked.reduce((n, f) => n + f.size, 0),
|
|
441
|
+
};
|
|
240
442
|
const files = [];
|
|
443
|
+
const credentials = [];
|
|
241
444
|
let totalBytes = 0;
|
|
242
|
-
for (const {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
445
|
+
for (const { file, norm } of normalized) {
|
|
446
|
+
let hashed;
|
|
447
|
+
try {
|
|
448
|
+
hashed = await hashAndScan(file.abs, file);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (err instanceof ManifestError) {
|
|
452
|
+
throw new ManifestError(err.reason, err.offendingPath, err.message, { stats });
|
|
453
|
+
}
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
456
|
+
if (hashed.why !== undefined)
|
|
457
|
+
credentials.push({ path: file.rel, why: hashed.why });
|
|
458
|
+
files.push({ path: norm, size: hashed.size, sha256: hashed.sha256 });
|
|
459
|
+
totalBytes += hashed.size;
|
|
460
|
+
}
|
|
461
|
+
if (credentials.length > 0) {
|
|
462
|
+
throw new ManifestError("credential_file", credentials[0].path, "credential files in the output", {
|
|
463
|
+
paths: credentials,
|
|
464
|
+
stats,
|
|
465
|
+
});
|
|
247
466
|
}
|
|
248
467
|
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
249
468
|
return { files, totalBytes };
|
package/dist/publish/upload.d.ts
CHANGED
|
@@ -22,6 +22,24 @@ export declare class UploadExpired extends Error {
|
|
|
22
22
|
readonly path: string;
|
|
23
23
|
constructor(path: string);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The api returned an upload target for a path this run's manifest does not
|
|
27
|
+
* hold. Nothing is sent: only files that passed this run's checks are uploaded.
|
|
28
|
+
*/
|
|
29
|
+
export declare class UploadTargetRejected extends Error {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
constructor(path: string);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A target's signed checksum is not the one this run's manifest holds for that
|
|
35
|
+
* path. The PUT reopens the file by path, so the signed checksum is what makes
|
|
36
|
+
* "checked is uploaded" hold; a target that would sign anything else is refused
|
|
37
|
+
* before any byte is sent.
|
|
38
|
+
*/
|
|
39
|
+
export declare class UploadChecksumMismatch extends Error {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
constructor(path: string);
|
|
42
|
+
}
|
|
25
43
|
export interface UploadProgress {
|
|
26
44
|
filesDone: number;
|
|
27
45
|
filesTotal: number;
|
package/dist/publish/upload.js
CHANGED
|
@@ -46,6 +46,37 @@ export class UploadExpired extends Error {
|
|
|
46
46
|
this.name = "UploadExpired";
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The api returned an upload target for a path this run's manifest does not
|
|
51
|
+
* hold. Nothing is sent: only files that passed this run's checks are uploaded.
|
|
52
|
+
*/
|
|
53
|
+
export class UploadTargetRejected extends Error {
|
|
54
|
+
path;
|
|
55
|
+
constructor(path) {
|
|
56
|
+
super(`the upload target ${path} is not in this build`);
|
|
57
|
+
this.path = path;
|
|
58
|
+
this.name = "UploadTargetRejected";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A target's signed checksum is not the one this run's manifest holds for that
|
|
63
|
+
* path. The PUT reopens the file by path, so the signed checksum is what makes
|
|
64
|
+
* "checked is uploaded" hold; a target that would sign anything else is refused
|
|
65
|
+
* before any byte is sent.
|
|
66
|
+
*/
|
|
67
|
+
export class UploadChecksumMismatch extends Error {
|
|
68
|
+
path;
|
|
69
|
+
constructor(path) {
|
|
70
|
+
super(`the upload target ${path} does not carry this build's checksum`);
|
|
71
|
+
this.path = path;
|
|
72
|
+
this.name = "UploadChecksumMismatch";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const CHECKSUM_HEADER = "x-amz-checksum-sha256";
|
|
76
|
+
function signedChecksum(headers) {
|
|
77
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === CHECKSUM_HEADER);
|
|
78
|
+
return key === undefined ? undefined : headers[key];
|
|
79
|
+
}
|
|
49
80
|
const DEFAULT_CONCURRENCY = 6;
|
|
50
81
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
51
82
|
const BACKOFF_BASE_MS = 500;
|
|
@@ -99,6 +130,13 @@ export function classifyS3Response(status, body) {
|
|
|
99
130
|
export async function uploadAll(opts) {
|
|
100
131
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
101
132
|
const sizes = new Map(opts.manifest.map((f) => [f.path, f.size]));
|
|
133
|
+
const foreign = opts.targets.find((t) => !sizes.has(t.path));
|
|
134
|
+
if (foreign !== undefined)
|
|
135
|
+
throw new UploadTargetRejected(foreign.path);
|
|
136
|
+
const sums = new Map(opts.manifest.map((f) => [f.path, f.sha256]));
|
|
137
|
+
const mismatched = opts.targets.find((t) => signedChecksum(t.headers) !== sums.get(t.path));
|
|
138
|
+
if (mismatched !== undefined)
|
|
139
|
+
throw new UploadChecksumMismatch(mismatched.path);
|
|
102
140
|
const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
|
|
103
141
|
const queue = [...opts.targets];
|
|
104
142
|
const retry = {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ManifestEntry } from "./manifest.js";
|
|
2
|
+
export declare const HEAVY_MODEL_BYTES = 10000000;
|
|
3
|
+
export declare const HEAVY_IMAGE_PX = 2048;
|
|
4
|
+
export declare const IMAGE_HEAD_BYTES = 524288;
|
|
5
|
+
export type HeavyFinding = {
|
|
6
|
+
path: string;
|
|
7
|
+
kind: "model";
|
|
8
|
+
bytes: number;
|
|
9
|
+
} | {
|
|
10
|
+
path: string;
|
|
11
|
+
kind: "image";
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
export type ReadHead = (path: string, n: number) => Promise<Uint8Array>;
|
|
16
|
+
export declare function heavyFileFindings(files: readonly ManifestEntry[], readHead: ReadHead): Promise<HeavyFinding[]>;
|
|
17
|
+
type Size = {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
/** Pixel size from a file header, or undefined for an unknown or truncated one. */
|
|
22
|
+
export declare function imageSize(head: Uint8Array): Size | undefined;
|
|
23
|
+
export {};
|