@taprootio/docs-artifact 1.0.1 → 1.1.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/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 +248 -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
package/src/node.js
CHANGED
|
@@ -511,3 +511,5 @@ export async function validateArtifactDirectory(rootDirectory, options = {}) {
|
|
|
511
511
|
if (context.errors.length > 0) return context.finish(undefined);
|
|
512
512
|
return validateArtifact(manifestResult.value, entries, stableValidationOptions);
|
|
513
513
|
}
|
|
514
|
+
|
|
515
|
+
export const validateManagedArtifactDirectory = validateArtifactDirectory;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { PREBUILT_ARCHIVE_FORMAT, PREBUILT_LIMITS, PREBUILT_MANIFEST_FILE_NAME } from "./prebuilt-constants.js";
|
|
4
|
+
import { serializePrebuiltManifest } from "./prebuilt-manifest-validator.js";
|
|
5
|
+
|
|
6
|
+
export { DocsArtifactValidationError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
const TAR_BLOCK_BYTES = 512;
|
|
9
|
+
const DEFLATE_STORED_BLOCK_BYTES = 65_535;
|
|
10
|
+
const GZIP_HEADER = Uint8Array.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]);
|
|
11
|
+
const GZIP_TRAILER_BYTES = 8;
|
|
12
|
+
const CRC_TABLE = new Uint32Array(256);
|
|
13
|
+
for (let index = 0; index < CRC_TABLE.length; index += 1) {
|
|
14
|
+
let value = index;
|
|
15
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
16
|
+
CRC_TABLE[index] = value >>> 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAXIMUM_TAR_BYTES = PREBUILT_LIMITS.manifestBytes
|
|
20
|
+
+ PREBUILT_LIMITS.artifactBytes
|
|
21
|
+
+ ((PREBUILT_LIMITS.files + 1) * ((TAR_BLOCK_BYTES * 2) - 1))
|
|
22
|
+
+ (TAR_BLOCK_BYTES * 2);
|
|
23
|
+
|
|
24
|
+
export class PrebuiltArchiveError extends Error {
|
|
25
|
+
constructor(code, message, field) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "PrebuiltArchiveError";
|
|
28
|
+
this.code = code;
|
|
29
|
+
if (field !== undefined) this.field = field;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function archiveError(code, message, field) {
|
|
34
|
+
throw new PrebuiltArchiveError(code, message, field);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeOctal(header, offset, length, value) {
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
39
|
+
archiveError("archive.numeric_overflow", "A POSIX USTAR numeric field is outside the supported integer range.");
|
|
40
|
+
}
|
|
41
|
+
const text = value.toString(8);
|
|
42
|
+
if (text.length > length - 1) {
|
|
43
|
+
archiveError("archive.numeric_overflow", "A POSIX USTAR numeric field exceeds its fixed octal envelope.");
|
|
44
|
+
}
|
|
45
|
+
header.fill(0x30, offset, offset + length - 1);
|
|
46
|
+
header.write(text, offset + length - 1 - text.length, text.length, "ascii");
|
|
47
|
+
header[offset + length - 1] = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitUstarPath(entryPath) {
|
|
51
|
+
if (typeof entryPath !== "string" || !/^[\x20-\x7e]+$/u.test(entryPath)) {
|
|
52
|
+
archiveError("archive.path_unrepresentable", "Archive paths must be printable ASCII POSIX USTAR text.", entryPath);
|
|
53
|
+
}
|
|
54
|
+
if (entryPath.length <= 100) return { name: entryPath, prefix: "" };
|
|
55
|
+
for (let offset = entryPath.lastIndexOf("/"); offset > 0; offset = entryPath.lastIndexOf("/", offset - 1)) {
|
|
56
|
+
const prefix = entryPath.slice(0, offset);
|
|
57
|
+
const name = entryPath.slice(offset + 1);
|
|
58
|
+
if (prefix.length <= 155 && name.length <= 100) return { name, prefix };
|
|
59
|
+
}
|
|
60
|
+
archiveError(
|
|
61
|
+
"archive.path_unrepresentable",
|
|
62
|
+
`Archive path '${entryPath}' does not fit the POSIX USTAR name and prefix fields.`,
|
|
63
|
+
entryPath,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ustarHeader(entryPath, byteLength) {
|
|
68
|
+
const { name, prefix } = splitUstarPath(entryPath);
|
|
69
|
+
const header = Buffer.alloc(TAR_BLOCK_BYTES);
|
|
70
|
+
header.write(name, 0, name.length, "ascii");
|
|
71
|
+
writeOctal(header, 100, 8, 0o644);
|
|
72
|
+
writeOctal(header, 108, 8, 0);
|
|
73
|
+
writeOctal(header, 116, 8, 0);
|
|
74
|
+
writeOctal(header, 124, 12, byteLength);
|
|
75
|
+
writeOctal(header, 136, 12, 0);
|
|
76
|
+
header.fill(0x20, 148, 156);
|
|
77
|
+
header[156] = 0x30;
|
|
78
|
+
header.write("ustar\0", 257, 6, "ascii");
|
|
79
|
+
header.write("00", 263, 2, "ascii");
|
|
80
|
+
writeOctal(header, 329, 8, 0);
|
|
81
|
+
writeOctal(header, 337, 8, 0);
|
|
82
|
+
if (prefix) header.write(prefix, 345, prefix.length, "ascii");
|
|
83
|
+
let checksum = 0;
|
|
84
|
+
for (const byte of header) checksum += byte;
|
|
85
|
+
const checksumText = checksum.toString(8).padStart(6, "0");
|
|
86
|
+
if (checksumText.length !== 6) {
|
|
87
|
+
archiveError("archive.checksum_overflow", "A POSIX USTAR header checksum exceeds its fixed octal envelope.");
|
|
88
|
+
}
|
|
89
|
+
header.write(checksumText, 148, 6, "ascii");
|
|
90
|
+
header[154] = 0;
|
|
91
|
+
header[155] = 0x20;
|
|
92
|
+
return header;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function roundedTarContentBytes(byteLength) {
|
|
96
|
+
const remainder = byteLength % TAR_BLOCK_BYTES;
|
|
97
|
+
return remainder === 0 ? byteLength : byteLength + TAR_BLOCK_BYTES - remainder;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function computeTarLength(entries) {
|
|
101
|
+
let total = TAR_BLOCK_BYTES * 2;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
total += TAR_BLOCK_BYTES + roundedTarContentBytes(entry.content.byteLength);
|
|
104
|
+
if (!Number.isSafeInteger(total) || total > MAXIMUM_TAR_BYTES) {
|
|
105
|
+
archiveError("archive.too_large", "The archive exceeds the bounded prebuilt POSIX USTAR envelope.");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return total;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function gzipLength(tarLength) {
|
|
112
|
+
const blockCount = Math.ceil(tarLength / DEFLATE_STORED_BLOCK_BYTES);
|
|
113
|
+
const length = GZIP_HEADER.byteLength + tarLength + (blockCount * 5) + GZIP_TRAILER_BYTES;
|
|
114
|
+
if (!Number.isSafeInteger(length)) {
|
|
115
|
+
archiveError("archive.too_large", "The deterministic gzip member exceeds the supported byte range.");
|
|
116
|
+
}
|
|
117
|
+
return { blockCount, length };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function storedBlockDataOffset(tarOffset) {
|
|
121
|
+
const blockIndex = Math.floor(tarOffset / DEFLATE_STORED_BLOCK_BYTES);
|
|
122
|
+
return GZIP_HEADER.byteLength + tarOffset + (blockIndex * 5) + 5;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function visitTarRange(output, start, byteLength, visitor) {
|
|
126
|
+
let tarOffset = start;
|
|
127
|
+
let remaining = byteLength;
|
|
128
|
+
while (remaining > 0) {
|
|
129
|
+
const inBlockOffset = tarOffset % DEFLATE_STORED_BLOCK_BYTES;
|
|
130
|
+
const length = Math.min(remaining, DEFLATE_STORED_BLOCK_BYTES - inBlockOffset);
|
|
131
|
+
const outputOffset = storedBlockDataOffset(tarOffset);
|
|
132
|
+
visitor(output.subarray(outputOffset, outputOffset + length));
|
|
133
|
+
tarOffset += length;
|
|
134
|
+
remaining -= length;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeTarRange(output, start, bytes) {
|
|
139
|
+
let sourceOffset = 0;
|
|
140
|
+
visitTarRange(output, start, bytes.byteLength, (target) => {
|
|
141
|
+
target.set(bytes.subarray(sourceOffset, sourceOffset + target.byteLength));
|
|
142
|
+
sourceOffset += target.byteLength;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function initializeStoredBlocks(output, tarLength, blockCount) {
|
|
147
|
+
output.set(GZIP_HEADER, 0);
|
|
148
|
+
for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) {
|
|
149
|
+
const tarOffset = blockIndex * DEFLATE_STORED_BLOCK_BYTES;
|
|
150
|
+
const length = Math.min(DEFLATE_STORED_BLOCK_BYTES, tarLength - tarOffset);
|
|
151
|
+
const outputOffset = GZIP_HEADER.byteLength + tarOffset + (blockIndex * 5);
|
|
152
|
+
output[outputOffset] = blockIndex === blockCount - 1 ? 0x01 : 0x00;
|
|
153
|
+
output.writeUInt16LE(length, outputOffset + 1);
|
|
154
|
+
output.writeUInt16LE((~length) & 0xffff, outputOffset + 3);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function crc32Tar(output, tarLength) {
|
|
159
|
+
let value = 0xffffffff;
|
|
160
|
+
visitTarRange(output, 0, tarLength, (bytes) => {
|
|
161
|
+
for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
|
|
162
|
+
});
|
|
163
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sha256TarRange(output, start, byteLength) {
|
|
167
|
+
const hash = createHash("sha256");
|
|
168
|
+
visitTarRange(output, start, byteLength, (bytes) => hash.update(bytes));
|
|
169
|
+
return `sha256:${hash.digest("hex")}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function snapshotEntries(snapshot, options) {
|
|
173
|
+
if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
174
|
+
archiveError("archive.inventory", "A validated prebuilt artifact snapshot is required.");
|
|
175
|
+
}
|
|
176
|
+
const manifestText = serializePrebuiltManifest(snapshot.manifest, options);
|
|
177
|
+
const manifestBytes = Buffer.from(manifestText, "utf8");
|
|
178
|
+
const manifest = JSON.parse(manifestText);
|
|
179
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length !== manifest.files.length) {
|
|
180
|
+
archiveError("archive.inventory", "The prebuilt snapshot must contain every declared payload file exactly once.");
|
|
181
|
+
}
|
|
182
|
+
const supplied = new Map();
|
|
183
|
+
for (const entry of snapshot.files) {
|
|
184
|
+
if (
|
|
185
|
+
entry === null
|
|
186
|
+
|| typeof entry !== "object"
|
|
187
|
+
|| typeof entry.path !== "string"
|
|
188
|
+
|| !(entry.content instanceof Uint8Array)
|
|
189
|
+
|| supplied.has(entry.path)
|
|
190
|
+
) {
|
|
191
|
+
archiveError("archive.inventory", "The prebuilt snapshot contains an invalid or duplicate payload entry.");
|
|
192
|
+
}
|
|
193
|
+
supplied.set(entry.path, entry.content);
|
|
194
|
+
}
|
|
195
|
+
const entries = [{ path: PREBUILT_MANIFEST_FILE_NAME, content: manifestBytes }];
|
|
196
|
+
for (const descriptor of manifest.files) {
|
|
197
|
+
const content = supplied.get(descriptor.path);
|
|
198
|
+
if (!content || content.byteLength !== descriptor.bytes) {
|
|
199
|
+
archiveError(
|
|
200
|
+
"archive.size_drift",
|
|
201
|
+
`Payload '${descriptor.path}' does not match its declared byte length.`,
|
|
202
|
+
descriptor.path,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
splitUstarPath(descriptor.path);
|
|
206
|
+
entries.push({ path: descriptor.path, content, expectedHash: descriptor.sha256 });
|
|
207
|
+
supplied.delete(descriptor.path);
|
|
208
|
+
}
|
|
209
|
+
if (supplied.size !== 0) {
|
|
210
|
+
archiveError("archive.inventory", "The prebuilt snapshot contains undeclared payload files.");
|
|
211
|
+
}
|
|
212
|
+
return entries;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createDeterministicPrebuiltArchive(snapshot, options = {}) {
|
|
216
|
+
const entries = snapshotEntries(snapshot, options);
|
|
217
|
+
const tarLength = computeTarLength(entries);
|
|
218
|
+
const { blockCount, length: outputLength } = gzipLength(tarLength);
|
|
219
|
+
const output = Buffer.alloc(outputLength);
|
|
220
|
+
initializeStoredBlocks(output, tarLength, blockCount);
|
|
221
|
+
|
|
222
|
+
let tarOffset = 0;
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const header = ustarHeader(entry.path, entry.content.byteLength);
|
|
225
|
+
writeTarRange(output, tarOffset, header);
|
|
226
|
+
tarOffset += TAR_BLOCK_BYTES;
|
|
227
|
+
const contentOffset = tarOffset;
|
|
228
|
+
writeTarRange(output, contentOffset, entry.content);
|
|
229
|
+
if (entry.expectedHash && sha256TarRange(output, contentOffset, entry.content.byteLength) !== entry.expectedHash) {
|
|
230
|
+
archiveError("archive.hash_drift", `Payload '${entry.path}' does not match its declared SHA-256.`, entry.path);
|
|
231
|
+
}
|
|
232
|
+
tarOffset += roundedTarContentBytes(entry.content.byteLength);
|
|
233
|
+
}
|
|
234
|
+
if (tarOffset + (TAR_BLOCK_BYTES * 2) !== tarLength) {
|
|
235
|
+
archiveError("archive.internal_length", "The deterministic tar length does not match its frozen inventory.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const trailerOffset = outputLength - GZIP_TRAILER_BYTES;
|
|
239
|
+
output.writeUInt32LE(crc32Tar(output, tarLength), trailerOffset);
|
|
240
|
+
output.writeUInt32LE(tarLength >>> 0, trailerOffset + 4);
|
|
241
|
+
const contentHash = `sha256:${createHash("sha256").update(output).digest("hex")}`;
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
format: PREBUILT_ARCHIVE_FORMAT,
|
|
244
|
+
bytes: output,
|
|
245
|
+
byteLength: output.byteLength,
|
|
246
|
+
contentHash,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { snapshotBinaryInput } from "./binary.js";
|
|
2
|
+
import { DocsArtifactValidationError, ValidationContext } from "./errors.js";
|
|
3
|
+
import { PREBUILT_LIMITS, PREBUILT_TEXT_MEDIA_TYPES } from "./prebuilt-constants.js";
|
|
4
|
+
import { validatePrebuiltManifest } from "./prebuilt-manifest-validator.js";
|
|
5
|
+
import { normalizePrebuiltArtifactPath } from "./prebuilt-path.js";
|
|
6
|
+
|
|
7
|
+
function inspectStringContent(content, maximumBytes) {
|
|
8
|
+
let byteLength = 0;
|
|
9
|
+
for (let offset = 0; offset < content.length;) {
|
|
10
|
+
const first = content.charCodeAt(offset);
|
|
11
|
+
if (first >= 0xd800 && first <= 0xdbff) {
|
|
12
|
+
const second = content.charCodeAt(offset + 1);
|
|
13
|
+
if (second < 0xdc00 || second > 0xdfff) return { error: "file.invalid_unicode" };
|
|
14
|
+
byteLength += 4;
|
|
15
|
+
offset += 2;
|
|
16
|
+
} else {
|
|
17
|
+
if (first >= 0xdc00 && first <= 0xdfff) return { error: "file.invalid_unicode" };
|
|
18
|
+
byteLength += first <= 0x7f ? 1 : first <= 0x7ff ? 2 : 3;
|
|
19
|
+
offset += 1;
|
|
20
|
+
}
|
|
21
|
+
if (byteLength > maximumBytes) return { kind: "string", byteLength };
|
|
22
|
+
}
|
|
23
|
+
return { kind: "string", byteLength, content };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inspectContent(content, remainingArtifactBytes, trustBinarySnapshot) {
|
|
27
|
+
const maximumBytes = Math.min(PREBUILT_LIMITS.fileBytes, remainingArtifactBytes);
|
|
28
|
+
if (typeof content === "string") return inspectStringContent(content, maximumBytes);
|
|
29
|
+
if (trustBinarySnapshot) {
|
|
30
|
+
if (!(content instanceof Uint8Array)) return undefined;
|
|
31
|
+
const byteLength = content.byteLength;
|
|
32
|
+
return byteLength > maximumBytes
|
|
33
|
+
? { kind: "too_large", byteLength }
|
|
34
|
+
: { kind: "bytes", byteLength, bytes: content };
|
|
35
|
+
}
|
|
36
|
+
const snapshot = snapshotBinaryInput(content, maximumBytes);
|
|
37
|
+
if (snapshot.kind === "bytes" || snapshot.kind === "too_large") return snapshot;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function materializeContent(content) {
|
|
42
|
+
if (content.kind === "string") return new TextEncoder().encode(content.content);
|
|
43
|
+
return content.bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function sha256(bytes) {
|
|
47
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
48
|
+
return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isUtf8(bytes) {
|
|
52
|
+
try {
|
|
53
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
54
|
+
for (let offset = 0; offset < bytes.byteLength; offset += 64 * 1024) {
|
|
55
|
+
decoder.decode(bytes.subarray(offset, Math.min(bytes.byteLength, offset + (64 * 1024))), { stream: true });
|
|
56
|
+
}
|
|
57
|
+
decoder.decode();
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function validatePrebuiltArtifactContents(manifest, fileEntries, trustBinarySnapshots) {
|
|
65
|
+
const context = new ValidationContext();
|
|
66
|
+
const expected = new Map(manifest.files.map((descriptor, index) => [
|
|
67
|
+
descriptor.path,
|
|
68
|
+
{ descriptor, descriptorPath: `$.files[${index}]` },
|
|
69
|
+
]));
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const seenCaseFolded = new Map();
|
|
72
|
+
const snapshots = new Map();
|
|
73
|
+
let totalBytes = 0;
|
|
74
|
+
let entryCount = 0;
|
|
75
|
+
let stoppedForTotal = false;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
if (typeof fileEntries === "string" || fileEntries === null || fileEntries === undefined) {
|
|
79
|
+
throw new TypeError("File entries must be an object iterable.");
|
|
80
|
+
}
|
|
81
|
+
for (const entry of fileEntries) {
|
|
82
|
+
entryCount += 1;
|
|
83
|
+
if (entryCount > PREBUILT_LIMITS.files) {
|
|
84
|
+
context.add(
|
|
85
|
+
"limit.files",
|
|
86
|
+
"$files",
|
|
87
|
+
`Prebuilt artifacts may not contain more than ${PREBUILT_LIMITS.files} payload files.`,
|
|
88
|
+
);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const entryPath = `$files[${entryCount - 1}]`;
|
|
92
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
93
|
+
context.add("file.invalid_entry", entryPath, "File entries must be objects with path and content.");
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const normalized = normalizePrebuiltArtifactPath(entry.path);
|
|
97
|
+
if (!normalized.ok) {
|
|
98
|
+
context.add(normalized.code, `${entryPath}.path`, normalized.message);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const folded = normalized.value.toLowerCase();
|
|
102
|
+
if (seen.has(normalized.value)) {
|
|
103
|
+
context.add("duplicate.file_entry", `${entryPath}.path`, `File '${normalized.value}' appears more than once.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (seenCaseFolded.has(folded)) {
|
|
107
|
+
context.add(
|
|
108
|
+
"duplicate.file_entry_casefold",
|
|
109
|
+
`${entryPath}.path`,
|
|
110
|
+
`File '${normalized.value}' collides with '${seenCaseFolded.get(folded)}' after ASCII case-folding.`,
|
|
111
|
+
);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
seen.add(normalized.value);
|
|
115
|
+
seenCaseFolded.set(folded, normalized.value);
|
|
116
|
+
|
|
117
|
+
const expectedFile = expected.get(normalized.value);
|
|
118
|
+
if (!expectedFile) {
|
|
119
|
+
context.add(
|
|
120
|
+
"file.unexpected",
|
|
121
|
+
`${entryPath}.path`,
|
|
122
|
+
`File '${normalized.value}' is not declared by the prebuilt manifest.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const content = inspectContent(
|
|
126
|
+
entry.content,
|
|
127
|
+
Math.max(0, PREBUILT_LIMITS.artifactBytes - totalBytes),
|
|
128
|
+
trustBinarySnapshots,
|
|
129
|
+
);
|
|
130
|
+
if (!content) {
|
|
131
|
+
context.add(
|
|
132
|
+
"file.invalid_content",
|
|
133
|
+
`${entryPath}.content`,
|
|
134
|
+
"File content must be a string, Uint8Array, or ArrayBuffer.",
|
|
135
|
+
);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (content.error) {
|
|
139
|
+
context.add(
|
|
140
|
+
content.error,
|
|
141
|
+
`${entryPath}.content`,
|
|
142
|
+
"String file content must contain only well-formed Unicode scalar values.",
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (content.byteLength > PREBUILT_LIMITS.artifactBytes - totalBytes) {
|
|
147
|
+
context.add(
|
|
148
|
+
"limit.artifact_bytes",
|
|
149
|
+
"$files",
|
|
150
|
+
`Prebuilt payload bytes may not exceed ${PREBUILT_LIMITS.artifactBytes}.`,
|
|
151
|
+
);
|
|
152
|
+
stoppedForTotal = true;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
totalBytes += content.byteLength;
|
|
156
|
+
if (content.byteLength > PREBUILT_LIMITS.fileBytes) {
|
|
157
|
+
context.add(
|
|
158
|
+
"file.too_large",
|
|
159
|
+
`${entryPath}.content`,
|
|
160
|
+
`File '${normalized.value}' exceeds ${PREBUILT_LIMITS.fileBytes} bytes.`,
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (content.kind === "too_large") {
|
|
165
|
+
context.add(
|
|
166
|
+
"file.too_large",
|
|
167
|
+
`${entryPath}.content`,
|
|
168
|
+
`File '${normalized.value}' exceeds its bounded read allocation.`,
|
|
169
|
+
);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (expectedFile && content.byteLength !== expectedFile.descriptor.bytes) {
|
|
173
|
+
context.add(
|
|
174
|
+
"file.size_drift",
|
|
175
|
+
`${expectedFile.descriptorPath}.bytes`,
|
|
176
|
+
`File '${normalized.value}' has ${content.byteLength} bytes; manifest declares ${expectedFile.descriptor.bytes}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const bytes = materializeContent(content);
|
|
180
|
+
snapshots.set(normalized.value, bytes);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
context.add("file.invalid_iterable", "$files", "Could not enumerate prebuilt files safely.");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!stoppedForTotal) {
|
|
187
|
+
for (const [filePath, expectedFile] of expected) {
|
|
188
|
+
const bytes = snapshots.get(filePath);
|
|
189
|
+
if (!bytes) {
|
|
190
|
+
if (!seen.has(filePath)) {
|
|
191
|
+
context.add("file.missing", `${expectedFile.descriptorPath}.path`, `Declared file '${filePath}' is missing.`);
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (bytes.byteLength !== expectedFile.descriptor.bytes) continue;
|
|
196
|
+
const actualHash = await sha256(bytes);
|
|
197
|
+
if (actualHash !== expectedFile.descriptor.sha256) {
|
|
198
|
+
context.add(
|
|
199
|
+
"file.hash_drift",
|
|
200
|
+
`${expectedFile.descriptorPath}.sha256`,
|
|
201
|
+
`File '${filePath}' does not match its declared SHA-256.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (PREBUILT_TEXT_MEDIA_TYPES.includes(expectedFile.descriptor.mediaType) && !isUtf8(bytes)) {
|
|
205
|
+
context.add(
|
|
206
|
+
"file.invalid_utf8",
|
|
207
|
+
`${expectedFile.descriptorPath}.mediaType`,
|
|
208
|
+
`File '${filePath}' must be valid UTF-8 for media type '${expectedFile.descriptor.mediaType}'.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const files = manifest.files
|
|
215
|
+
.filter((descriptor) => snapshots.has(descriptor.path))
|
|
216
|
+
.map((descriptor) => ({ path: descriptor.path, content: snapshots.get(descriptor.path) }));
|
|
217
|
+
return context.finish({ manifest, files, fileCount: snapshots.size, totalBytes });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function validatePrebuiltArtifact(manifestInput, fileEntries, options = {}) {
|
|
221
|
+
const manifestResult = validatePrebuiltManifest(manifestInput, options);
|
|
222
|
+
if (!manifestResult.ok) return manifestResult;
|
|
223
|
+
return validatePrebuiltArtifactContents(manifestResult.value, fileEntries, false);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Internal Node boundary: the manifest and buffers were already snapshotted from
|
|
227
|
+
// stable file handles and are not reachable by the public caller.
|
|
228
|
+
export async function validatePrebuiltArtifactFromTrustedSnapshots(validatedManifest, fileEntries) {
|
|
229
|
+
return validatePrebuiltArtifactContents(validatedManifest, fileEntries, true);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function assertValidPrebuiltArtifact(manifestInput, fileEntries, options = {}) {
|
|
233
|
+
const result = await validatePrebuiltArtifact(manifestInput, fileEntries, options);
|
|
234
|
+
if (!result.ok) throw new DocsArtifactValidationError(result.errors);
|
|
235
|
+
return result.value;
|
|
236
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FILE_OPEN_RACE_HOOK } from "./node-internal.js";
|
|
5
|
+
import { validatePrebuiltArtifact } from "./prebuilt-artifact-validator.js";
|
|
6
|
+
import { PREBUILT_LIMITS } from "./prebuilt-constants.js";
|
|
7
|
+
import { serializePrebuiltManifest } from "./prebuilt-manifest-validator.js";
|
|
8
|
+
import { validatePrebuiltArtifactDirectory } from "./prebuilt-node.js";
|
|
9
|
+
|
|
10
|
+
const FIXTURES_ROOT = new URL("../fixtures/", import.meta.url);
|
|
11
|
+
|
|
12
|
+
function targetAt(root, segments) {
|
|
13
|
+
let target = root;
|
|
14
|
+
for (let index = 0; index < segments.length - 1; index += 1) target = target[segments[index]];
|
|
15
|
+
return { target, key: segments.at(-1) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function applyMutations(manifest, mutations = []) {
|
|
19
|
+
for (const mutation of mutations) {
|
|
20
|
+
const { target, key } = targetAt(manifest, mutation.path);
|
|
21
|
+
if (mutation.operation === "repeat-array") {
|
|
22
|
+
const source = target[key][mutation.sourceIndex];
|
|
23
|
+
target[key] = Array.from({ length: mutation.count }, () => structuredClone(source));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const value = mutation.repeat === undefined
|
|
27
|
+
? structuredClone(mutation.value)
|
|
28
|
+
: `${mutation.repeat.repeat(mutation.count)}${mutation.suffix ?? ""}`;
|
|
29
|
+
if (mutation.operation === "append") target[key].push(value);
|
|
30
|
+
else if (mutation.operation === "remove") target.splice(key, 1);
|
|
31
|
+
else if (mutation.operation === "replace") target[key] = value;
|
|
32
|
+
else throw new Error(`Unsupported prebuilt conformance mutation '${mutation.operation}'.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function baseFiles(manifest, baseManifest, omitted = []) {
|
|
37
|
+
const directory = baseManifest.slice(0, baseManifest.lastIndexOf("/") + 1);
|
|
38
|
+
const files = [];
|
|
39
|
+
for (const descriptor of manifest.files) {
|
|
40
|
+
if (omitted.includes(descriptor.path)) continue;
|
|
41
|
+
files.push({
|
|
42
|
+
path: descriptor.path,
|
|
43
|
+
content: await readFile(new URL(`${directory}${descriptor.path}`, FIXTURES_ROOT)),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function specifiedFiles(specifications = []) {
|
|
50
|
+
const result = [];
|
|
51
|
+
for (const specification of specifications) {
|
|
52
|
+
let content;
|
|
53
|
+
if (specification.source) content = await readFile(new URL(specification.source, FIXTURES_ROOT));
|
|
54
|
+
else if (specification.base64 !== undefined) content = Buffer.from(specification.base64, "base64");
|
|
55
|
+
else content = Buffer.from(specification.utf8 ?? "", "utf8");
|
|
56
|
+
result.push({ path: specification.path, content });
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function expectedArchive(specification) {
|
|
62
|
+
if (!specification) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
bytes: await readFile(new URL(specification.path, FIXTURES_ROOT)),
|
|
65
|
+
byteLength: specification.byteLength,
|
|
66
|
+
sha256: specification.sha256,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadPrebuiltConformanceCases() {
|
|
71
|
+
const index = JSON.parse(await readFile(new URL("prebuilt/conformance.json", FIXTURES_ROOT), "utf8"));
|
|
72
|
+
if (index.schemaVersion !== 1 || !Array.isArray(index.cases)) {
|
|
73
|
+
throw new Error("Unsupported prebuilt conformance fixture index.");
|
|
74
|
+
}
|
|
75
|
+
const cases = [];
|
|
76
|
+
for (const fixture of index.cases) {
|
|
77
|
+
let manifest;
|
|
78
|
+
let manifestValue;
|
|
79
|
+
if (fixture.baseManifest) {
|
|
80
|
+
manifestValue = JSON.parse(await readFile(new URL(fixture.baseManifest, FIXTURES_ROOT), "utf8"));
|
|
81
|
+
applyMutations(manifestValue, fixture.mutations);
|
|
82
|
+
manifest = Buffer.from(JSON.stringify(manifestValue));
|
|
83
|
+
} else {
|
|
84
|
+
manifest = await readFile(new URL(fixture.manifest, FIXTURES_ROOT));
|
|
85
|
+
}
|
|
86
|
+
const files = [
|
|
87
|
+
...(fixture.includeBaseFiles ? await baseFiles(manifestValue, fixture.baseManifest, fixture.omitBaseFiles) : []),
|
|
88
|
+
...await specifiedFiles(fixture.files),
|
|
89
|
+
];
|
|
90
|
+
if (fixture.manifestPaddingBytes) {
|
|
91
|
+
manifest = Buffer.concat([
|
|
92
|
+
manifest,
|
|
93
|
+
Buffer.alloc(Math.min(fixture.manifestPaddingBytes, PREBUILT_LIMITS.manifestBytes + 1), 0x20),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
cases.push({
|
|
97
|
+
name: fixture.name,
|
|
98
|
+
valid: fixture.valid,
|
|
99
|
+
expectedCodes: fixture.expectedCodes,
|
|
100
|
+
manifest,
|
|
101
|
+
files,
|
|
102
|
+
nodeScenario: fixture.nodeScenario === undefined ? undefined : structuredClone(fixture.nodeScenario),
|
|
103
|
+
expectedArchive: await expectedArchive(fixture.archive),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return cases;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function materializeDirectory(root, manifest, files) {
|
|
110
|
+
await writeFile(path.join(root, "taproot-docs-prebuilt-manifest.json"), manifest);
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const target = path.join(root, ...file.path.split("/"));
|
|
113
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
114
|
+
await writeFile(target, file.content);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function validatePrebuiltConformanceCase(fixture) {
|
|
119
|
+
if (fixture.nodeScenario === undefined) {
|
|
120
|
+
return validatePrebuiltArtifact(fixture.manifest, fixture.files);
|
|
121
|
+
}
|
|
122
|
+
if (fixture.nodeScenario.type !== "replace-file-with-directory-before-open") {
|
|
123
|
+
throw new Error(`Unsupported prebuilt Node conformance scenario '${fixture.nodeScenario.type}'.`);
|
|
124
|
+
}
|
|
125
|
+
const semanticResult = await validatePrebuiltArtifact(fixture.manifest, fixture.files);
|
|
126
|
+
if (!semanticResult.ok) return semanticResult;
|
|
127
|
+
if (!semanticResult.value.files.some((file) => file.path === fixture.nodeScenario.path)) {
|
|
128
|
+
throw new Error("The prebuilt Node conformance scenario must target a declared payload file.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const root = await mkdtemp(path.join(tmpdir(), "taproot-docs-prebuilt-conformance-"));
|
|
132
|
+
try {
|
|
133
|
+
await materializeDirectory(
|
|
134
|
+
root,
|
|
135
|
+
serializePrebuiltManifest(semanticResult.value.manifest),
|
|
136
|
+
semanticResult.value.files,
|
|
137
|
+
);
|
|
138
|
+
let replaced = false;
|
|
139
|
+
const options = {
|
|
140
|
+
[FILE_OPEN_RACE_HOOK]: async (displayPath, absolutePath) => {
|
|
141
|
+
if (replaced || displayPath !== fixture.nodeScenario.path) return;
|
|
142
|
+
replaced = true;
|
|
143
|
+
await rm(absolutePath);
|
|
144
|
+
await mkdir(absolutePath);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
return await validatePrebuiltArtifactDirectory(root, options);
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(root, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const PREBUILT_MANIFEST_FILE_NAME = "taproot-docs-prebuilt-manifest.json";
|
|
2
|
+
export const PREBUILT_SCHEMA_VERSION = 1;
|
|
3
|
+
export const PREBUILT_MODE = "prebuilt";
|
|
4
|
+
export const PREBUILT_ARCHIVE_FORMAT = "taproot-docs-prebuilt-tar-gzip-v1";
|
|
5
|
+
export const PREBUILT_FILES_CAPABILITY = "taproot.docs.prebuilt.files.v1";
|
|
6
|
+
export const PREBUILT_NOT_FOUND_FILE = "404.html";
|
|
7
|
+
|
|
8
|
+
export const PREBUILT_SUPPORTED_CAPABILITIES = Object.freeze([
|
|
9
|
+
PREBUILT_FILES_CAPABILITY,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const PREBUILT_LIMITS = Object.freeze({
|
|
13
|
+
manifestBytes: 8 * 1024 * 1024,
|
|
14
|
+
manifestObjectWork: 250_000,
|
|
15
|
+
manifestObjectDepth: 64,
|
|
16
|
+
artifactBytes: 512 * 1024 * 1024,
|
|
17
|
+
fileBytes: 64 * 1024 * 1024,
|
|
18
|
+
files: 25_000,
|
|
19
|
+
resources: 25_000,
|
|
20
|
+
redirects: 10_000,
|
|
21
|
+
artifactPathBytes: 255,
|
|
22
|
+
directoryDepth: 64,
|
|
23
|
+
directoryEntries: 40_000,
|
|
24
|
+
supportedCapabilities: 100,
|
|
25
|
+
resourceKey: 200,
|
|
26
|
+
title: 300,
|
|
27
|
+
routeBytes: 2_000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const PREBUILT_MEDIA_TYPES = Object.freeze([
|
|
31
|
+
"application/json; charset=utf-8",
|
|
32
|
+
"application/manifest+json; charset=utf-8",
|
|
33
|
+
"application/octet-stream",
|
|
34
|
+
"application/wasm",
|
|
35
|
+
"application/xml; charset=utf-8",
|
|
36
|
+
"font/otf",
|
|
37
|
+
"font/ttf",
|
|
38
|
+
"font/woff",
|
|
39
|
+
"font/woff2",
|
|
40
|
+
"image/avif",
|
|
41
|
+
"image/gif",
|
|
42
|
+
"image/jpeg",
|
|
43
|
+
"image/png",
|
|
44
|
+
"image/svg+xml",
|
|
45
|
+
"image/vnd.microsoft.icon",
|
|
46
|
+
"image/webp",
|
|
47
|
+
"text/css; charset=utf-8",
|
|
48
|
+
"text/html; charset=utf-8",
|
|
49
|
+
"text/javascript; charset=utf-8",
|
|
50
|
+
"text/plain; charset=utf-8",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export const PREBUILT_TEXT_MEDIA_TYPES = Object.freeze(
|
|
54
|
+
PREBUILT_MEDIA_TYPES.filter((mediaType) => mediaType.includes("charset=utf-8") || mediaType === "image/svg+xml"),
|
|
55
|
+
);
|