@taprootio/docs-artifact 1.0.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 +13 -0
- package/README.md +362 -0
- package/bin/taproot-docs-conformance.js +20 -0
- package/bin/taproot-docs-validate.js +17 -0
- package/conformance.d.ts +11 -0
- package/fixtures/README.md +24 -0
- package/fixtures/conformance.json +1630 -0
- package/fixtures/invalid/duplicate-json-key.json +1 -0
- package/fixtures/invalid/hash-drift/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/invalid/size-drift/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/invalid/unsafe-markup/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/valid/complete/taproot-docs/assets/pixel.png.base64 +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/button.en-us.html +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/button.fr-fr.html +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/getting-started.en-us.html +3 -0
- package/fixtures/valid/complete/taproot-docs/fragments/getting-started.fr-fr.html +3 -0
- package/fixtures/valid/complete/taproot-docs-manifest.json +231 -0
- package/fixtures/valid/minimal/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/valid/minimal/taproot-docs-manifest.json +80 -0
- package/index.d.ts +204 -0
- package/node.d.ts +6 -0
- package/package.json +54 -0
- package/schema/taproot-docs-manifest.schema.json +487 -0
- package/src/artifact-validator.js +870 -0
- package/src/binary.js +67 -0
- package/src/conformance.js +578 -0
- package/src/constants.js +104 -0
- package/src/errors.js +139 -0
- package/src/index.js +18 -0
- package/src/json.js +516 -0
- package/src/manifest-validator.js +650 -0
- package/src/markup.js +578 -0
- package/src/node-internal.js +4 -0
- package/src/node.js +513 -0
- package/src/path.js +103 -0
- package/src/text.js +30 -0
package/src/node.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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, MANIFEST_FILE_NAME } from "./constants.js";
|
|
5
|
+
import { validateArtifact } from "./artifact-validator.js";
|
|
6
|
+
import { compareCanonicalStrings, ValidationContext } from "./errors.js";
|
|
7
|
+
import { snapshotSupportedCapabilities, validateManifest } from "./manifest-validator.js";
|
|
8
|
+
import { DIRECTORY_LIMITS_OVERRIDE, FILE_OPEN_RACE_HOOK, FILE_READ_RACE_HOOK } from "./node-internal.js";
|
|
9
|
+
|
|
10
|
+
const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
11
|
+
const NON_BLOCKING = fsConstants.O_NONBLOCK ?? 0;
|
|
12
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
function collectDeclaredPaths(manifest) {
|
|
15
|
+
const paths = [];
|
|
16
|
+
for (let resourceIndex = 0; resourceIndex < manifest.resources.length; resourceIndex += 1) {
|
|
17
|
+
const resource = manifest.resources[resourceIndex];
|
|
18
|
+
for (let variantIndex = 0; variantIndex < resource.variants.length; variantIndex += 1) {
|
|
19
|
+
const variant = resource.variants[variantIndex];
|
|
20
|
+
for (let fragmentIndex = 0; fragmentIndex < variant.fragments.length; fragmentIndex += 1) {
|
|
21
|
+
const fragment = variant.fragments[fragmentIndex];
|
|
22
|
+
paths.push({
|
|
23
|
+
path: fragment.path,
|
|
24
|
+
maximumBytes: LIMITS.fragmentBytes,
|
|
25
|
+
declaredBytes: fragment.bytes,
|
|
26
|
+
bytesPath: `$.resources[${resourceIndex}].variants[${variantIndex}].fragments[${fragmentIndex}].bytes`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (let assetIndex = 0; assetIndex < manifest.assets.length; assetIndex += 1) {
|
|
32
|
+
const asset = manifest.assets[assetIndex];
|
|
33
|
+
paths.push({
|
|
34
|
+
path: asset.path,
|
|
35
|
+
maximumBytes: LIMITS.assetBytes,
|
|
36
|
+
declaredBytes: asset.bytes,
|
|
37
|
+
bytesPath: `$.assets[${assetIndex}].bytes`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return paths.sort((left, right) => compareCanonicalStrings(left.path, right.path));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function errorCode(error) {
|
|
44
|
+
return error && typeof error === "object" && typeof error.code === "string" ? error.code : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameFileIdentity(left, right) {
|
|
48
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameFileSnapshot(left, right) {
|
|
52
|
+
return sameFileIdentity(left, right)
|
|
53
|
+
&& left.size === right.size
|
|
54
|
+
&& left.mtimeNs === right.mtimeNs
|
|
55
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function openNoFollow(filePath) {
|
|
59
|
+
const readFlags = fsConstants.O_RDONLY | NON_BLOCKING;
|
|
60
|
+
if (NO_FOLLOW === 0) {
|
|
61
|
+
return { handle: await open(filePath, readFlags), verifyPathIdentity: true };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return {
|
|
65
|
+
handle: await open(filePath, readFlags | NO_FOLLOW),
|
|
66
|
+
verifyPathIdentity: false,
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(errorCode(error))) throw error;
|
|
70
|
+
return { handle: await open(filePath, readFlags), verifyPathIdentity: true };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readFromHandle(handle, maximumBytes) {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let totalBytes = 0;
|
|
77
|
+
while (totalBytes <= maximumBytes) {
|
|
78
|
+
const remaining = maximumBytes + 1 - totalBytes;
|
|
79
|
+
const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining));
|
|
80
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, totalBytes);
|
|
81
|
+
if (bytesRead === 0) break;
|
|
82
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
83
|
+
totalBytes += bytesRead;
|
|
84
|
+
}
|
|
85
|
+
return Buffer.concat(chunks, totalBytes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isWithinRoot(root, candidate) {
|
|
89
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function verifyFilePathAfterRead(filePath, before, boundary) {
|
|
93
|
+
try {
|
|
94
|
+
const relative = path.relative(boundary.rootPath, filePath);
|
|
95
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
|
|
96
|
+
const rootAfter = await lstat(boundary.rootPath, { bigint: true });
|
|
97
|
+
if (rootAfter.isSymbolicLink() || !rootAfter.isDirectory() || !sameFileIdentity(boundary.rootStats, rootAfter)) return false;
|
|
98
|
+
|
|
99
|
+
let current = boundary.rootPath;
|
|
100
|
+
const segments = relative.split(path.sep);
|
|
101
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
102
|
+
current = path.join(current, segments[index]);
|
|
103
|
+
const currentStats = await lstat(current, { bigint: true });
|
|
104
|
+
if (currentStats.isSymbolicLink()) return false;
|
|
105
|
+
if (index < segments.length - 1) {
|
|
106
|
+
if (!currentStats.isDirectory()) return false;
|
|
107
|
+
} else if (!currentStats.isFile() || !sameFileIdentity(before, currentStats)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resolvedPath = await realpath(filePath);
|
|
113
|
+
if (!isWithinRoot(boundary.rootRealPath, resolvedPath)) return false;
|
|
114
|
+
const resolvedStats = await lstat(resolvedPath, { bigint: true });
|
|
115
|
+
return !resolvedStats.isSymbolicLink() && resolvedStats.isFile() && sameFileIdentity(before, resolvedStats);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function inspectRegularFile(
|
|
122
|
+
filePath,
|
|
123
|
+
displayPath,
|
|
124
|
+
maximumBytes,
|
|
125
|
+
context,
|
|
126
|
+
expectedBytes,
|
|
127
|
+
expectedBytesPath,
|
|
128
|
+
readBudget,
|
|
129
|
+
boundary,
|
|
130
|
+
) {
|
|
131
|
+
let opened;
|
|
132
|
+
try {
|
|
133
|
+
opened = await openNoFollow(filePath);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const code = errorCode(error);
|
|
136
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
137
|
+
context.add("file.missing", displayPath, `Required file '${displayPath}' is missing.`);
|
|
138
|
+
} else if (code === "ELOOP") {
|
|
139
|
+
context.add("file.symlink", displayPath, `Symbolic links are not allowed for '${displayPath}'.`);
|
|
140
|
+
} else {
|
|
141
|
+
context.add("file.unreadable", displayPath, `Could not open '${displayPath}'.`);
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { handle, verifyPathIdentity } = opened;
|
|
147
|
+
try {
|
|
148
|
+
const before = await handle.stat({ bigint: true });
|
|
149
|
+
if (!before.isFile()) {
|
|
150
|
+
context.add("file.not_regular", displayPath, `'${displayPath}' must be a regular file.`);
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (verifyPathIdentity) {
|
|
154
|
+
let pathStats;
|
|
155
|
+
try {
|
|
156
|
+
pathStats = await lstat(filePath, { bigint: true });
|
|
157
|
+
} catch {
|
|
158
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being opened.`);
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
if (pathStats.isSymbolicLink()) {
|
|
162
|
+
context.add("file.symlink", displayPath, `Symbolic links are not allowed for '${displayPath}'.`);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
if (!sameFileIdentity(before, pathStats)) {
|
|
166
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being opened.`);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (before.size > BigInt(maximumBytes)) {
|
|
171
|
+
context.add("file.too_large", displayPath, `'${displayPath}' exceeds the ${maximumBytes}-byte read bound.`);
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
if (expectedBytes !== undefined && before.size !== BigInt(expectedBytes)) {
|
|
175
|
+
context.add("file.size_drift", expectedBytesPath ?? displayPath, `File '${displayPath}' has ${before.size} bytes; manifest declares ${expectedBytes}.`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
if (readBudget && before.size > BigInt(readBudget.remainingBytes)) {
|
|
179
|
+
context.add("limit.artifact_bytes", "$files", `Artifact bytes may not exceed ${LIMITS.artifactBytes}.`);
|
|
180
|
+
readBudget.stopped = true;
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
if (readBudget) readBudget.remainingBytes -= Number(before.size);
|
|
184
|
+
|
|
185
|
+
const readMaximum = expectedBytes ?? maximumBytes;
|
|
186
|
+
const bytes = await readFromHandle(handle, readMaximum);
|
|
187
|
+
const after = await handle.stat({ bigint: true });
|
|
188
|
+
if (bytes.byteLength > readMaximum) {
|
|
189
|
+
context.add("file.changed", displayPath, `'${displayPath}' grew while it was being read.`);
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
if (!sameFileSnapshot(before, after) || BigInt(bytes.byteLength) !== after.size) {
|
|
193
|
+
context.add("file.changed", displayPath, `'${displayPath}' changed while it was being read.`);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
if (typeof boundary.afterRead === "function") await boundary.afterRead(displayPath);
|
|
197
|
+
if (!await verifyFilePathAfterRead(filePath, before, boundary)) {
|
|
198
|
+
context.add("file.changed", displayPath, `'${displayPath}' or one of its parent directories changed while it was being read.`);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return bytes;
|
|
202
|
+
} catch {
|
|
203
|
+
context.add("file.unreadable", displayPath, `Could not read '${displayPath}'.`);
|
|
204
|
+
return undefined;
|
|
205
|
+
} finally {
|
|
206
|
+
await handle.close().catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function requireRegularManifest(filePath, context) {
|
|
211
|
+
let stats;
|
|
212
|
+
try {
|
|
213
|
+
stats = await lstat(filePath, { bigint: true });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (["ENOENT", "ENOTDIR"].includes(errorCode(error))) {
|
|
216
|
+
context.add("file.missing", MANIFEST_FILE_NAME, `Required file '${MANIFEST_FILE_NAME}' is missing.`);
|
|
217
|
+
} else {
|
|
218
|
+
context.add("file.unreadable", MANIFEST_FILE_NAME, `Could not inspect '${MANIFEST_FILE_NAME}'.`);
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (stats.isSymbolicLink()) {
|
|
223
|
+
context.add("file.symlink", MANIFEST_FILE_NAME, `Symbolic links are not allowed for '${MANIFEST_FILE_NAME}'.`);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
if (!stats.isFile()) {
|
|
227
|
+
context.add("file.not_regular", MANIFEST_FILE_NAME, `'${MANIFEST_FILE_NAME}' must be a regular file.`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function recordDirectoryDiagnostic(state, code, path, message) {
|
|
234
|
+
if (state.diagnostics.length <= LIMITS.validationErrors) {
|
|
235
|
+
state.diagnostics.push({ code, path, message });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function stopDirectoryTraversal(state, code, path, message) {
|
|
240
|
+
state.stopped = true;
|
|
241
|
+
state.limitDiagnostic = { code, path, message };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function createDirectoryTraversalState(limits) {
|
|
245
|
+
return {
|
|
246
|
+
entryCount: 0,
|
|
247
|
+
fileCount: 0,
|
|
248
|
+
stopped: false,
|
|
249
|
+
diagnostics: [],
|
|
250
|
+
limitDiagnostic: undefined,
|
|
251
|
+
limits,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sameManagedEntryTypes(left, right) {
|
|
256
|
+
if (left.size !== right.size) return false;
|
|
257
|
+
for (const [entryPath, entryType] of left) {
|
|
258
|
+
if (right.get(entryPath) !== entryType) return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function enumerateManagedDirectory(absoluteDirectory, relativeDirectory, expectedPaths, encountered, state, depth) {
|
|
264
|
+
if (state.stopped) return;
|
|
265
|
+
let directoryStats;
|
|
266
|
+
try {
|
|
267
|
+
directoryStats = await lstat(absoluteDirectory);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (errorCode(error) !== "ENOENT") {
|
|
270
|
+
recordDirectoryDiagnostic(state, "directory.unreadable", relativeDirectory, `Could not inspect managed directory '${relativeDirectory}'.`);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (directoryStats.isSymbolicLink()) {
|
|
275
|
+
encountered.set(relativeDirectory, "symlink");
|
|
276
|
+
recordDirectoryDiagnostic(state, "file.symlink", relativeDirectory, `Symbolic links are not allowed for '${relativeDirectory}'.`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!directoryStats.isDirectory()) {
|
|
280
|
+
encountered.set(relativeDirectory, "unsupported");
|
|
281
|
+
recordDirectoryDiagnostic(state, "file.not_regular", relativeDirectory, `'${relativeDirectory}' must be a real directory.`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
encountered.set(relativeDirectory, "directory");
|
|
285
|
+
|
|
286
|
+
let directory;
|
|
287
|
+
try {
|
|
288
|
+
directory = await opendir(absoluteDirectory);
|
|
289
|
+
} catch {
|
|
290
|
+
recordDirectoryDiagnostic(state, "directory.unreadable", relativeDirectory, `Could not enumerate managed directory '${relativeDirectory}'.`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const entries = [];
|
|
294
|
+
const remainingEntries = state.limits.entries - state.entryCount;
|
|
295
|
+
try {
|
|
296
|
+
for await (const entry of directory) {
|
|
297
|
+
entries.push(entry);
|
|
298
|
+
if (entries.length > remainingEntries) {
|
|
299
|
+
stopDirectoryTraversal(
|
|
300
|
+
state,
|
|
301
|
+
"limit.directory_entries",
|
|
302
|
+
"$directory",
|
|
303
|
+
`Managed subtree may not contain more than ${state.limits.entries} filesystem entries.`,
|
|
304
|
+
);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
if (!state.stopped) {
|
|
310
|
+
recordDirectoryDiagnostic(state, "directory.unreadable", relativeDirectory, `Could not completely enumerate managed directory '${relativeDirectory}'.`);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (state.stopped) return;
|
|
315
|
+
entries.sort((left, right) => compareCanonicalStrings(left.name, right.name));
|
|
316
|
+
state.entryCount += entries.length;
|
|
317
|
+
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
if (state.stopped) break;
|
|
320
|
+
const relativePath = `${relativeDirectory}/${entry.name}`;
|
|
321
|
+
if (relativePath.length > state.limits.pathLength) {
|
|
322
|
+
stopDirectoryTraversal(
|
|
323
|
+
state,
|
|
324
|
+
"path.too_long",
|
|
325
|
+
relativePath,
|
|
326
|
+
`Managed subtree paths may not exceed ${state.limits.pathLength} characters.`,
|
|
327
|
+
);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
const absolutePath = path.join(absoluteDirectory, entry.name);
|
|
331
|
+
if (entry.isSymbolicLink()) {
|
|
332
|
+
encountered.set(relativePath, "symlink");
|
|
333
|
+
recordDirectoryDiagnostic(state, "file.symlink", relativePath, `Symbolic links are not allowed for '${relativePath}'.`);
|
|
334
|
+
} else if (entry.isDirectory()) {
|
|
335
|
+
if (depth >= state.limits.depth) {
|
|
336
|
+
stopDirectoryTraversal(
|
|
337
|
+
state,
|
|
338
|
+
"limit.directory_depth",
|
|
339
|
+
relativePath,
|
|
340
|
+
`Managed subtree depth may not exceed ${state.limits.depth} levels.`,
|
|
341
|
+
);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
if (expectedPaths.has(relativePath)) {
|
|
345
|
+
encountered.set(relativePath, "unsupported");
|
|
346
|
+
recordDirectoryDiagnostic(state, "file.not_regular", relativePath, `'${relativePath}' is declared as a file but is a directory.`);
|
|
347
|
+
}
|
|
348
|
+
await enumerateManagedDirectory(absolutePath, relativePath, expectedPaths, encountered, state, depth + 1);
|
|
349
|
+
if (state.stopped) break;
|
|
350
|
+
} else if (entry.isFile()) {
|
|
351
|
+
state.fileCount += 1;
|
|
352
|
+
if (state.fileCount > state.limits.files) {
|
|
353
|
+
stopDirectoryTraversal(
|
|
354
|
+
state,
|
|
355
|
+
"limit.files",
|
|
356
|
+
"$directory",
|
|
357
|
+
`Managed subtree may not contain more than ${state.limits.files} files.`,
|
|
358
|
+
);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
encountered.set(relativePath, "regular");
|
|
362
|
+
if (!expectedPaths.has(relativePath)) {
|
|
363
|
+
recordDirectoryDiagnostic(state, "file.unexpected", relativePath, `File '${relativePath}' is not declared by the semantic manifest.`);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
encountered.set(relativePath, "unsupported");
|
|
367
|
+
recordDirectoryDiagnostic(state, "file.not_regular", relativePath, `'${relativePath}' has an unsupported filesystem entry type.`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function validateArtifactDirectory(rootDirectory, options = {}) {
|
|
373
|
+
const context = new ValidationContext();
|
|
374
|
+
const supportedCapabilitiesResult = snapshotSupportedCapabilities(options);
|
|
375
|
+
if (!supportedCapabilitiesResult.ok) return supportedCapabilitiesResult;
|
|
376
|
+
const stableValidationOptions = Object.freeze({ supportedCapabilities: supportedCapabilitiesResult.value });
|
|
377
|
+
const root = path.resolve(rootDirectory);
|
|
378
|
+
let rootStats;
|
|
379
|
+
try {
|
|
380
|
+
rootStats = await lstat(root, { bigint: true });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (errorCode(error) === "ENOENT") {
|
|
383
|
+
context.add("directory.missing", "$directory", `Artifact directory '${root}' does not exist.`);
|
|
384
|
+
} else {
|
|
385
|
+
context.add("directory.unreadable", "$directory", `Could not inspect artifact directory '${root}'.`);
|
|
386
|
+
}
|
|
387
|
+
return context.finish(undefined);
|
|
388
|
+
}
|
|
389
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
390
|
+
context.add("directory.invalid", "$directory", "Artifact root must be a real directory, not a file or symbolic link.");
|
|
391
|
+
return context.finish(undefined);
|
|
392
|
+
}
|
|
393
|
+
let rootRealPath;
|
|
394
|
+
try {
|
|
395
|
+
rootRealPath = await realpath(root);
|
|
396
|
+
const realRootStats = await lstat(rootRealPath, { bigint: true });
|
|
397
|
+
if (!realRootStats.isDirectory() || !sameFileIdentity(rootStats, realRootStats)) throw new Error("Artifact root changed.");
|
|
398
|
+
} catch {
|
|
399
|
+
context.add("directory.changed", "$directory", "Artifact root changed while it was being inspected.");
|
|
400
|
+
return context.finish(undefined);
|
|
401
|
+
}
|
|
402
|
+
const pathBoundary = {
|
|
403
|
+
rootPath: root,
|
|
404
|
+
rootRealPath,
|
|
405
|
+
rootStats,
|
|
406
|
+
beforeOpen: options[FILE_OPEN_RACE_HOOK],
|
|
407
|
+
afterRead: options[FILE_READ_RACE_HOOK],
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const manifestPath = path.join(root, MANIFEST_FILE_NAME);
|
|
411
|
+
if (!await requireRegularManifest(manifestPath, context)) return context.finish(undefined);
|
|
412
|
+
const manifestBytes = await inspectRegularFile(
|
|
413
|
+
manifestPath,
|
|
414
|
+
MANIFEST_FILE_NAME,
|
|
415
|
+
LIMITS.manifestBytes,
|
|
416
|
+
context,
|
|
417
|
+
undefined,
|
|
418
|
+
undefined,
|
|
419
|
+
undefined,
|
|
420
|
+
pathBoundary,
|
|
421
|
+
);
|
|
422
|
+
if (!manifestBytes) return context.finish(undefined);
|
|
423
|
+
const manifestResult = validateManifest(manifestBytes, stableValidationOptions);
|
|
424
|
+
if (!manifestResult.ok) return manifestResult;
|
|
425
|
+
|
|
426
|
+
const declaredFiles = collectDeclaredPaths(manifestResult.value);
|
|
427
|
+
const expectedPaths = new Map(declaredFiles.map((entry) => [entry.path, entry]));
|
|
428
|
+
const encountered = new Map();
|
|
429
|
+
const limitOverride = options[DIRECTORY_LIMITS_OVERRIDE];
|
|
430
|
+
const internalLimit = (value, authoritative) => (
|
|
431
|
+
Number.isSafeInteger(value) && value > 0 ? Math.min(value, authoritative) : authoritative
|
|
432
|
+
);
|
|
433
|
+
const directoryLimits = {
|
|
434
|
+
entries: internalLimit(limitOverride?.entries, LIMITS.directoryEntries),
|
|
435
|
+
files: internalLimit(limitOverride?.files, LIMITS.files),
|
|
436
|
+
depth: internalLimit(limitOverride?.depth, LIMITS.directoryDepth),
|
|
437
|
+
pathLength: internalLimit(limitOverride?.pathLength, LIMITS.artifactPath),
|
|
438
|
+
};
|
|
439
|
+
const traversalState = createDirectoryTraversalState(directoryLimits);
|
|
440
|
+
await enumerateManagedDirectory(
|
|
441
|
+
path.join(root, "taproot-docs"),
|
|
442
|
+
"taproot-docs",
|
|
443
|
+
expectedPaths,
|
|
444
|
+
encountered,
|
|
445
|
+
traversalState,
|
|
446
|
+
1,
|
|
447
|
+
);
|
|
448
|
+
if (traversalState.stopped) {
|
|
449
|
+
const diagnostic = traversalState.limitDiagnostic;
|
|
450
|
+
context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
451
|
+
return context.finish(undefined);
|
|
452
|
+
}
|
|
453
|
+
for (const diagnostic of traversalState.diagnostics) {
|
|
454
|
+
context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const entries = [];
|
|
458
|
+
const readBudget = { remainingBytes: LIMITS.artifactBytes, stopped: false };
|
|
459
|
+
for (const declaredFile of declaredFiles) {
|
|
460
|
+
if (readBudget.stopped) break;
|
|
461
|
+
const entryType = encountered.get(declaredFile.path);
|
|
462
|
+
if (entryType === undefined) {
|
|
463
|
+
context.add("file.missing", declaredFile.path, `Required file '${declaredFile.path}' is missing.`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (entryType !== "regular") continue;
|
|
467
|
+
const absolutePath = path.resolve(root, ...declaredFile.path.split("/"));
|
|
468
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
|
|
469
|
+
context.add("path.unsafe", declaredFile.path, "Resolved artifact path escapes the artifact directory.");
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (typeof pathBoundary.beforeOpen === "function") {
|
|
473
|
+
await pathBoundary.beforeOpen(declaredFile.path, absolutePath);
|
|
474
|
+
}
|
|
475
|
+
const bytes = await inspectRegularFile(
|
|
476
|
+
absolutePath,
|
|
477
|
+
declaredFile.path,
|
|
478
|
+
declaredFile.maximumBytes,
|
|
479
|
+
context,
|
|
480
|
+
declaredFile.declaredBytes,
|
|
481
|
+
declaredFile.bytesPath,
|
|
482
|
+
readBudget,
|
|
483
|
+
pathBoundary,
|
|
484
|
+
);
|
|
485
|
+
if (bytes) entries.push({ path: declaredFile.path, content: bytes });
|
|
486
|
+
}
|
|
487
|
+
if (context.errors.length > 0) return context.finish(undefined);
|
|
488
|
+
|
|
489
|
+
const finalEncountered = new Map();
|
|
490
|
+
const finalTraversalState = createDirectoryTraversalState(directoryLimits);
|
|
491
|
+
await enumerateManagedDirectory(
|
|
492
|
+
path.join(root, "taproot-docs"),
|
|
493
|
+
"taproot-docs",
|
|
494
|
+
expectedPaths,
|
|
495
|
+
finalEncountered,
|
|
496
|
+
finalTraversalState,
|
|
497
|
+
1,
|
|
498
|
+
);
|
|
499
|
+
if (finalTraversalState.stopped) {
|
|
500
|
+
const diagnostic = finalTraversalState.limitDiagnostic;
|
|
501
|
+
context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
502
|
+
return context.finish(undefined);
|
|
503
|
+
}
|
|
504
|
+
if (!sameManagedEntryTypes(encountered, finalEncountered)) {
|
|
505
|
+
context.add("directory.changed", "$directory", "Managed subtree paths or entry types changed while the artifact was being inspected.");
|
|
506
|
+
return context.finish(undefined);
|
|
507
|
+
}
|
|
508
|
+
for (const diagnostic of finalTraversalState.diagnostics) {
|
|
509
|
+
context.add(diagnostic.code, diagnostic.path, diagnostic.message);
|
|
510
|
+
}
|
|
511
|
+
if (context.errors.length > 0) return context.finish(undefined);
|
|
512
|
+
return validateArtifact(manifestResult.value, entries, stableValidationOptions);
|
|
513
|
+
}
|
package/src/path.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { LIMITS, RESERVED_ROUTE_PREFIXES, RESERVED_ROUTES } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
const ARTIFACT_SEGMENT = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
4
|
+
const ROUTE_SEGMENT = /^[a-z0-9]+(?:[._~-][a-z0-9]+)*$/;
|
|
5
|
+
const WINDOWS_DEVICE_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
|
|
6
|
+
|
|
7
|
+
function pathFailure(code, message) {
|
|
8
|
+
return { ok: false, code, message };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hasUnsafeCharacters(value) {
|
|
12
|
+
return /[\u0000-\u001f\u007f]/u.test(value) || value.includes("\\") || value.includes("%");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeArtifactPath(value) {
|
|
16
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
17
|
+
return pathFailure("path.invalid", "Artifact paths must be non-empty strings.");
|
|
18
|
+
}
|
|
19
|
+
if (value.length > LIMITS.artifactPath) {
|
|
20
|
+
return pathFailure("path.too_long", `Artifact paths may not exceed ${LIMITS.artifactPath} characters.`);
|
|
21
|
+
}
|
|
22
|
+
if (value !== value.normalize("NFC")) {
|
|
23
|
+
return pathFailure("path.not_normalized", "Artifact paths must use Unicode NFC normalization.");
|
|
24
|
+
}
|
|
25
|
+
if (value.startsWith("/") || hasUnsafeCharacters(value) || /[?#]/u.test(value)) {
|
|
26
|
+
return pathFailure("path.unsafe", "Artifact paths must be relative POSIX paths without escapes, controls, queries, or fragments.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const segments = value.split("/");
|
|
30
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
31
|
+
return pathFailure("path.unsafe", "Artifact paths may not contain empty or dot segments.");
|
|
32
|
+
}
|
|
33
|
+
if (segments.some((segment) => WINDOWS_DEVICE_SEGMENT.test(segment))) {
|
|
34
|
+
return pathFailure("path.device_name", "Artifact path segments may not use Windows device-name aliases.");
|
|
35
|
+
}
|
|
36
|
+
if (segments.some((segment) => !ARTIFACT_SEGMENT.test(segment))) {
|
|
37
|
+
return pathFailure("path.not_canonical", "Artifact path segments must be lowercase ASCII words separated only by '.', '_', or '-'.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { ok: true, value: segments.join("/") };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeSourcePath(value) {
|
|
44
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
45
|
+
return pathFailure("source_path.invalid", "Source paths must be non-empty strings.");
|
|
46
|
+
}
|
|
47
|
+
if (!value.isWellFormed()) {
|
|
48
|
+
return pathFailure("source_path.invalid_unicode", "Source paths must contain only well-formed Unicode scalar values.");
|
|
49
|
+
}
|
|
50
|
+
if (value.length > LIMITS.sourcePath * 2 || [...value].length > LIMITS.sourcePath) {
|
|
51
|
+
return pathFailure("source_path.too_long", `Source paths may not exceed ${LIMITS.sourcePath} characters.`);
|
|
52
|
+
}
|
|
53
|
+
if (value !== value.normalize("NFC")) {
|
|
54
|
+
return pathFailure("source_path.not_normalized", "Source paths must use Unicode NFC normalization.");
|
|
55
|
+
}
|
|
56
|
+
if (value.startsWith("/") || hasUnsafeCharacters(value) || /[?#]/u.test(value)) {
|
|
57
|
+
return pathFailure("source_path.unsafe", "Source paths must be relative POSIX paths without escapes, controls, queries, or fragments.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const segments = value.split("/");
|
|
61
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
62
|
+
return pathFailure("source_path.unsafe", "Source paths may not contain empty or dot segments.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ok: true, value: segments.join("/") };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeRoute(value) {
|
|
69
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
70
|
+
return pathFailure("route.invalid", "Routes must be non-empty strings.");
|
|
71
|
+
}
|
|
72
|
+
if (value.length > LIMITS.route) {
|
|
73
|
+
return pathFailure("route.too_long", `Routes may not exceed ${LIMITS.route} characters.`);
|
|
74
|
+
}
|
|
75
|
+
if (value !== value.normalize("NFC")) {
|
|
76
|
+
return pathFailure("route.not_normalized", "Routes must use Unicode NFC normalization.");
|
|
77
|
+
}
|
|
78
|
+
if (!value.startsWith("/") || hasUnsafeCharacters(value) || /[?#]/u.test(value)) {
|
|
79
|
+
return pathFailure("route.unsafe", "Routes must be root-relative paths without escapes, controls, queries, or fragments.");
|
|
80
|
+
}
|
|
81
|
+
if (value !== "/" && !value.endsWith("/")) {
|
|
82
|
+
return pathFailure("route.not_canonical", "Routes must end with '/' so file and directory forms cannot alias.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const body = value === "/" ? "" : value.slice(1, -1);
|
|
86
|
+
const segments = body === "" ? [] : body.split("/");
|
|
87
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
88
|
+
return pathFailure("route.unsafe", "Routes may not contain empty or dot segments.");
|
|
89
|
+
}
|
|
90
|
+
if (segments.some((segment) => WINDOWS_DEVICE_SEGMENT.test(segment))) {
|
|
91
|
+
return pathFailure("route.device_name", "Route segments may not use Windows device-name aliases.");
|
|
92
|
+
}
|
|
93
|
+
if (segments.some((segment) => !ROUTE_SEGMENT.test(segment))) {
|
|
94
|
+
return pathFailure("route.not_canonical", "Route segments must be lowercase ASCII URL words.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const normalized = segments.length === 0 ? "/" : `/${segments.join("/")}/`;
|
|
98
|
+
if (RESERVED_ROUTES.includes(normalized) || RESERVED_ROUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
|
99
|
+
return pathFailure("route.reserved", "The route is reserved for Taproot's published shell or artifact metadata.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: true, value: normalized };
|
|
103
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const SUPPORTED_LOCALE_MAX_LENGTH = 12;
|
|
2
|
+
|
|
3
|
+
// Docs v1 deliberately supports a stable, registry-independent BCP 47 subset:
|
|
4
|
+
// a two- or three-letter language, then optional Script and region subtags.
|
|
5
|
+
// The casing is part of the wire contract and does not depend on host ICU data.
|
|
6
|
+
export const SUPPORTED_LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/u;
|
|
7
|
+
const SUPPORTED_LOCALE_SHAPE = /^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?$/u;
|
|
8
|
+
|
|
9
|
+
export function classifySupportedLocale(value) {
|
|
10
|
+
if (SUPPORTED_LOCALE_PATTERN.test(value)) return "supported";
|
|
11
|
+
if (SUPPORTED_LOCALE_SHAPE.test(value)) return "not_canonical";
|
|
12
|
+
return "unsupported";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isDisallowedStringCodePoint(codePoint) {
|
|
16
|
+
return codePoint <= 0x1f
|
|
17
|
+
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
18
|
+
|| codePoint === 0x061c
|
|
19
|
+
|| codePoint === 0x200e
|
|
20
|
+
|| codePoint === 0x200f
|
|
21
|
+
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|
|
22
|
+
|| (codePoint >= 0x2066 && codePoint <= 0x2069);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasDisallowedStringCharacters(value) {
|
|
26
|
+
for (const character of value) {
|
|
27
|
+
if (isDisallowedStringCodePoint(character.codePointAt(0))) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|