@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,654 @@
|
|
|
1
|
+
import { compareCanonicalStrings, DocsArtifactValidationError, ValidationContext } from "./errors.js";
|
|
2
|
+
import {
|
|
3
|
+
canonicalJson,
|
|
4
|
+
canonicalJsonByteLength,
|
|
5
|
+
classifyManifestInput,
|
|
6
|
+
parseManifestJson,
|
|
7
|
+
preflightManifestObject,
|
|
8
|
+
} from "./json.js";
|
|
9
|
+
import {
|
|
10
|
+
PREBUILT_FILES_CAPABILITY,
|
|
11
|
+
PREBUILT_LIMITS,
|
|
12
|
+
PREBUILT_MEDIA_TYPES,
|
|
13
|
+
PREBUILT_MODE,
|
|
14
|
+
PREBUILT_NOT_FOUND_FILE,
|
|
15
|
+
PREBUILT_SCHEMA_VERSION,
|
|
16
|
+
PREBUILT_SUPPORTED_CAPABILITIES,
|
|
17
|
+
} from "./prebuilt-constants.js";
|
|
18
|
+
import { normalizePrebuiltArtifactPath, normalizePrebuiltRedirectRoute, prebuiltFileRoute } from "./prebuilt-path.js";
|
|
19
|
+
import { hasDisallowedStringCharacters } from "./text.js";
|
|
20
|
+
|
|
21
|
+
const RESOURCE_KEY = /^[a-z0-9]+(?:[._:/-][a-z0-9]+)*$/;
|
|
22
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
23
|
+
const SEMVER_IDENTIFIER = String.raw`(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
24
|
+
const SEMVER = new RegExp(
|
|
25
|
+
String
|
|
26
|
+
.raw`^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-${SEMVER_IDENTIFIER}(?:\.${SEMVER_IDENTIFIER})*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`,
|
|
27
|
+
);
|
|
28
|
+
const REVISION = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
29
|
+
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
30
|
+
const REF = /^refs\/(?:heads|tags)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
31
|
+
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkObject(context, value, path, required, allowed) {
|
|
37
|
+
if (!isRecord(value)) {
|
|
38
|
+
context.add("type.object", path, "Expected an object.");
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
for (const key of required) {
|
|
42
|
+
if (!Object.hasOwn(value, key)) {
|
|
43
|
+
context.add("property.required", `${path}.${key}`, `Required property '${key}' is missing.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of Object.keys(value)) {
|
|
47
|
+
if (!allowed.includes(key)) {
|
|
48
|
+
context.add(
|
|
49
|
+
"property.unsupported",
|
|
50
|
+
`${path}.${key}`,
|
|
51
|
+
`Property '${key}' is not supported by prebuilt schema v${PREBUILT_SCHEMA_VERSION}.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stringValue(context, value, path, { min = 1, max = 2_000, pattern, values } = {}) {
|
|
59
|
+
if (typeof value !== "string") {
|
|
60
|
+
context.add("type.string", path, "Expected a string.");
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (!value.isWellFormed()) {
|
|
64
|
+
context.add("string.invalid_unicode", path, "Strings must contain only well-formed Unicode scalar values.");
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
if (value.length > max * 2) {
|
|
68
|
+
context.add("string.length", path, `String length must be between ${min} and ${max} characters.`);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const scalarLength = [...value].length;
|
|
72
|
+
if (scalarLength < min || scalarLength > max) {
|
|
73
|
+
context.add("string.length", path, `String length must be between ${min} and ${max} characters.`);
|
|
74
|
+
}
|
|
75
|
+
if (value !== value.normalize("NFC")) {
|
|
76
|
+
context.add("string.not_normalized", path, "Strings must use Unicode NFC normalization.");
|
|
77
|
+
}
|
|
78
|
+
if (hasDisallowedStringCharacters(value)) {
|
|
79
|
+
context.add(
|
|
80
|
+
"string.control",
|
|
81
|
+
path,
|
|
82
|
+
"Strings may not contain C0, C1, or bidirectional formatting and override controls.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (pattern && !pattern.test(value)) {
|
|
86
|
+
context.add("string.pattern", path, "String does not match the required canonical form.");
|
|
87
|
+
}
|
|
88
|
+
if (values && !values.includes(value)) context.add("value.unsupported", path, `Unsupported value '${value}'.`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function integerValue(context, value, path, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
93
|
+
if (!Number.isSafeInteger(value)) {
|
|
94
|
+
context.add("type.integer", path, "Expected a safe integer.");
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (value < min || value > max) context.add("number.range", path, `Integer must be between ${min} and ${max}.`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function arrayValue(context, value, path, { min = 0, max }) {
|
|
102
|
+
if (!Array.isArray(value)) {
|
|
103
|
+
context.add("type.array", path, "Expected an array.");
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (value.length < min || value.length > max) {
|
|
107
|
+
context.add("array.length", path, `Array length must be between ${min} and ${max}.`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateHash(context, value, path) {
|
|
113
|
+
stringValue(context, value, path, { min: 71, max: 71, pattern: HASH });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateHttpsUrl(context, value, path) {
|
|
117
|
+
const candidate = stringValue(context, value, path, { max: 2_000 });
|
|
118
|
+
if (candidate === undefined) return;
|
|
119
|
+
try {
|
|
120
|
+
const url = new URL(candidate);
|
|
121
|
+
if (!candidate.startsWith("https://") || url.protocol !== "https:" || url.username !== "" || url.password !== "") {
|
|
122
|
+
context.add("url.unsafe", path, "URLs must start with canonical 'https://' and may not contain credentials.");
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
context.add("url.invalid", path, "Expected an absolute HTTPS URL.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateSortedCapabilities(context, value, path) {
|
|
130
|
+
const values = arrayValue(context, value, path, { max: PREBUILT_LIMITS.supportedCapabilities });
|
|
131
|
+
if (!values) return [];
|
|
132
|
+
const result = [];
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (let index = 0; index < Math.min(values.length, PREBUILT_LIMITS.supportedCapabilities); index += 1) {
|
|
135
|
+
const capability = stringValue(context, values[index], `${path}[${index}]`, {
|
|
136
|
+
max: PREBUILT_LIMITS.resourceKey,
|
|
137
|
+
pattern: RESOURCE_KEY,
|
|
138
|
+
});
|
|
139
|
+
if (capability === undefined) continue;
|
|
140
|
+
if (seen.has(capability)) context.add("duplicate.value", `${path}[${index}]`, `Duplicate value '${capability}'.`);
|
|
141
|
+
seen.add(capability);
|
|
142
|
+
result.push(capability);
|
|
143
|
+
}
|
|
144
|
+
if (result.some((item, index) => index > 0 && compareCanonicalStrings(result[index - 1], item) >= 0)) {
|
|
145
|
+
context.add("array.not_sorted", path, "Array values must be unique and sorted lexicographically.");
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function expectedMediaType(path) {
|
|
151
|
+
const lower = path.toLowerCase();
|
|
152
|
+
if (lower.endsWith(".html")) return "text/html; charset=utf-8";
|
|
153
|
+
if (lower.endsWith(".css")) return "text/css; charset=utf-8";
|
|
154
|
+
if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "text/javascript; charset=utf-8";
|
|
155
|
+
if (lower.endsWith(".json")) return "application/json; charset=utf-8";
|
|
156
|
+
if (lower.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
|
|
157
|
+
if (lower.endsWith(".xml")) return "application/xml; charset=utf-8";
|
|
158
|
+
if (lower.endsWith(".svg")) return "image/svg+xml";
|
|
159
|
+
if (lower.endsWith(".wasm")) return "application/wasm";
|
|
160
|
+
if (lower.endsWith(".avif")) return "image/avif";
|
|
161
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
162
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
163
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
164
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
165
|
+
if (lower.endsWith(".ico")) return "image/vnd.microsoft.icon";
|
|
166
|
+
if (lower.endsWith(".otf")) return "font/otf";
|
|
167
|
+
if (lower.endsWith(".ttf")) return "font/ttf";
|
|
168
|
+
if (lower.endsWith(".woff")) return "font/woff";
|
|
169
|
+
if (lower.endsWith(".woff2")) return "font/woff2";
|
|
170
|
+
if (lower.endsWith(".txt")) return "text/plain; charset=utf-8";
|
|
171
|
+
return "application/octet-stream";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function invalidSupportedCapabilities(code, message) {
|
|
175
|
+
return { ok: false, errors: [{ code, path: "$options.supportedCapabilities", message }] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function snapshotPrebuiltSupportedCapabilities(options) {
|
|
179
|
+
let iterable;
|
|
180
|
+
try {
|
|
181
|
+
iterable = options?.supportedCapabilities;
|
|
182
|
+
} catch {
|
|
183
|
+
return invalidSupportedCapabilities(
|
|
184
|
+
"capability.invalid_iterable",
|
|
185
|
+
"Could not enumerate supported capabilities safely.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (iterable === undefined) return { ok: true, value: Object.freeze([]) };
|
|
189
|
+
if (typeof iterable === "string") {
|
|
190
|
+
return invalidSupportedCapabilities(
|
|
191
|
+
"capability.invalid_iterable",
|
|
192
|
+
"Could not enumerate supported capabilities safely.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const values = [];
|
|
196
|
+
try {
|
|
197
|
+
for (const value of iterable) {
|
|
198
|
+
if (values.length >= PREBUILT_LIMITS.supportedCapabilities) {
|
|
199
|
+
return invalidSupportedCapabilities(
|
|
200
|
+
"limit.supported_capabilities",
|
|
201
|
+
`Supported capabilities may not contain more than ${PREBUILT_LIMITS.supportedCapabilities} entries.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
typeof value !== "string" || value.length > PREBUILT_LIMITS.resourceKey || !RESOURCE_KEY.test(value)
|
|
206
|
+
|| value !== value.normalize("NFC") || hasDisallowedStringCharacters(value)
|
|
207
|
+
) {
|
|
208
|
+
return invalidSupportedCapabilities(
|
|
209
|
+
"capability.invalid_value",
|
|
210
|
+
`Supported capabilities must be canonical strings of at most ${PREBUILT_LIMITS.resourceKey} characters.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
values.push(value);
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
return invalidSupportedCapabilities(
|
|
217
|
+
"capability.invalid_iterable",
|
|
218
|
+
"Could not enumerate supported capabilities safely.",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, value: Object.freeze(values) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateRoot(value, supportedCapabilities) {
|
|
225
|
+
const context = new ValidationContext();
|
|
226
|
+
const rootFields = [
|
|
227
|
+
"schemaVersion",
|
|
228
|
+
"mode",
|
|
229
|
+
"source",
|
|
230
|
+
"build",
|
|
231
|
+
"capabilities",
|
|
232
|
+
"notFoundFile",
|
|
233
|
+
"files",
|
|
234
|
+
"resources",
|
|
235
|
+
"redirects",
|
|
236
|
+
];
|
|
237
|
+
if (!checkObject(context, value, "$", rootFields, rootFields)) return context.finish(value);
|
|
238
|
+
|
|
239
|
+
const schemaVersion = integerValue(context, value.schemaVersion, "$.schemaVersion", { min: 1, max: 1 });
|
|
240
|
+
if (schemaVersion !== undefined && schemaVersion !== PREBUILT_SCHEMA_VERSION) {
|
|
241
|
+
context.add(
|
|
242
|
+
"schema.unsupported",
|
|
243
|
+
"$.schemaVersion",
|
|
244
|
+
`Only prebuilt schemaVersion ${PREBUILT_SCHEMA_VERSION} is supported.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
stringValue(context, value.mode, "$.mode", { values: [PREBUILT_MODE] });
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
checkObject(context, value.source, "$.source", [
|
|
251
|
+
"provider",
|
|
252
|
+
"repositoryId",
|
|
253
|
+
"repository",
|
|
254
|
+
"repositoryUrl",
|
|
255
|
+
"revision",
|
|
256
|
+
"ref",
|
|
257
|
+
], ["provider", "repositoryId", "repository", "repositoryUrl", "revision", "ref"])
|
|
258
|
+
) {
|
|
259
|
+
stringValue(context, value.source.provider, "$.source.provider", { values: ["github"] });
|
|
260
|
+
stringValue(context, value.source.repositoryId, "$.source.repositoryId", {
|
|
261
|
+
max: 200,
|
|
262
|
+
pattern: /^[A-Za-z0-9_.:-]+$/,
|
|
263
|
+
});
|
|
264
|
+
const repository = stringValue(context, value.source.repository, "$.source.repository", {
|
|
265
|
+
max: 300,
|
|
266
|
+
pattern: REPOSITORY,
|
|
267
|
+
});
|
|
268
|
+
validateHttpsUrl(context, value.source.repositoryUrl, "$.source.repositoryUrl");
|
|
269
|
+
if (repository !== undefined && value.source.repositoryUrl !== `https://github.com/${repository}`) {
|
|
270
|
+
context.add(
|
|
271
|
+
"source.repository_url",
|
|
272
|
+
"$.source.repositoryUrl",
|
|
273
|
+
"GitHub repositoryUrl must be the canonical URL for source.repository.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
stringValue(context, value.source.revision, "$.source.revision", { min: 40, max: 64, pattern: REVISION });
|
|
277
|
+
stringValue(context, value.source.ref, "$.source.ref", { max: 500, pattern: REF });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
checkObject(context, value.build, "$.build", [
|
|
282
|
+
"producer",
|
|
283
|
+
"producerVersion",
|
|
284
|
+
"configurationSha256",
|
|
285
|
+
"sourceDateEpoch",
|
|
286
|
+
], ["producer", "producerVersion", "configurationSha256", "sourceDateEpoch"])
|
|
287
|
+
) {
|
|
288
|
+
stringValue(context, value.build.producer, "$.build.producer", {
|
|
289
|
+
max: 200,
|
|
290
|
+
pattern: /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/,
|
|
291
|
+
});
|
|
292
|
+
stringValue(context, value.build.producerVersion, "$.build.producerVersion", { max: 100, pattern: SEMVER });
|
|
293
|
+
validateHash(context, value.build.configurationSha256, "$.build.configurationSha256");
|
|
294
|
+
integerValue(context, value.build.sourceDateEpoch, "$.build.sourceDateEpoch", { min: 0, max: 253_402_300_799 });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (checkObject(context, value.capabilities, "$.capabilities", ["required", "optional"], ["required", "optional"])) {
|
|
298
|
+
const required = validateSortedCapabilities(context, value.capabilities.required, "$.capabilities.required");
|
|
299
|
+
const optional = validateSortedCapabilities(context, value.capabilities.optional, "$.capabilities.optional");
|
|
300
|
+
if (!required.includes(PREBUILT_FILES_CAPABILITY)) {
|
|
301
|
+
context.add(
|
|
302
|
+
"capability.missing",
|
|
303
|
+
"$.capabilities.required",
|
|
304
|
+
`Prebuilt schema v${PREBUILT_SCHEMA_VERSION} requires '${PREBUILT_FILES_CAPABILITY}'.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
for (let index = 0; index < required.length; index += 1) {
|
|
308
|
+
if (!supportedCapabilities.has(required[index])) {
|
|
309
|
+
context.add(
|
|
310
|
+
"capability.unsupported",
|
|
311
|
+
`$.capabilities.required[${index}]`,
|
|
312
|
+
`Required capability '${required[index]}' is not supported.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const capability of optional) {
|
|
317
|
+
if (required.includes(capability)) {
|
|
318
|
+
context.add(
|
|
319
|
+
"duplicate.capability",
|
|
320
|
+
"$.capabilities",
|
|
321
|
+
`Capability '${capability}' cannot be both required and optional.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const notFoundFile = stringValue(context, value.notFoundFile, "$.notFoundFile", {
|
|
328
|
+
max: PREBUILT_LIMITS.artifactPathBytes,
|
|
329
|
+
});
|
|
330
|
+
if (notFoundFile !== undefined && notFoundFile !== PREBUILT_NOT_FOUND_FILE) {
|
|
331
|
+
context.add(
|
|
332
|
+
"not_found.not_canonical",
|
|
333
|
+
"$.notFoundFile",
|
|
334
|
+
`Prebuilt schema v1 requires the canonical '${PREBUILT_NOT_FOUND_FILE}' file.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const fileValues = arrayValue(context, value.files, "$.files", { min: 1, max: PREBUILT_LIMITS.files });
|
|
339
|
+
const files = new Map();
|
|
340
|
+
const fileRoutes = new Map();
|
|
341
|
+
const fileOrder = [];
|
|
342
|
+
const caseFoldedPaths = new Map();
|
|
343
|
+
const caseFoldedDirectoryPrefixes = new Map();
|
|
344
|
+
let declaredBytes = 0;
|
|
345
|
+
if (fileValues) {
|
|
346
|
+
for (let index = 0; index < Math.min(fileValues.length, PREBUILT_LIMITS.files); index += 1) {
|
|
347
|
+
const descriptorPath = `$.files[${index}]`;
|
|
348
|
+
const descriptor = fileValues[index];
|
|
349
|
+
if (
|
|
350
|
+
!checkObject(context, descriptor, descriptorPath, ["path", "mediaType", "bytes", "sha256"], [
|
|
351
|
+
"path",
|
|
352
|
+
"mediaType",
|
|
353
|
+
"bytes",
|
|
354
|
+
"sha256",
|
|
355
|
+
])
|
|
356
|
+
) continue;
|
|
357
|
+
const candidate = stringValue(context, descriptor.path, `${descriptorPath}.path`, {
|
|
358
|
+
max: PREBUILT_LIMITS.artifactPathBytes,
|
|
359
|
+
});
|
|
360
|
+
if (candidate !== undefined) {
|
|
361
|
+
const normalized = normalizePrebuiltArtifactPath(candidate);
|
|
362
|
+
if (!normalized.ok) {
|
|
363
|
+
context.add(normalized.code, `${descriptorPath}.path`, normalized.message);
|
|
364
|
+
} else {
|
|
365
|
+
const folded = normalized.value.toLowerCase();
|
|
366
|
+
if (files.has(normalized.value)) {
|
|
367
|
+
context.add(
|
|
368
|
+
"duplicate.file_path",
|
|
369
|
+
`${descriptorPath}.path`,
|
|
370
|
+
`File path '${normalized.value}' is duplicated.`,
|
|
371
|
+
);
|
|
372
|
+
} else if (caseFoldedPaths.has(folded)) {
|
|
373
|
+
context.add(
|
|
374
|
+
"duplicate.file_path_casefold",
|
|
375
|
+
`${descriptorPath}.path`,
|
|
376
|
+
`File path '${normalized.value}' collides with '${
|
|
377
|
+
caseFoldedPaths.get(folded)
|
|
378
|
+
}' after ASCII case-folding.`,
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
const segments = normalized.value.split("/");
|
|
382
|
+
const directoryPrefixes = [];
|
|
383
|
+
for (let segmentIndex = 1; segmentIndex < segments.length; segmentIndex += 1) {
|
|
384
|
+
const prefix = segments.slice(0, segmentIndex).join("/");
|
|
385
|
+
directoryPrefixes.push({ folded: prefix.toLowerCase(), value: prefix });
|
|
386
|
+
}
|
|
387
|
+
const conflictingPrefix = directoryPrefixes.find((prefix) => caseFoldedPaths.has(prefix.folded));
|
|
388
|
+
const aliasedPrefix = directoryPrefixes.find((prefix) => {
|
|
389
|
+
const existing = caseFoldedDirectoryPrefixes.get(prefix.folded);
|
|
390
|
+
return existing !== undefined && existing.value !== prefix.value;
|
|
391
|
+
});
|
|
392
|
+
if (caseFoldedDirectoryPrefixes.has(folded)) {
|
|
393
|
+
context.add(
|
|
394
|
+
"file.path_collision",
|
|
395
|
+
`${descriptorPath}.path`,
|
|
396
|
+
`File path '${normalized.value}' is already required as a directory by '${
|
|
397
|
+
caseFoldedDirectoryPrefixes.get(folded).file
|
|
398
|
+
}'.`,
|
|
399
|
+
);
|
|
400
|
+
} else if (conflictingPrefix !== undefined) {
|
|
401
|
+
context.add(
|
|
402
|
+
"file.path_collision",
|
|
403
|
+
`${descriptorPath}.path`,
|
|
404
|
+
`File path '${
|
|
405
|
+
caseFoldedPaths.get(conflictingPrefix.folded)
|
|
406
|
+
}' cannot also be a parent directory of '${normalized.value}'.`,
|
|
407
|
+
);
|
|
408
|
+
} else if (aliasedPrefix !== undefined) {
|
|
409
|
+
const existing = caseFoldedDirectoryPrefixes.get(aliasedPrefix.folded);
|
|
410
|
+
context.add(
|
|
411
|
+
"duplicate.directory_path_casefold",
|
|
412
|
+
`${descriptorPath}.path`,
|
|
413
|
+
`Parent directory '${aliasedPrefix.value}' collides with '${existing.value}' after ASCII case-folding.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
files.set(normalized.value, descriptor);
|
|
417
|
+
caseFoldedPaths.set(folded, normalized.value);
|
|
418
|
+
for (const prefix of directoryPrefixes) {
|
|
419
|
+
if (!caseFoldedDirectoryPrefixes.has(prefix.folded)) {
|
|
420
|
+
caseFoldedDirectoryPrefixes.set(prefix.folded, { file: normalized.value, value: prefix.value });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const route = prebuiltFileRoute(normalized.value);
|
|
424
|
+
if (!route.ok) context.add(route.code, `${descriptorPath}.path`, route.message);
|
|
425
|
+
else fileRoutes.set(route.value.toLowerCase(), normalized.value);
|
|
426
|
+
}
|
|
427
|
+
fileOrder.push(normalized.value);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const mediaType = stringValue(context, descriptor.mediaType, `${descriptorPath}.mediaType`, {
|
|
431
|
+
values: PREBUILT_MEDIA_TYPES,
|
|
432
|
+
});
|
|
433
|
+
if (candidate !== undefined && mediaType !== undefined && mediaType !== expectedMediaType(candidate)) {
|
|
434
|
+
context.add(
|
|
435
|
+
"file.media_type",
|
|
436
|
+
`${descriptorPath}.mediaType`,
|
|
437
|
+
`File '${candidate}' must use media type '${expectedMediaType(candidate)}'.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const bytes = integerValue(context, descriptor.bytes, `${descriptorPath}.bytes`, {
|
|
441
|
+
min: 0,
|
|
442
|
+
max: PREBUILT_LIMITS.fileBytes,
|
|
443
|
+
});
|
|
444
|
+
if (bytes !== undefined) declaredBytes += bytes;
|
|
445
|
+
validateHash(context, descriptor.sha256, `${descriptorPath}.sha256`);
|
|
446
|
+
}
|
|
447
|
+
if (fileOrder.some((item, index) => index > 0 && compareCanonicalStrings(fileOrder[index - 1], item) >= 0)) {
|
|
448
|
+
context.add("array.not_sorted", "$.files", "Files must have unique paths sorted lexicographically.");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (declaredBytes > PREBUILT_LIMITS.artifactBytes) {
|
|
452
|
+
context.add(
|
|
453
|
+
"limit.artifact_bytes",
|
|
454
|
+
"$.files",
|
|
455
|
+
`Declared prebuilt bytes may not exceed ${PREBUILT_LIMITS.artifactBytes}.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (!files.has(PREBUILT_NOT_FOUND_FILE)) {
|
|
459
|
+
context.add(
|
|
460
|
+
"not_found.missing",
|
|
461
|
+
"$.notFoundFile",
|
|
462
|
+
`Canonical 404 file '${PREBUILT_NOT_FOUND_FILE}' must be declared.`,
|
|
463
|
+
);
|
|
464
|
+
} else if (files.get(PREBUILT_NOT_FOUND_FILE).mediaType !== "text/html; charset=utf-8") {
|
|
465
|
+
context.add("not_found.media_type", "$.notFoundFile", "The canonical 404 file must be declared as UTF-8 HTML.");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const resourceValues = arrayValue(context, value.resources, "$.resources", { max: PREBUILT_LIMITS.resources });
|
|
469
|
+
const resources = new Map();
|
|
470
|
+
const resourceFiles = new Map();
|
|
471
|
+
const resourceOrder = [];
|
|
472
|
+
if (resourceValues) {
|
|
473
|
+
for (let index = 0; index < Math.min(resourceValues.length, PREBUILT_LIMITS.resources); index += 1) {
|
|
474
|
+
const resourcePath = `$.resources[${index}]`;
|
|
475
|
+
const resource = resourceValues[index];
|
|
476
|
+
if (!checkObject(context, resource, resourcePath, ["key", "file", "title"], ["key", "file", "title"])) continue;
|
|
477
|
+
const key = stringValue(context, resource.key, `${resourcePath}.key`, {
|
|
478
|
+
max: PREBUILT_LIMITS.resourceKey,
|
|
479
|
+
pattern: RESOURCE_KEY,
|
|
480
|
+
});
|
|
481
|
+
if (key !== undefined) {
|
|
482
|
+
if (resources.has(key)) {
|
|
483
|
+
context.add("duplicate.resource_key", `${resourcePath}.key`, `Resource key '${key}' is duplicated.`);
|
|
484
|
+
} else resources.set(key, resource);
|
|
485
|
+
resourceOrder.push(key);
|
|
486
|
+
}
|
|
487
|
+
const file = stringValue(context, resource.file, `${resourcePath}.file`, {
|
|
488
|
+
max: PREBUILT_LIMITS.artifactPathBytes,
|
|
489
|
+
});
|
|
490
|
+
if (file !== undefined) {
|
|
491
|
+
const normalized = normalizePrebuiltArtifactPath(file);
|
|
492
|
+
if (!normalized.ok) context.add(normalized.code, `${resourcePath}.file`, normalized.message);
|
|
493
|
+
else {
|
|
494
|
+
const descriptor = files.get(normalized.value);
|
|
495
|
+
if (!descriptor) {
|
|
496
|
+
context.add(
|
|
497
|
+
"resource.unknown_file",
|
|
498
|
+
`${resourcePath}.file`,
|
|
499
|
+
`Resource references undeclared file '${normalized.value}'.`,
|
|
500
|
+
);
|
|
501
|
+
} else if (descriptor.mediaType !== "text/html; charset=utf-8") {
|
|
502
|
+
context.add(
|
|
503
|
+
"resource.not_html",
|
|
504
|
+
`${resourcePath}.file`,
|
|
505
|
+
"Stable resources must reference declared UTF-8 HTML files.",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (normalized.value === PREBUILT_NOT_FOUND_FILE) {
|
|
509
|
+
context.add(
|
|
510
|
+
"resource.not_found",
|
|
511
|
+
`${resourcePath}.file`,
|
|
512
|
+
"The canonical 404 file may not be assigned a successful-response resource identity.",
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
if (resourceFiles.has(normalized.value)) {
|
|
516
|
+
context.add(
|
|
517
|
+
"duplicate.resource_file",
|
|
518
|
+
`${resourcePath}.file`,
|
|
519
|
+
`HTML file '${normalized.value}' is already mapped to resource '${resourceFiles.get(normalized.value)}'.`,
|
|
520
|
+
);
|
|
521
|
+
} else resourceFiles.set(normalized.value, key);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
stringValue(context, resource.title, `${resourcePath}.title`, { max: PREBUILT_LIMITS.title });
|
|
525
|
+
}
|
|
526
|
+
if (
|
|
527
|
+
resourceOrder.some((item, index) => index > 0 && compareCanonicalStrings(resourceOrder[index - 1], item) >= 0)
|
|
528
|
+
) {
|
|
529
|
+
context.add("array.not_sorted", "$.resources", "Resources must have unique keys sorted lexicographically.");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const redirectValues = arrayValue(context, value.redirects, "$.redirects", { max: PREBUILT_LIMITS.redirects });
|
|
534
|
+
const redirectSources = new Map();
|
|
535
|
+
const redirectOrder = [];
|
|
536
|
+
if (redirectValues) {
|
|
537
|
+
for (let index = 0; index < Math.min(redirectValues.length, PREBUILT_LIMITS.redirects); index += 1) {
|
|
538
|
+
const redirectPath = `$.redirects[${index}]`;
|
|
539
|
+
const redirect = redirectValues[index];
|
|
540
|
+
if (
|
|
541
|
+
!checkObject(context, redirect, redirectPath, ["from", "toResourceKey", "status"], [
|
|
542
|
+
"from",
|
|
543
|
+
"toResourceKey",
|
|
544
|
+
"status",
|
|
545
|
+
])
|
|
546
|
+
) continue;
|
|
547
|
+
const from = stringValue(context, redirect.from, `${redirectPath}.from`, { max: PREBUILT_LIMITS.routeBytes });
|
|
548
|
+
if (from !== undefined) {
|
|
549
|
+
const normalized = normalizePrebuiltRedirectRoute(from);
|
|
550
|
+
if (!normalized.ok) context.add(normalized.code, `${redirectPath}.from`, normalized.message);
|
|
551
|
+
else {
|
|
552
|
+
const folded = normalized.value.toLowerCase();
|
|
553
|
+
if (redirectSources.has(folded)) {
|
|
554
|
+
context.add(
|
|
555
|
+
"duplicate.redirect",
|
|
556
|
+
`${redirectPath}.from`,
|
|
557
|
+
`Redirect source '${normalized.value}' collides with '${redirectSources.get(folded)}'.`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (fileRoutes.has(folded)) {
|
|
561
|
+
context.add(
|
|
562
|
+
"redirect.route_collision",
|
|
563
|
+
`${redirectPath}.from`,
|
|
564
|
+
`Redirect source '${normalized.value}' collides with file route for '${fileRoutes.get(folded)}'.`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
redirectSources.set(folded, normalized.value);
|
|
568
|
+
redirectOrder.push(normalized.value);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const target = stringValue(context, redirect.toResourceKey, `${redirectPath}.toResourceKey`, {
|
|
572
|
+
max: PREBUILT_LIMITS.resourceKey,
|
|
573
|
+
pattern: RESOURCE_KEY,
|
|
574
|
+
});
|
|
575
|
+
if (target !== undefined && !resources.has(target)) {
|
|
576
|
+
context.add(
|
|
577
|
+
"redirect.unknown_resource",
|
|
578
|
+
`${redirectPath}.toResourceKey`,
|
|
579
|
+
`Redirect targets unknown resource '${target}'.`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
integerValue(context, redirect.status, `${redirectPath}.status`, { min: 301, max: 308 });
|
|
583
|
+
if (redirect.status !== 301 && redirect.status !== 308) {
|
|
584
|
+
context.add("redirect.status", `${redirectPath}.status`, "Redirect status must be 301 or 308.");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (
|
|
588
|
+
redirectOrder.some((item, index) => index > 0 && compareCanonicalStrings(redirectOrder[index - 1], item) >= 0)
|
|
589
|
+
) {
|
|
590
|
+
context.add(
|
|
591
|
+
"array.not_sorted",
|
|
592
|
+
"$.redirects",
|
|
593
|
+
"Redirects must have unique source routes sorted lexicographically.",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return context.finish(value);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function validatePrebuiltManifest(input, options = {}) {
|
|
601
|
+
let manifest = input;
|
|
602
|
+
let objectPreflight;
|
|
603
|
+
const classification = classifyManifestInput(input);
|
|
604
|
+
if (["string", "uint8array", "arraybuffer", "unsupported_binary", "invalid"].includes(classification.kind)) {
|
|
605
|
+
const parsed = parseManifestJson(
|
|
606
|
+
input,
|
|
607
|
+
classification,
|
|
608
|
+
PREBUILT_LIMITS.manifestBytes,
|
|
609
|
+
PREBUILT_LIMITS.manifestObjectDepth,
|
|
610
|
+
);
|
|
611
|
+
if (!parsed.ok) return parsed;
|
|
612
|
+
manifest = parsed.value;
|
|
613
|
+
} else if (classification.kind === "object") {
|
|
614
|
+
objectPreflight = preflightManifestObject(input, { ...PREBUILT_LIMITS, stopAtByteLimit: true });
|
|
615
|
+
if (!objectPreflight.ok) return objectPreflight;
|
|
616
|
+
if (objectPreflight.exceedsByteLimit) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
errors: [{
|
|
620
|
+
code: "manifest.too_large",
|
|
621
|
+
path: "$",
|
|
622
|
+
message: `Canonical prebuilt manifest bytes may not exceed ${PREBUILT_LIMITS.manifestBytes}.`,
|
|
623
|
+
}],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
manifest = objectPreflight.value;
|
|
627
|
+
}
|
|
628
|
+
const supportedResult = snapshotPrebuiltSupportedCapabilities(options);
|
|
629
|
+
if (!supportedResult.ok) return supportedResult;
|
|
630
|
+
const supported = new Set([...PREBUILT_SUPPORTED_CAPABILITIES, ...supportedResult.value]);
|
|
631
|
+
const result = validateRoot(manifest, supported);
|
|
632
|
+
if (!result.ok) return result;
|
|
633
|
+
if (canonicalJsonByteLength(result.value, PREBUILT_LIMITS.manifestBytes) > PREBUILT_LIMITS.manifestBytes) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
errors: [{
|
|
637
|
+
code: "manifest.too_large",
|
|
638
|
+
path: "$",
|
|
639
|
+
message: `Canonical prebuilt manifest bytes may not exceed ${PREBUILT_LIMITS.manifestBytes}.`,
|
|
640
|
+
}],
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function assertValidPrebuiltManifest(input, options = {}) {
|
|
647
|
+
const result = validatePrebuiltManifest(input, options);
|
|
648
|
+
if (!result.ok) throw new DocsArtifactValidationError(result.errors);
|
|
649
|
+
return result.value;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function serializePrebuiltManifest(input, options = {}) {
|
|
653
|
+
return canonicalJson(assertValidPrebuiltManifest(input, options));
|
|
654
|
+
}
|