@taprootio/docs-artifact 1.0.1 → 1.1.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/README.md +196 -30
- package/bin/taproot-docs-conformance.js +34 -13
- package/bin/taproot-docs-validate.js +44 -9
- package/fixtures/README.md +19 -0
- package/fixtures/prebuilt/conformance.json +444 -0
- package/fixtures/prebuilt/golden/espalier.tar.gz +0 -0
- package/fixtures/prebuilt/invalid/duplicate-json-key.json +4 -0
- package/fixtures/prebuilt/valid/espalier/404.html +2 -0
- package/fixtures/prebuilt/valid/espalier/api/show-toast/index.html +5 -0
- package/fixtures/prebuilt/valid/espalier/assets/icons.svg +1 -0
- package/fixtures/prebuilt/valid/espalier/assets/pulse.svg +1 -0
- package/fixtures/prebuilt/valid/espalier/assets/search-worker.js +1 -0
- package/fixtures/prebuilt/valid/espalier/assets/search.wasm +0 -0
- package/fixtures/prebuilt/valid/espalier/assets/site.css +3 -0
- package/fixtures/prebuilt/valid/espalier/assets/site.js +2 -0
- package/fixtures/prebuilt/valid/espalier/dist/-6iE9DOe.css +1 -0
- package/fixtures/prebuilt/valid/espalier/dist/_AN3XUT_.css +1 -0
- package/fixtures/prebuilt/valid/espalier/guides/index.html +2 -0
- package/fixtures/prebuilt/valid/espalier/index.html +2 -0
- package/fixtures/prebuilt/valid/espalier/pagefind/index/abc.pf_index +0 -0
- package/fixtures/prebuilt/valid/espalier/pagefind/pagefind.js +4 -0
- package/fixtures/prebuilt/valid/espalier/taproot-docs-prebuilt-manifest.json +135 -0
- package/index.d.ts +9 -0
- package/node.d.ts +1 -0
- package/package.json +28 -5
- package/prebuilt-archive.d.ts +27 -0
- package/prebuilt-conformance.d.ts +27 -0
- package/prebuilt-node.d.ts +7 -0
- package/prebuilt.d.ts +128 -0
- package/schema/taproot-docs-prebuilt-manifest.schema.json +156 -0
- package/src/constants.js +4 -0
- package/src/index.js +13 -0
- package/src/json.js +50 -26
- package/src/node.js +2 -0
- package/src/prebuilt-archive.js +246 -0
- package/src/prebuilt-artifact-validator.js +236 -0
- package/src/prebuilt-conformance.js +151 -0
- package/src/prebuilt-constants.js +55 -0
- package/src/prebuilt-manifest-validator.js +654 -0
- package/src/prebuilt-node.js +518 -0
- package/src/prebuilt-path.js +129 -0
- package/src/prebuilt.js +20 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { LIMITS } from "./constants.js";
|
|
5
|
+
import { compareCanonicalStrings, ValidationContext } from "./errors.js";
|
|
6
|
+
import { DIRECTORY_LIMITS_OVERRIDE, FILE_OPEN_RACE_HOOK, FILE_READ_RACE_HOOK } from "./node-internal.js";
|
|
7
|
+
import { validatePrebuiltArtifactFromTrustedSnapshots } from "./prebuilt-artifact-validator.js";
|
|
8
|
+
import { PREBUILT_LIMITS, PREBUILT_MANIFEST_FILE_NAME } from "./prebuilt-constants.js";
|
|
9
|
+
import { snapshotPrebuiltSupportedCapabilities, validatePrebuiltManifest } from "./prebuilt-manifest-validator.js";
|
|
10
|
+
import { normalizePrebuiltArtifactPath } from "./prebuilt-path.js";
|
|
11
|
+
|
|
12
|
+
const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
13
|
+
const NON_BLOCKING = fsConstants.O_NONBLOCK ?? 0;
|
|
14
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
function errorCode(error) {
|
|
17
|
+
return error && typeof error === "object" && typeof error.code === "string" ? error.code : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameIdentity(left, right) {
|
|
21
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sameSnapshot(left, right) {
|
|
25
|
+
return sameIdentity(left, right)
|
|
26
|
+
&& left.size === right.size
|
|
27
|
+
&& left.mtimeNs === right.mtimeNs
|
|
28
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function snapshotTreeEntry(type, stats) {
|
|
32
|
+
return { type, dev: stats.dev, ino: stats.ino };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isWithinRoot(root, candidate) {
|
|
36
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function openNoFollow(filePath) {
|
|
40
|
+
const flags = fsConstants.O_RDONLY | NON_BLOCKING;
|
|
41
|
+
if (NO_FOLLOW === 0) return { handle: await open(filePath, flags), verifyPathIdentity: true };
|
|
42
|
+
try {
|
|
43
|
+
return { handle: await open(filePath, flags | NO_FOLLOW), verifyPathIdentity: false };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(errorCode(error))) throw error;
|
|
46
|
+
return { handle: await open(filePath, flags), verifyPathIdentity: true };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readBounded(handle, maximumBytes) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let totalBytes = 0;
|
|
53
|
+
while (totalBytes <= maximumBytes) {
|
|
54
|
+
const remaining = maximumBytes + 1 - totalBytes;
|
|
55
|
+
const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining));
|
|
56
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, totalBytes);
|
|
57
|
+
if (bytesRead === 0) break;
|
|
58
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
59
|
+
totalBytes += bytesRead;
|
|
60
|
+
}
|
|
61
|
+
const bytes = new Uint8Array(totalBytes);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (const chunk of chunks) {
|
|
64
|
+
bytes.set(chunk, offset);
|
|
65
|
+
offset += chunk.byteLength;
|
|
66
|
+
}
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function verifyPathSnapshot(absolutePath, snapshot, boundary) {
|
|
71
|
+
try {
|
|
72
|
+
const rootAfter = await lstat(boundary.rootPath, { bigint: true });
|
|
73
|
+
if (rootAfter.isSymbolicLink() || !rootAfter.isDirectory() || !sameIdentity(boundary.rootStats, rootAfter)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const relative = path.relative(boundary.rootPath, absolutePath);
|
|
77
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
let current = boundary.rootPath;
|
|
81
|
+
const segments = relative.split(path.sep);
|
|
82
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
83
|
+
current = path.join(current, segments[index]);
|
|
84
|
+
const stats = await lstat(current, { bigint: true });
|
|
85
|
+
if (stats.isSymbolicLink()) return false;
|
|
86
|
+
const firstWalkEntry = boundary.firstWalk?.get(segments.slice(0, index + 1).join("/"));
|
|
87
|
+
if (boundary.firstWalk && (!firstWalkEntry || !sameIdentity(firstWalkEntry, stats))) return false;
|
|
88
|
+
if (index < segments.length - 1) {
|
|
89
|
+
if (!stats.isDirectory() || (boundary.firstWalk && firstWalkEntry.type !== "directory")) return false;
|
|
90
|
+
} else if (
|
|
91
|
+
!stats.isFile()
|
|
92
|
+
|| (boundary.firstWalk && firstWalkEntry.type !== "regular")
|
|
93
|
+
|| !sameSnapshot(snapshot, stats)
|
|
94
|
+
) return false;
|
|
95
|
+
}
|
|
96
|
+
const resolved = await realpath(absolutePath);
|
|
97
|
+
if (!isWithinRoot(boundary.rootRealPath, resolved)) return false;
|
|
98
|
+
const resolvedStats = await lstat(resolved, { bigint: true });
|
|
99
|
+
return !resolvedStats.isSymbolicLink() && resolvedStats.isFile() && sameSnapshot(snapshot, resolvedStats);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readStableFile(
|
|
106
|
+
absolutePath,
|
|
107
|
+
displayPath,
|
|
108
|
+
maximumBytes,
|
|
109
|
+
expectedBytes,
|
|
110
|
+
expectedBytesPath,
|
|
111
|
+
budget,
|
|
112
|
+
boundary,
|
|
113
|
+
context,
|
|
114
|
+
) {
|
|
115
|
+
let opened;
|
|
116
|
+
try {
|
|
117
|
+
opened = await openNoFollow(absolutePath);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code = errorCode(error);
|
|
120
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
121
|
+
context.add("file.missing", displayPath, `Required file '${displayPath}' is missing.`);
|
|
122
|
+
} else if (code === "ELOOP") {
|
|
123
|
+
context.add("file.symlink", displayPath, `Symbolic links are not allowed for '${displayPath}'.`);
|
|
124
|
+
} else context.add("file.unreadable", displayPath, `Could not open '${displayPath}'.`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const { handle, verifyPathIdentity } = opened;
|
|
128
|
+
try {
|
|
129
|
+
const before = await handle.stat({ bigint: true });
|
|
130
|
+
if (!before.isFile()) {
|
|
131
|
+
context.add("file.not_regular", displayPath, `'${displayPath}' must be a regular file.`);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (verifyPathIdentity) {
|
|
135
|
+
let pathStats;
|
|
136
|
+
try {
|
|
137
|
+
pathStats = await lstat(absolutePath, { bigint: true });
|
|
138
|
+
} catch {
|
|
139
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being opened.`);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (pathStats.isSymbolicLink()) {
|
|
143
|
+
context.add("file.symlink", displayPath, `Symbolic links are not allowed for '${displayPath}'.`);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (!sameIdentity(before, pathStats)) {
|
|
147
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being opened.`);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (before.size > BigInt(maximumBytes)) {
|
|
152
|
+
context.add("file.too_large", displayPath, `'${displayPath}' exceeds the ${maximumBytes}-byte read bound.`);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (expectedBytes !== undefined && before.size !== BigInt(expectedBytes)) {
|
|
156
|
+
context.add(
|
|
157
|
+
"file.size_drift",
|
|
158
|
+
expectedBytesPath,
|
|
159
|
+
`File '${displayPath}' has ${before.size} bytes; manifest declares ${expectedBytes}.`,
|
|
160
|
+
);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (budget && before.size > BigInt(budget.remainingBytes)) {
|
|
164
|
+
context.add(
|
|
165
|
+
"limit.artifact_bytes",
|
|
166
|
+
"$files",
|
|
167
|
+
`Prebuilt payload bytes may not exceed ${PREBUILT_LIMITS.artifactBytes}.`,
|
|
168
|
+
);
|
|
169
|
+
budget.stopped = true;
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (budget) budget.remainingBytes -= Number(before.size);
|
|
173
|
+
const readMaximum = expectedBytes ?? maximumBytes;
|
|
174
|
+
const bytes = await readBounded(handle, readMaximum);
|
|
175
|
+
const after = await handle.stat({ bigint: true });
|
|
176
|
+
if (bytes.byteLength > readMaximum || !sameSnapshot(before, after) || BigInt(bytes.byteLength) !== after.size) {
|
|
177
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being read.`);
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
if (typeof boundary.afterRead === "function") await boundary.afterRead(displayPath);
|
|
181
|
+
if (!await verifyPathSnapshot(absolutePath, before, boundary)) {
|
|
182
|
+
context.add(
|
|
183
|
+
"file.changed",
|
|
184
|
+
displayPath,
|
|
185
|
+
`'${displayPath}' or one of its parent directories changed while it was being read.`,
|
|
186
|
+
);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return { bytes, snapshot: before, absolutePath };
|
|
190
|
+
} catch {
|
|
191
|
+
context.add("file.unreadable", displayPath, `Could not read '${displayPath}'.`);
|
|
192
|
+
return undefined;
|
|
193
|
+
} finally {
|
|
194
|
+
await handle.close().catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function prefixDirectories(filePaths) {
|
|
199
|
+
const directories = new Set();
|
|
200
|
+
for (const filePath of filePaths) {
|
|
201
|
+
const segments = filePath.split("/");
|
|
202
|
+
for (let index = 1; index < segments.length; index += 1) directories.add(segments.slice(0, index).join("/"));
|
|
203
|
+
}
|
|
204
|
+
return directories;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createTraversalState(limits) {
|
|
208
|
+
return { entries: 0, files: 0, stopped: false, limitDiagnostic: undefined, diagnostics: [], limits };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function recordDiagnostic(state, code, path, message) {
|
|
212
|
+
if (state.diagnostics.length < LIMITS.validationErrors) state.diagnostics.push({ code, path, message });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stopTraversal(state, code, diagnosticPath, message) {
|
|
216
|
+
state.stopped = true;
|
|
217
|
+
state.limitDiagnostic = { code, path: diagnosticPath, message };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sameTree(left, right) {
|
|
221
|
+
if (left.size !== right.size) return false;
|
|
222
|
+
for (const [entryPath, entry] of left) {
|
|
223
|
+
const finalEntry = right.get(entryPath);
|
|
224
|
+
if (!finalEntry || finalEntry.type !== entry.type || !sameIdentity(entry, finalEntry)) return false;
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function enumerateTree(root, relativeDirectory, expectedFiles, expectedDirectories, encountered, state, depth) {
|
|
230
|
+
if (state.stopped) return;
|
|
231
|
+
const absoluteDirectory = relativeDirectory === "" ? root : path.join(root, ...relativeDirectory.split("/"));
|
|
232
|
+
let directory;
|
|
233
|
+
try {
|
|
234
|
+
directory = await opendir(absoluteDirectory);
|
|
235
|
+
} catch {
|
|
236
|
+
recordDiagnostic(
|
|
237
|
+
state,
|
|
238
|
+
"directory.unreadable",
|
|
239
|
+
relativeDirectory || "$directory",
|
|
240
|
+
`Could not enumerate '${relativeDirectory || "artifact root"}'.`,
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const entries = [];
|
|
245
|
+
const remaining = state.limits.entries - state.entries;
|
|
246
|
+
try {
|
|
247
|
+
for await (const entry of directory) {
|
|
248
|
+
entries.push(entry.name);
|
|
249
|
+
if (entries.length > remaining) {
|
|
250
|
+
stopTraversal(
|
|
251
|
+
state,
|
|
252
|
+
"limit.directory_entries",
|
|
253
|
+
"$directory",
|
|
254
|
+
`Prebuilt artifact tree may not contain more than ${state.limits.entries} filesystem entries.`,
|
|
255
|
+
);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
if (!state.stopped) {
|
|
261
|
+
recordDiagnostic(
|
|
262
|
+
state,
|
|
263
|
+
"directory.unreadable",
|
|
264
|
+
relativeDirectory || "$directory",
|
|
265
|
+
`Could not completely enumerate '${relativeDirectory || "artifact root"}'.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (state.stopped) return;
|
|
271
|
+
entries.sort(compareCanonicalStrings);
|
|
272
|
+
state.entries += entries.length;
|
|
273
|
+
|
|
274
|
+
for (const name of entries) {
|
|
275
|
+
if (state.stopped) break;
|
|
276
|
+
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
|
|
277
|
+
if (relativePath.length > state.limits.pathLength) {
|
|
278
|
+
stopTraversal(
|
|
279
|
+
state,
|
|
280
|
+
"path.too_long",
|
|
281
|
+
relativePath,
|
|
282
|
+
`Prebuilt artifact paths may not exceed ${state.limits.pathLength} bytes.`,
|
|
283
|
+
);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
const absolutePath = path.join(absoluteDirectory, name);
|
|
287
|
+
let stats;
|
|
288
|
+
try {
|
|
289
|
+
stats = await lstat(absolutePath, { bigint: true });
|
|
290
|
+
} catch {
|
|
291
|
+
recordDiagnostic(state, "file.changed", relativePath, `'${relativePath}' changed during directory enumeration.`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (stats.isSymbolicLink()) {
|
|
295
|
+
encountered.set(relativePath, snapshotTreeEntry("symlink", stats));
|
|
296
|
+
recordDiagnostic(state, "file.symlink", relativePath, `Symbolic links are not allowed for '${relativePath}'.`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (stats.isDirectory()) {
|
|
300
|
+
encountered.set(relativePath, snapshotTreeEntry("directory", stats));
|
|
301
|
+
const probe = normalizePrebuiltArtifactPath(`${relativePath}/x`);
|
|
302
|
+
if (!probe.ok) recordDiagnostic(state, probe.code, relativePath, probe.message);
|
|
303
|
+
if (depth >= state.limits.depth) {
|
|
304
|
+
stopTraversal(
|
|
305
|
+
state,
|
|
306
|
+
"limit.directory_depth",
|
|
307
|
+
relativePath,
|
|
308
|
+
`Prebuilt artifact directories may not exceed ${state.limits.depth} levels.`,
|
|
309
|
+
);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
if (!expectedDirectories.has(relativePath)) {
|
|
313
|
+
recordDiagnostic(
|
|
314
|
+
state,
|
|
315
|
+
"directory.unexpected",
|
|
316
|
+
relativePath,
|
|
317
|
+
`Directory '${relativePath}' is not required by any declared file.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
await enumerateTree(root, relativePath, expectedFiles, expectedDirectories, encountered, state, depth + 1);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (stats.isFile()) {
|
|
324
|
+
state.files += 1;
|
|
325
|
+
if (state.files > state.limits.files + 1) {
|
|
326
|
+
stopTraversal(
|
|
327
|
+
state,
|
|
328
|
+
"limit.files",
|
|
329
|
+
"$directory",
|
|
330
|
+
`Prebuilt artifact tree may not contain more than ${state.limits.files} payload files plus its manifest.`,
|
|
331
|
+
);
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
encountered.set(relativePath, snapshotTreeEntry("regular", stats));
|
|
335
|
+
if (stats.nlink > 1n) {
|
|
336
|
+
recordDiagnostic(
|
|
337
|
+
state,
|
|
338
|
+
"file.hard_link",
|
|
339
|
+
relativePath,
|
|
340
|
+
`Hard-linked files are not allowed for '${relativePath}'.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (relativePath !== PREBUILT_MANIFEST_FILE_NAME) {
|
|
344
|
+
const normalized = normalizePrebuiltArtifactPath(relativePath);
|
|
345
|
+
if (!normalized.ok) recordDiagnostic(state, normalized.code, relativePath, normalized.message);
|
|
346
|
+
}
|
|
347
|
+
if (!expectedFiles.has(relativePath)) {
|
|
348
|
+
recordDiagnostic(
|
|
349
|
+
state,
|
|
350
|
+
"file.unexpected",
|
|
351
|
+
relativePath,
|
|
352
|
+
`File '${relativePath}' is not declared by the prebuilt manifest.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
encountered.set(relativePath, snapshotTreeEntry("unsupported", stats));
|
|
358
|
+
recordDiagnostic(
|
|
359
|
+
state,
|
|
360
|
+
"file.not_regular",
|
|
361
|
+
relativePath,
|
|
362
|
+
`'${relativePath}' has an unsupported filesystem entry type.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function validatePrebuiltArtifactDirectory(rootDirectory, options = {}) {
|
|
368
|
+
const context = new ValidationContext();
|
|
369
|
+
const supportedResult = snapshotPrebuiltSupportedCapabilities(options);
|
|
370
|
+
if (!supportedResult.ok) return supportedResult;
|
|
371
|
+
const validationOptions = Object.freeze({ supportedCapabilities: supportedResult.value });
|
|
372
|
+
let root;
|
|
373
|
+
try {
|
|
374
|
+
root = path.resolve(rootDirectory);
|
|
375
|
+
} catch {
|
|
376
|
+
context.add("directory.invalid", "$directory", "Artifact root must be a filesystem path.");
|
|
377
|
+
return context.finish(undefined);
|
|
378
|
+
}
|
|
379
|
+
let rootStats;
|
|
380
|
+
try {
|
|
381
|
+
rootStats = await lstat(root, { bigint: true });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
context.add(
|
|
384
|
+
errorCode(error) === "ENOENT" ? "directory.missing" : "directory.unreadable",
|
|
385
|
+
"$directory",
|
|
386
|
+
"Could not inspect the prebuilt artifact directory.",
|
|
387
|
+
);
|
|
388
|
+
return context.finish(undefined);
|
|
389
|
+
}
|
|
390
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
391
|
+
context.add(
|
|
392
|
+
"directory.invalid",
|
|
393
|
+
"$directory",
|
|
394
|
+
"Artifact root must be a real directory, not a file or symbolic link.",
|
|
395
|
+
);
|
|
396
|
+
return context.finish(undefined);
|
|
397
|
+
}
|
|
398
|
+
let rootRealPath;
|
|
399
|
+
try {
|
|
400
|
+
rootRealPath = await realpath(root);
|
|
401
|
+
const realStats = await lstat(rootRealPath, { bigint: true });
|
|
402
|
+
if (!realStats.isDirectory() || !sameIdentity(rootStats, realStats)) throw new Error("Root changed.");
|
|
403
|
+
} catch {
|
|
404
|
+
context.add("directory.changed", "$directory", "Artifact root changed while it was being inspected.");
|
|
405
|
+
return context.finish(undefined);
|
|
406
|
+
}
|
|
407
|
+
const boundary = {
|
|
408
|
+
rootPath: root,
|
|
409
|
+
rootRealPath,
|
|
410
|
+
rootStats,
|
|
411
|
+
firstWalk: undefined,
|
|
412
|
+
beforeOpen: options[FILE_OPEN_RACE_HOOK],
|
|
413
|
+
afterRead: options[FILE_READ_RACE_HOOK],
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const manifestPath = path.join(root, PREBUILT_MANIFEST_FILE_NAME);
|
|
417
|
+
if (typeof boundary.beforeOpen === "function") await boundary.beforeOpen(PREBUILT_MANIFEST_FILE_NAME, manifestPath);
|
|
418
|
+
const manifestRead = await readStableFile(
|
|
419
|
+
manifestPath,
|
|
420
|
+
PREBUILT_MANIFEST_FILE_NAME,
|
|
421
|
+
PREBUILT_LIMITS.manifestBytes,
|
|
422
|
+
undefined,
|
|
423
|
+
undefined,
|
|
424
|
+
undefined,
|
|
425
|
+
boundary,
|
|
426
|
+
context,
|
|
427
|
+
);
|
|
428
|
+
if (!manifestRead) return context.finish(undefined);
|
|
429
|
+
const manifestResult = validatePrebuiltManifest(manifestRead.bytes, validationOptions);
|
|
430
|
+
if (!manifestResult.ok) return manifestResult;
|
|
431
|
+
const manifest = manifestResult.value;
|
|
432
|
+
|
|
433
|
+
const expectedFiles = new Set([PREBUILT_MANIFEST_FILE_NAME, ...manifest.files.map((file) => file.path)]);
|
|
434
|
+
const expectedDirectories = prefixDirectories(manifest.files.map((file) => file.path));
|
|
435
|
+
const internalLimit = (value, authoritative) =>
|
|
436
|
+
Number.isSafeInteger(value) && value > 0 ? Math.min(value, authoritative) : authoritative;
|
|
437
|
+
const override = options[DIRECTORY_LIMITS_OVERRIDE];
|
|
438
|
+
const limits = {
|
|
439
|
+
entries: internalLimit(override?.entries, PREBUILT_LIMITS.directoryEntries),
|
|
440
|
+
files: internalLimit(override?.files, PREBUILT_LIMITS.files),
|
|
441
|
+
depth: internalLimit(override?.depth, PREBUILT_LIMITS.directoryDepth),
|
|
442
|
+
pathLength: internalLimit(override?.pathLength, PREBUILT_LIMITS.artifactPathBytes),
|
|
443
|
+
};
|
|
444
|
+
const encountered = new Map();
|
|
445
|
+
const firstWalk = createTraversalState(limits);
|
|
446
|
+
await enumerateTree(root, "", expectedFiles, expectedDirectories, encountered, firstWalk, 0);
|
|
447
|
+
if (firstWalk.stopped) {
|
|
448
|
+
context.add(firstWalk.limitDiagnostic.code, firstWalk.limitDiagnostic.path, firstWalk.limitDiagnostic.message);
|
|
449
|
+
return context.finish(undefined);
|
|
450
|
+
}
|
|
451
|
+
for (const diagnostic of firstWalk.diagnostics) context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
452
|
+
for (const expectedPath of expectedFiles) {
|
|
453
|
+
if (!encountered.has(expectedPath)) {
|
|
454
|
+
context.add("file.missing", expectedPath, `Required file '${expectedPath}' is missing.`);
|
|
455
|
+
} else if (encountered.get(expectedPath).type !== "regular") {
|
|
456
|
+
context.add("file.not_regular", expectedPath, `'${expectedPath}' must be a regular file.`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (context.errors.length > 0) return context.finish(undefined);
|
|
460
|
+
boundary.firstWalk = encountered;
|
|
461
|
+
|
|
462
|
+
const snapshots = new Map([[PREBUILT_MANIFEST_FILE_NAME, manifestRead]]);
|
|
463
|
+
const fileEntries = [];
|
|
464
|
+
const budget = { remainingBytes: PREBUILT_LIMITS.artifactBytes, stopped: false };
|
|
465
|
+
for (let index = 0; index < manifest.files.length; index += 1) {
|
|
466
|
+
if (budget.stopped) break;
|
|
467
|
+
const descriptor = manifest.files[index];
|
|
468
|
+
const absolutePath = path.join(root, ...descriptor.path.split("/"));
|
|
469
|
+
if (!isWithinRoot(root, absolutePath)) {
|
|
470
|
+
context.add("path.unsafe", descriptor.path, "Resolved prebuilt path escapes the artifact directory.");
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (typeof boundary.beforeOpen === "function") await boundary.beforeOpen(descriptor.path, absolutePath);
|
|
474
|
+
const read = await readStableFile(
|
|
475
|
+
absolutePath,
|
|
476
|
+
descriptor.path,
|
|
477
|
+
PREBUILT_LIMITS.fileBytes,
|
|
478
|
+
descriptor.bytes,
|
|
479
|
+
`$.files[${index}].bytes`,
|
|
480
|
+
budget,
|
|
481
|
+
boundary,
|
|
482
|
+
context,
|
|
483
|
+
);
|
|
484
|
+
if (read) {
|
|
485
|
+
snapshots.set(descriptor.path, read);
|
|
486
|
+
fileEntries.push({ path: descriptor.path, content: read.bytes });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (context.errors.length > 0) return context.finish(undefined);
|
|
490
|
+
|
|
491
|
+
const finalEncountered = new Map();
|
|
492
|
+
const finalWalk = createTraversalState(limits);
|
|
493
|
+
await enumerateTree(root, "", expectedFiles, expectedDirectories, finalEncountered, finalWalk, 0);
|
|
494
|
+
if (finalWalk.stopped) {
|
|
495
|
+
context.add(finalWalk.limitDiagnostic.code, finalWalk.limitDiagnostic.path, finalWalk.limitDiagnostic.message);
|
|
496
|
+
return context.finish(undefined);
|
|
497
|
+
}
|
|
498
|
+
if (!sameTree(encountered, finalEncountered)) {
|
|
499
|
+
context.add(
|
|
500
|
+
"directory.changed",
|
|
501
|
+
"$directory",
|
|
502
|
+
"Prebuilt artifact paths or entry types changed while the artifact was being inspected.",
|
|
503
|
+
);
|
|
504
|
+
return context.finish(undefined);
|
|
505
|
+
}
|
|
506
|
+
for (const diagnostic of finalWalk.diagnostics) context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
507
|
+
for (const [displayPath, snapshot] of snapshots) {
|
|
508
|
+
if (!await verifyPathSnapshot(snapshot.absolutePath, snapshot.snapshot, boundary)) {
|
|
509
|
+
context.add(
|
|
510
|
+
"file.changed",
|
|
511
|
+
displayPath,
|
|
512
|
+
`'${displayPath}' or one of its parent directories changed after it was read.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (context.errors.length > 0) return context.finish(undefined);
|
|
517
|
+
return validatePrebuiltArtifactFromTrustedSnapshots(manifest, fileEntries);
|
|
518
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { PREBUILT_LIMITS, PREBUILT_MANIFEST_FILE_NAME } from "./prebuilt-constants.js";
|
|
2
|
+
|
|
3
|
+
const PORTABLE_SEGMENT = /^(?:[A-Za-z0-9]|[A-Za-z0-9_-][A-Za-z0-9._-]*[A-Za-z0-9])$/;
|
|
4
|
+
const WINDOWS_DEVICE_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
|
|
5
|
+
const PUBLISHED_SITE_ROUTING_CONTROL_ROUTE = "/__taproot/internal/published-site-routing";
|
|
6
|
+
|
|
7
|
+
function failure(code, message) {
|
|
8
|
+
return { ok: false, code, message };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ustarRepresentable(value) {
|
|
12
|
+
if (value.length <= 100) return true;
|
|
13
|
+
for (let index = value.lastIndexOf("/"); index > 0; index = value.lastIndexOf("/", index - 1)) {
|
|
14
|
+
if (index <= 155 && value.length - index - 1 <= 100) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizePrebuiltArtifactPath(value) {
|
|
20
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
21
|
+
return failure("path.invalid", "Prebuilt artifact paths must be non-empty strings.");
|
|
22
|
+
}
|
|
23
|
+
if (!value.isWellFormed() || /[^\u0020-\u007e]/u.test(value)) {
|
|
24
|
+
return failure("path.not_ascii", "Prebuilt artifact paths must contain only printable ASCII characters.");
|
|
25
|
+
}
|
|
26
|
+
if (value.length > PREBUILT_LIMITS.artifactPathBytes) {
|
|
27
|
+
return failure(
|
|
28
|
+
"path.too_long",
|
|
29
|
+
`Prebuilt artifact paths may not exceed ${PREBUILT_LIMITS.artifactPathBytes} bytes.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (value.startsWith("/") || value.includes("\\") || value.includes("%") || /[?#]/u.test(value)) {
|
|
33
|
+
return failure(
|
|
34
|
+
"path.unsafe",
|
|
35
|
+
"Prebuilt artifact paths must be relative POSIX paths without escapes, queries, or fragments.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const segments = value.split("/");
|
|
39
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
40
|
+
return failure("path.unsafe", "Prebuilt artifact paths may not contain empty or dot segments.");
|
|
41
|
+
}
|
|
42
|
+
if (segments.length - 1 > PREBUILT_LIMITS.directoryDepth) {
|
|
43
|
+
return failure(
|
|
44
|
+
"limit.directory_depth",
|
|
45
|
+
`Prebuilt artifact paths may not exceed ${PREBUILT_LIMITS.directoryDepth} directory levels.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (segments.some((segment) => WINDOWS_DEVICE_SEGMENT.test(segment))) {
|
|
49
|
+
return failure("path.device_name", "Prebuilt artifact path segments may not use Windows device-name aliases.");
|
|
50
|
+
}
|
|
51
|
+
if (segments.some((segment) => !PORTABLE_SEGMENT.test(segment))) {
|
|
52
|
+
return failure(
|
|
53
|
+
"path.not_portable",
|
|
54
|
+
"Prebuilt artifact path segments must use portable ASCII letters, digits, '.', '_', and '-'.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (segments[0].toLowerCase() === PREBUILT_MANIFEST_FILE_NAME) {
|
|
58
|
+
return failure(
|
|
59
|
+
"path.manifest",
|
|
60
|
+
`The manifest '${PREBUILT_MANIFEST_FILE_NAME}' and its path descendants may not be declared as payload.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!ustarRepresentable(value)) {
|
|
64
|
+
return failure("path.ustar", "Prebuilt artifact paths must fit POSIX USTAR name and prefix fields.");
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, value };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function prebuiltFileRoute(value) {
|
|
70
|
+
const normalized = normalizePrebuiltArtifactPath(value);
|
|
71
|
+
if (!normalized.ok) return normalized;
|
|
72
|
+
let route = `/${value}`;
|
|
73
|
+
if (value === "index.html") route = "/";
|
|
74
|
+
else if (value.endsWith("/index.html")) route = `/${value.slice(0, -"index.html".length)}`;
|
|
75
|
+
if (route === PUBLISHED_SITE_ROUTING_CONTROL_ROUTE) {
|
|
76
|
+
return failure(
|
|
77
|
+
"route.reserved",
|
|
78
|
+
`The route '${PUBLISHED_SITE_ROUTING_CONTROL_ROUTE}' is reserved for Taproot routing control.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, value: route };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizePrebuiltRedirectRoute(value) {
|
|
85
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
86
|
+
return failure("route.invalid", "Redirect sources must be non-empty strings.");
|
|
87
|
+
}
|
|
88
|
+
if (!value.isWellFormed() || /[^\u0020-\u007e]/u.test(value)) {
|
|
89
|
+
return failure("route.not_ascii", "Redirect sources must contain only printable ASCII characters.");
|
|
90
|
+
}
|
|
91
|
+
if (value.length > PREBUILT_LIMITS.routeBytes) {
|
|
92
|
+
return failure("route.too_long", `Redirect sources may not exceed ${PREBUILT_LIMITS.routeBytes} bytes.`);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("%")
|
|
96
|
+
|| /[?#]/u.test(value)
|
|
97
|
+
) {
|
|
98
|
+
return failure(
|
|
99
|
+
"route.unsafe",
|
|
100
|
+
"Redirect sources must be root-relative URL paths without escapes, queries, or fragments.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (value.toLowerCase() === `/${PREBUILT_MANIFEST_FILE_NAME}`) {
|
|
104
|
+
return failure(
|
|
105
|
+
"route.reserved",
|
|
106
|
+
"The prebuilt manifest path is reserved artifact metadata and cannot be a redirect source.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (value === PUBLISHED_SITE_ROUTING_CONTROL_ROUTE) {
|
|
110
|
+
return failure(
|
|
111
|
+
"route.reserved",
|
|
112
|
+
`The route '${PUBLISHED_SITE_ROUTING_CONTROL_ROUTE}' is reserved for Taproot routing control.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (value === "/") return { ok: true, value };
|
|
116
|
+
const hasTrailingSlash = value.endsWith("/");
|
|
117
|
+
const body = value.slice(1, hasTrailingSlash ? -1 : undefined);
|
|
118
|
+
const segments = body.split("/");
|
|
119
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
120
|
+
return failure("route.unsafe", "Redirect sources may not contain empty or dot segments.");
|
|
121
|
+
}
|
|
122
|
+
if (segments.some((segment) => WINDOWS_DEVICE_SEGMENT.test(segment))) {
|
|
123
|
+
return failure("route.device_name", "Redirect source segments may not use Windows device-name aliases.");
|
|
124
|
+
}
|
|
125
|
+
if (segments.some((segment) => !PORTABLE_SEGMENT.test(segment))) {
|
|
126
|
+
return failure("route.not_portable", "Redirect source segments must use portable ASCII characters.");
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, value };
|
|
129
|
+
}
|
package/src/prebuilt.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { DocsArtifactValidationError } from "./errors.js";
|
|
2
|
+
export { assertValidPrebuiltArtifact, validatePrebuiltArtifact } from "./prebuilt-artifact-validator.js";
|
|
3
|
+
export {
|
|
4
|
+
PREBUILT_ARCHIVE_FORMAT,
|
|
5
|
+
PREBUILT_FILES_CAPABILITY,
|
|
6
|
+
PREBUILT_LIMITS,
|
|
7
|
+
PREBUILT_MANIFEST_FILE_NAME,
|
|
8
|
+
PREBUILT_MEDIA_TYPES,
|
|
9
|
+
PREBUILT_MODE,
|
|
10
|
+
PREBUILT_NOT_FOUND_FILE,
|
|
11
|
+
PREBUILT_SCHEMA_VERSION,
|
|
12
|
+
PREBUILT_SUPPORTED_CAPABILITIES,
|
|
13
|
+
PREBUILT_TEXT_MEDIA_TYPES,
|
|
14
|
+
} from "./prebuilt-constants.js";
|
|
15
|
+
export {
|
|
16
|
+
assertValidPrebuiltManifest,
|
|
17
|
+
serializePrebuiltManifest,
|
|
18
|
+
validatePrebuiltManifest,
|
|
19
|
+
} from "./prebuilt-manifest-validator.js";
|
|
20
|
+
export { normalizePrebuiltArtifactPath, normalizePrebuiltRedirectRoute, prebuiltFileRoute } from "./prebuilt-path.js";
|