@mpgd/cli 0.28.1 → 0.30.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/dist/cloudflare-pages-static.d.ts +53 -0
- package/dist/cloudflare-pages-static.js +236 -0
- package/dist/hosted-pwa-deployment.d.ts +42 -0
- package/dist/hosted-pwa-deployment.js +1264 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +387 -2
- package/dist/microsoft-store-package-acceptance.js +19 -8
- package/dist/microsoft-store-pwa-release.d.ts +58 -0
- package/dist/microsoft-store-pwa-release.js +343 -0
- package/dist/offline-playtest.js +20 -4
- package/package.json +7 -7
- package/templates/phaser-game/agent/game-manifest.json +4 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/entry.ts +10 -0
- package/templates/phaser-game/src/gameEntry.ts +10 -0
- package/templates/phaser-game/src/platform/textSelection.ts +121 -0
- package/templates/phaser-game/vite.shared.ts +14 -1
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join, posix, relative, resolve } from 'node:path';
|
|
4
|
+
import { decodeHTMLAttribute } from 'entities';
|
|
5
|
+
import { cloudflarePagesPathMatches, evaluateCloudflarePagesHeader, normalizeHeaderDirectiveValue, parseCloudflarePagesHeaders, parseCloudflarePagesRedirects, } from './cloudflare-pages-static.js';
|
|
6
|
+
import { createMicrosoftStorePwaRevisionFromFiles, createMicrosoftStorePwaServiceWorker, listPrecacheUrls, readMicrosoftStorePwaReleaseEvidence, } from './microsoft-store-pwa-release.js';
|
|
7
|
+
/**
|
|
8
|
+
* Read-only verification that a hosted Cloudflare Pages deployment directory
|
|
9
|
+
* serves exactly the files of an already-verified PWA artifact, with routing
|
|
10
|
+
* and cache headers that keep the PWA entry points reachable and fresh.
|
|
11
|
+
*
|
|
12
|
+
* The verifier knows nothing about individual games or workspace layouts: it
|
|
13
|
+
* compares an explicit source artifact directory against an explicit
|
|
14
|
+
* deployment directory under one of the reviewed Pages host profiles.
|
|
15
|
+
*/
|
|
16
|
+
export const hostedPwaDeploymentHosts = ['cloudflare-pages'];
|
|
17
|
+
export const cloudflarePagesDeploymentProfiles = ['api-only', 'api-canonical-index'];
|
|
18
|
+
/** Host files that Pages adds around the game artifact. */
|
|
19
|
+
const cloudflarePagesHostFileAllowlist = new Set([
|
|
20
|
+
'_worker.js',
|
|
21
|
+
'_headers',
|
|
22
|
+
'_redirects',
|
|
23
|
+
'_routes.json',
|
|
24
|
+
'wrangler.jsonc',
|
|
25
|
+
'legal-site.json',
|
|
26
|
+
]);
|
|
27
|
+
/** SVG presentation attributes whose url() values load external resources. */
|
|
28
|
+
const svgUrlAttributeNames = new Set([
|
|
29
|
+
'fill',
|
|
30
|
+
'stroke',
|
|
31
|
+
'filter',
|
|
32
|
+
'clip-path',
|
|
33
|
+
'mask',
|
|
34
|
+
'marker-start',
|
|
35
|
+
'marker-mid',
|
|
36
|
+
'marker-end',
|
|
37
|
+
]);
|
|
38
|
+
/** Control artifacts Pages consumes instead of serving to browsers. */
|
|
39
|
+
const pagesControlArtifactNames = new Set([
|
|
40
|
+
'_worker.js',
|
|
41
|
+
'_headers',
|
|
42
|
+
'_redirects',
|
|
43
|
+
'_routes.json',
|
|
44
|
+
'wrangler.jsonc',
|
|
45
|
+
]);
|
|
46
|
+
/** Request paths a redirect may never cover, matched as patterns. */
|
|
47
|
+
const protectedPwaMetadataRequestPaths = [
|
|
48
|
+
'/',
|
|
49
|
+
'/index.html',
|
|
50
|
+
'/manifest.webmanifest',
|
|
51
|
+
'/service-worker.js',
|
|
52
|
+
'/pwa-release.json',
|
|
53
|
+
];
|
|
54
|
+
const freshCacheControl = 'public, max-age=0, must-revalidate';
|
|
55
|
+
const noStoreCacheControl = 'no-store, must-revalidate';
|
|
56
|
+
const immutableCacheControl = 'public, max-age=31536000, immutable';
|
|
57
|
+
const freshCacheControlFiles = new Set([
|
|
58
|
+
'/manifest.webmanifest',
|
|
59
|
+
'/pwa-release.json',
|
|
60
|
+
'/mpgd-effective-target.json',
|
|
61
|
+
'/mpgd-icon-manifest.json',
|
|
62
|
+
'/mpgd-icon-precache.json',
|
|
63
|
+
'/legal-site.json',
|
|
64
|
+
'/game-services-routing.json',
|
|
65
|
+
]);
|
|
66
|
+
const noStoreCacheControlFiles = new Set(['/service-worker.js']);
|
|
67
|
+
const immutableCacheControlDirectories = new Set(['assets']);
|
|
68
|
+
const noStoreCacheControlDirectories = new Set(['icons']);
|
|
69
|
+
/** Precache URLs the reviewed PWA contract always includes. */
|
|
70
|
+
const requiredPrecacheUrls = new Set([
|
|
71
|
+
'./index.html',
|
|
72
|
+
'./manifest.webmanifest',
|
|
73
|
+
'./pwa-release.json',
|
|
74
|
+
]);
|
|
75
|
+
export function isSupportedHostedPwaProfile(host, profile) {
|
|
76
|
+
if (!hostedPwaDeploymentHosts.includes(host)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return cloudflarePagesDeploymentProfiles.includes(profile);
|
|
80
|
+
}
|
|
81
|
+
export function verifyHostedPwaDeployment(input) {
|
|
82
|
+
if (!hostedPwaDeploymentHosts.includes(input.host)) {
|
|
83
|
+
throw new Error(`Unsupported hosted PWA deployment host: ${input.host} `
|
|
84
|
+
+ `(supported: ${hostedPwaDeploymentHosts.join(', ')}).`);
|
|
85
|
+
}
|
|
86
|
+
if (!cloudflarePagesDeploymentProfiles.includes(input.profile)) {
|
|
87
|
+
throw new Error(`Unsupported Cloudflare Pages host profile: ${input.profile} `
|
|
88
|
+
+ `(supported: ${cloudflarePagesDeploymentProfiles.join(', ')}).`);
|
|
89
|
+
}
|
|
90
|
+
const sourceRoot = resolveArtifactDirectory(input.sourceArtifactRoot, 'source PWA artifact');
|
|
91
|
+
const deploymentRoot = resolveArtifactDirectory(input.deploymentRoot, 'deployment');
|
|
92
|
+
if (sourceRoot === deploymentRoot) {
|
|
93
|
+
throw new Error('The source artifact and the deployment directory must be different directories; '
|
|
94
|
+
+ 'a self-comparison cannot validate the deployment merge.');
|
|
95
|
+
}
|
|
96
|
+
const sourceFiles = listArtifactFilesStrict(sourceRoot, 'source PWA artifact');
|
|
97
|
+
const deploymentFiles = listArtifactFilesStrict(deploymentRoot, 'deployment');
|
|
98
|
+
const evidence = verifySourceArtifactSelfConsistency(sourceRoot, sourceFiles);
|
|
99
|
+
verifyDeploymentGameFiles(sourceRoot, sourceFiles, deploymentRoot, deploymentFiles);
|
|
100
|
+
const hostFiles = verifyDeploymentFileClassification(deploymentFiles, sourceFiles, deploymentRoot, input.profile);
|
|
101
|
+
const referencedDirectoryUrls = verifyIndexReferences(deploymentRoot, deploymentFiles);
|
|
102
|
+
const workerRoutes = verifyCloudflarePagesRoutes(deploymentRoot, input.profile);
|
|
103
|
+
assertNoSourceRoutesShadowed(workerRoutes, sourceFiles, input.profile);
|
|
104
|
+
assertNoLegalRoutesShadowed(workerRoutes, [...readLegalSitePages(deploymentRoot)], input.profile);
|
|
105
|
+
const legalPages = [...readLegalSitePages(deploymentRoot)];
|
|
106
|
+
const declaredLegalDirectories = new Set(legalPages.map((page) => page.slice(0, -'index.html'.length)));
|
|
107
|
+
verifyCloudflarePagesHeaders(deploymentRoot, sourceFiles, deploymentFiles, declaredLegalDirectories, workerRoutes);
|
|
108
|
+
const deploymentLegalPaths = [
|
|
109
|
+
...[...readLegalSitePages(deploymentRoot)].flatMap((page) => {
|
|
110
|
+
const directoryUrl = `/${page.slice(0, -'index.html'.length)}`;
|
|
111
|
+
return [directoryUrl, `/${page}`];
|
|
112
|
+
}),
|
|
113
|
+
...(existsSync(join(deploymentRoot, 'legal-site.json'))
|
|
114
|
+
? ['/legal-site.json']
|
|
115
|
+
: []),
|
|
116
|
+
];
|
|
117
|
+
verifyCloudflarePagesRedirects(deploymentRoot, sourceFiles, deploymentLegalPaths, referencedDirectoryUrls);
|
|
118
|
+
return {
|
|
119
|
+
host: input.host,
|
|
120
|
+
profile: input.profile,
|
|
121
|
+
sourceArtifactRoot: sourceRoot,
|
|
122
|
+
deploymentRoot,
|
|
123
|
+
appVersion: evidence.appVersion,
|
|
124
|
+
buildId: evidence.buildId,
|
|
125
|
+
revision: evidence.revision,
|
|
126
|
+
verifiedGameFileCount: sourceFiles.length,
|
|
127
|
+
hostFileCount: hostFiles.length,
|
|
128
|
+
workerRoutes,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function resolveArtifactDirectory(path, label) {
|
|
132
|
+
const resolved = resolve(path);
|
|
133
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
134
|
+
throw new Error(`The ${label} directory does not exist: ${resolved}`);
|
|
135
|
+
}
|
|
136
|
+
return realpathSync(resolved);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Enumerate every file under a root with strict safety: symbolic links,
|
|
140
|
+
* non-regular files, and non-portable names are rejected so the deployment
|
|
141
|
+
* comparison cannot be redirected outside the inspected directory.
|
|
142
|
+
*/
|
|
143
|
+
function listArtifactFilesStrict(root, label) {
|
|
144
|
+
const files = [];
|
|
145
|
+
const pendingDirectories = [root];
|
|
146
|
+
while (pendingDirectories.length > 0) {
|
|
147
|
+
const directory = pendingDirectories.pop();
|
|
148
|
+
if (directory === undefined) {
|
|
149
|
+
throw new Error(`${label} traversal lost its directory.`);
|
|
150
|
+
}
|
|
151
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
152
|
+
const absolute = join(directory, entry.name);
|
|
153
|
+
if (entry.isSymbolicLink()) {
|
|
154
|
+
throw new Error(`The ${label} must not contain symbolic links: ${absolute}`);
|
|
155
|
+
}
|
|
156
|
+
if (/[\\\t\n\r]/u.test(entry.name)) {
|
|
157
|
+
throw new Error(`The ${label} contains a non-portable file name with a backslash or `
|
|
158
|
+
+ `URL-stripped control character: ${JSON.stringify(entry.name)}`);
|
|
159
|
+
}
|
|
160
|
+
if (entry.isDirectory()) {
|
|
161
|
+
pendingDirectories.push(absolute);
|
|
162
|
+
}
|
|
163
|
+
else if (entry.isFile()) {
|
|
164
|
+
const relativePath = posix.normalize(relativePortablePath(root, absolute));
|
|
165
|
+
if (relativePath.startsWith('../')
|
|
166
|
+
|| relativePath === '..'
|
|
167
|
+
|| relativePath.includes('/../')
|
|
168
|
+
|| posix.isAbsolute(relativePath)) {
|
|
169
|
+
throw new Error(`The ${label} file escapes its root: ${absolute}`);
|
|
170
|
+
}
|
|
171
|
+
// Digests stream from disk; bytes are retained only for the files
|
|
172
|
+
// whose content is parsed later, so large game assets are not held
|
|
173
|
+
// in memory during verification.
|
|
174
|
+
const digest = createHash('sha256');
|
|
175
|
+
const handle = openSync(absolute, 'r');
|
|
176
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
177
|
+
try {
|
|
178
|
+
let read = readSync(handle, chunk, 0, chunk.length, null);
|
|
179
|
+
while (read > 0) {
|
|
180
|
+
digest.update(chunk.subarray(0, read));
|
|
181
|
+
read = readSync(handle, chunk, 0, chunk.length, null);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
closeSync(handle);
|
|
186
|
+
}
|
|
187
|
+
files.push({
|
|
188
|
+
path: relativePath,
|
|
189
|
+
sha256: digest.digest('hex'),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
throw new Error(`The ${label} contains an entry that is neither a directory nor a regular `
|
|
194
|
+
+ `file: ${absolute}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
function relativePortablePath(root, absolute) {
|
|
201
|
+
const rootReal = realpathSync(root);
|
|
202
|
+
const parentReal = realpathSync(dirname(absolute));
|
|
203
|
+
return relative(rootReal, join(parentReal, absolute.slice(dirname(absolute).length)))
|
|
204
|
+
.split('\\')
|
|
205
|
+
.join('/');
|
|
206
|
+
}
|
|
207
|
+
function verifySourceArtifactSelfConsistency(sourceRoot, sourceFiles) {
|
|
208
|
+
const evidencePath = `${sourceRoot}/pwa-release.json`;
|
|
209
|
+
if (!existsSync(evidencePath)) {
|
|
210
|
+
throw new Error('The source PWA artifact is missing pwa-release.json.');
|
|
211
|
+
}
|
|
212
|
+
const evidence = readMicrosoftStorePwaReleaseEvidence(evidencePath);
|
|
213
|
+
for (const reserved of cloudflarePagesHostFileAllowlist) {
|
|
214
|
+
if (sourceFiles.some((file) => file.path === reserved)) {
|
|
215
|
+
throw new Error(`The source PWA artifact contains the Pages control file ${reserved}; `
|
|
216
|
+
+ 'control files belong to the host deployment, not the game artifact.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const required of ['index.html', 'manifest.webmanifest', 'service-worker.js']) {
|
|
220
|
+
if (!sourceFiles.some((file) => file.path === required)) {
|
|
221
|
+
throw new Error(`The source PWA artifact is missing ${required}.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const manifest = JSON.parse(readFileSync(`${sourceRoot}/manifest.webmanifest`, 'utf8'));
|
|
225
|
+
if (manifest.id !== evidence.pwaId) {
|
|
226
|
+
throw new Error('The source PWA artifact manifest application id does not match the '
|
|
227
|
+
+ 'release evidence; the manifest and pwa-release.json must identify '
|
|
228
|
+
+ 'the same application.');
|
|
229
|
+
}
|
|
230
|
+
const enumeratedUrls = [
|
|
231
|
+
...listPrecacheUrls(sourceRoot),
|
|
232
|
+
'./pwa-release.json',
|
|
233
|
+
].sort();
|
|
234
|
+
const recordedUrls = [...evidence.precacheUrls].sort();
|
|
235
|
+
if (JSON.stringify(enumeratedUrls) !== JSON.stringify(recordedUrls)) {
|
|
236
|
+
throw new Error('The source PWA artifact precache contract is inconsistent: the recorded '
|
|
237
|
+
+ 'pwa-release.json URLs do not match the files actually present in the '
|
|
238
|
+
+ 'artifact, so the service worker precache list cannot be trusted.');
|
|
239
|
+
}
|
|
240
|
+
for (const required of requiredPrecacheUrls) {
|
|
241
|
+
if (!evidence.precacheUrls.includes(required)) {
|
|
242
|
+
throw new Error(`The source PWA artifact precache contract omits the required URL ${required}.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const sourceWorker = sourceFiles.find((file) => file.path === 'service-worker.js');
|
|
246
|
+
if (sourceWorker !== undefined
|
|
247
|
+
&& readFileSync(join(sourceRoot, 'service-worker.js'), 'utf8')
|
|
248
|
+
!== createMicrosoftStorePwaServiceWorker(evidence)) {
|
|
249
|
+
throw new Error('The source PWA artifact service worker does not match the release '
|
|
250
|
+
+ 'evidence; it must be the deterministic worker generated for the '
|
|
251
|
+
+ 'recorded precache contract so its cache identity and precache list '
|
|
252
|
+
+ 'cannot drift from pwa-release.json.');
|
|
253
|
+
}
|
|
254
|
+
const recomputedRevision = createMicrosoftStorePwaRevisionFromFiles({
|
|
255
|
+
appVersion: evidence.appVersion,
|
|
256
|
+
buildId: evidence.buildId,
|
|
257
|
+
sourceGitSha: evidence.sourceGitSha,
|
|
258
|
+
kitGitSha: evidence.kitGitSha,
|
|
259
|
+
precacheUrls: evidence.precacheUrls,
|
|
260
|
+
artifactRoot: sourceRoot,
|
|
261
|
+
});
|
|
262
|
+
if (recomputedRevision !== evidence.revision) {
|
|
263
|
+
throw new Error('The source PWA artifact is not self-consistent: the recomputed release revision '
|
|
264
|
+
+ `${recomputedRevision} does not match the recorded ${evidence.revision}.`);
|
|
265
|
+
}
|
|
266
|
+
return evidence;
|
|
267
|
+
}
|
|
268
|
+
function verifyDeploymentGameFiles(sourceRoot, sourceFiles, deploymentRoot, deploymentFiles) {
|
|
269
|
+
const deploymentByPath = new Map(deploymentFiles.map((file) => [file.path, file]));
|
|
270
|
+
for (const sourceFile of sourceFiles) {
|
|
271
|
+
const deployed = deploymentByPath.get(sourceFile.path);
|
|
272
|
+
if (deployed === undefined) {
|
|
273
|
+
throw new Error(`The deployment is missing game file ${sourceFile.path} `
|
|
274
|
+
+ `from the source artifact ${sourceRoot}.`);
|
|
275
|
+
}
|
|
276
|
+
if (deployed.sha256 !== sourceFile.sha256) {
|
|
277
|
+
throw new Error(`The deployment game file ${sourceFile.path} does not match the source artifact; `
|
|
278
|
+
+ `expected sha256 ${sourceFile.sha256} but found ${deployed.sha256}.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function verifyDeploymentFileClassification(deploymentFiles, sourceFiles, deploymentRoot, profile) {
|
|
283
|
+
const sourcePaths = new Set(sourceFiles.map((file) => file.path));
|
|
284
|
+
const legalPages = readLegalSitePages(deploymentRoot);
|
|
285
|
+
const hostFiles = [];
|
|
286
|
+
const unexpected = [];
|
|
287
|
+
for (const file of deploymentFiles) {
|
|
288
|
+
if (sourcePaths.has(file.path)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (cloudflarePagesHostFileAllowlist.has(file.path) || legalPages.has(file.path)) {
|
|
292
|
+
hostFiles.push(file.path);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
unexpected.push(file.path);
|
|
296
|
+
}
|
|
297
|
+
if (unexpected.length > 0) {
|
|
298
|
+
throw new Error('The deployment contains files that belong neither to the source artifact nor to '
|
|
299
|
+
+ `the recognized Pages host files: ${unexpected.join(', ')}. `
|
|
300
|
+
+ 'This usually means files from a different build were mixed into the deployment.');
|
|
301
|
+
}
|
|
302
|
+
// Both reviewed profiles route /api/* through the Pages worker, so a
|
|
303
|
+
// deployment without the worker would verify statically while shipping
|
|
304
|
+
// broken API routes.
|
|
305
|
+
if (!hostFiles.includes('_worker.js')) {
|
|
306
|
+
throw new Error(`The deployment is missing _worker.js; the reviewed ${profile} profile `
|
|
307
|
+
+ 'routes /api/* through the Pages worker.');
|
|
308
|
+
}
|
|
309
|
+
return hostFiles;
|
|
310
|
+
}
|
|
311
|
+
function readLegalSitePages(deploymentRoot) {
|
|
312
|
+
const manifestPath = `${deploymentRoot}/legal-site.json`;
|
|
313
|
+
if (!existsSync(manifestPath)) {
|
|
314
|
+
return new Set();
|
|
315
|
+
}
|
|
316
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
317
|
+
const pages = new Set();
|
|
318
|
+
if (typeof parsed !== 'object'
|
|
319
|
+
|| parsed === null
|
|
320
|
+
|| !Array.isArray(parsed.pages)) {
|
|
321
|
+
throw new Error('The deployment legal-site.json must declare a pages array.');
|
|
322
|
+
}
|
|
323
|
+
for (const page of parsed.pages) {
|
|
324
|
+
if (typeof page !== 'object' || page === null) {
|
|
325
|
+
throw new Error('The deployment legal-site.json page entries must be objects.');
|
|
326
|
+
}
|
|
327
|
+
const path = page.path;
|
|
328
|
+
const canonicalPath = typeof path === 'string'
|
|
329
|
+
&& path.startsWith('/')
|
|
330
|
+
&& path.endsWith('/')
|
|
331
|
+
&& path !== '/'
|
|
332
|
+
&& path
|
|
333
|
+
.slice(1, -1)
|
|
334
|
+
.split('/')
|
|
335
|
+
.every((segment) => /^[A-Za-z0-9_-]+$/u.test(segment));
|
|
336
|
+
if (!canonicalPath) {
|
|
337
|
+
throw new Error('The deployment legal-site.json page paths must be canonical slash-delimited '
|
|
338
|
+
+ `URL pathnames without queries, fragments, dot segments, or escapes: ${String(path)}`);
|
|
339
|
+
}
|
|
340
|
+
const relativePage = `${path.slice(1)}index.html`;
|
|
341
|
+
const distance = relative(deploymentRoot, resolve(deploymentRoot, relativePage));
|
|
342
|
+
if (distance === '' || distance.startsWith('..') || posix.isAbsolute(distance)) {
|
|
343
|
+
throw new Error(`The deployment legal-site.json page path escapes the deployment root: ${path}`);
|
|
344
|
+
}
|
|
345
|
+
pages.add(relativePage);
|
|
346
|
+
}
|
|
347
|
+
for (const page of pages) {
|
|
348
|
+
const pageFile = join(deploymentRoot, page);
|
|
349
|
+
if (!existsSync(pageFile) || !statSync(pageFile).isFile()) {
|
|
350
|
+
throw new Error('The deployment legal-site.json declares a page that is not present as a '
|
|
351
|
+
+ `regular file: ${page}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return pages;
|
|
355
|
+
}
|
|
356
|
+
function verifyIndexReferences(deploymentRoot, deploymentFiles) {
|
|
357
|
+
const index = deploymentFiles.find((file) => file.path === 'index.html');
|
|
358
|
+
if (index === undefined) {
|
|
359
|
+
throw new Error('The deployment is missing index.html.');
|
|
360
|
+
}
|
|
361
|
+
const rawHtml = readFileSync(join(deploymentRoot, 'index.html'), 'utf8');
|
|
362
|
+
const cssReferences = [];
|
|
363
|
+
const cssSourceHtml = stripScriptsAndComments(rawHtml);
|
|
364
|
+
for (const styleBody of cssSourceHtml.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
|
|
365
|
+
collectInlineCssReferences(styleBody[1] ?? '', cssReferences);
|
|
366
|
+
}
|
|
367
|
+
const html = stripNonMarkupRanges(rawHtml);
|
|
368
|
+
if (/<base\b/iu.test(html)) {
|
|
369
|
+
throw new Error('The deployment index.html declares a base element; reference resolution '
|
|
370
|
+
+ 'against a non-root document base is unsupported by this verifier.');
|
|
371
|
+
}
|
|
372
|
+
const deploymentPaths = new Set(deploymentFiles.map((file) => file.path));
|
|
373
|
+
const referenced = new Set();
|
|
374
|
+
const referencedDirectoryUrls = new Set();
|
|
375
|
+
const references = [...cssReferences];
|
|
376
|
+
const metaRefreshContents = [];
|
|
377
|
+
const embeddedDocuments = [];
|
|
378
|
+
const stylesheetHrefs = new Set();
|
|
379
|
+
const activeStylesheets = new Set();
|
|
380
|
+
// Attributes only exist on start tags; scanning raw text would treat prose
|
|
381
|
+
// like `Set href="..."` as a reference. Extract within element start tags,
|
|
382
|
+
// with object data URLs and srcset candidates handled per tag.
|
|
383
|
+
const startTagPattern = /<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu;
|
|
384
|
+
for (const tag of html.matchAll(startTagPattern)) {
|
|
385
|
+
const tagName = (tag[1] ?? '').toLowerCase();
|
|
386
|
+
const attributes = tag[2] ?? '';
|
|
387
|
+
const resourceNames = tagName === 'object'
|
|
388
|
+
? ['href', 'src', 'poster', 'data']
|
|
389
|
+
: ['href', 'src', 'poster', 'xlink:href'];
|
|
390
|
+
// Tokenize real attribute name/value pairs exactly once so every
|
|
391
|
+
// attribute-driven check (resource names, srcset, style, srcdoc, meta)
|
|
392
|
+
// shares one parse. Quoted values of unrelated attributes
|
|
393
|
+
// (title='See srcset="./missing.png"') can never leak references, and
|
|
394
|
+
// duplicate attributes keep their first occurrence like HTML parsing.
|
|
395
|
+
const seenAttributeNames = new Set();
|
|
396
|
+
const attributePairs = [];
|
|
397
|
+
for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
|
|
398
|
+
const name = (attribute[1] ?? '').toLowerCase();
|
|
399
|
+
if (seenAttributeNames.has(name)) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
seenAttributeNames.add(name);
|
|
403
|
+
attributePairs.push({ name, value: attribute[2] ?? attribute[3] ?? attribute[4] ?? '' });
|
|
404
|
+
}
|
|
405
|
+
// An iframe srcdoc overrides src even when its value is empty.
|
|
406
|
+
const attributesHasSrcdoc = tagName === 'iframe'
|
|
407
|
+
&& attributePairs.some(({ name }) => name === 'srcdoc');
|
|
408
|
+
let isMetaRefresh = false;
|
|
409
|
+
let metaContent;
|
|
410
|
+
let styleValue;
|
|
411
|
+
let srcsetValue;
|
|
412
|
+
let linkRel;
|
|
413
|
+
let linkHref;
|
|
414
|
+
for (const { name, value } of attributePairs) {
|
|
415
|
+
if (tagName === 'meta' && name === 'http-equiv' && value.trim().toLowerCase() === 'refresh') {
|
|
416
|
+
isMetaRefresh = true;
|
|
417
|
+
}
|
|
418
|
+
if (tagName === 'meta' && name === 'content') {
|
|
419
|
+
metaContent = value;
|
|
420
|
+
}
|
|
421
|
+
if (name === 'style') {
|
|
422
|
+
styleValue = value;
|
|
423
|
+
}
|
|
424
|
+
if (name === 'srcset') {
|
|
425
|
+
srcsetValue = value;
|
|
426
|
+
}
|
|
427
|
+
if (tagName === 'link' && name === 'rel') {
|
|
428
|
+
linkRel = value.trim().toLowerCase();
|
|
429
|
+
}
|
|
430
|
+
if (tagName === 'link' && name === 'href') {
|
|
431
|
+
linkHref = value;
|
|
432
|
+
}
|
|
433
|
+
if (!resourceNames.includes(name) && name !== 'srcdoc') {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (name === 'srcdoc' && value.length > 0) {
|
|
437
|
+
// srcdoc embeds a document resolved relative to this one; scan its
|
|
438
|
+
// content for the same resource attributes.
|
|
439
|
+
embeddedDocuments.push(decodeHtmlReferences(value));
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (value.length > 0) {
|
|
443
|
+
if (tagName === 'iframe' && name === 'src' && attributesHasSrcdoc) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
references.push(value);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Remember stylesheet links by their raw href so only files the browser
|
|
450
|
+
// actually applies as CSS join the stylesheet scan; a plain anchor to a
|
|
451
|
+
// .css file is just a downloadable reference. rel/href order-agnostic.
|
|
452
|
+
if (tagName === 'link' && linkHref !== undefined && isStylesheetRel(linkRel)) {
|
|
453
|
+
stylesheetHrefs.add(linkHref);
|
|
454
|
+
}
|
|
455
|
+
if (isMetaRefresh && metaContent !== undefined) {
|
|
456
|
+
metaRefreshContents.push(metaContent);
|
|
457
|
+
}
|
|
458
|
+
if (styleValue !== undefined) {
|
|
459
|
+
collectInlineCssReferences(decodeHtmlReferences(styleValue), references);
|
|
460
|
+
}
|
|
461
|
+
if (srcsetValue === undefined) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// Shared WHATWG srcset tokenizer (also used by the legal-page pass).
|
|
465
|
+
pushSrcsetCandidates(decodeHtmlReferences(srcsetValue), references);
|
|
466
|
+
}
|
|
467
|
+
// Embedded srcdoc documents resolve their resources against this
|
|
468
|
+
// deployment; scan them with the full tokenizer (resource attributes,
|
|
469
|
+
// srcset candidates, object data, and inline CSS alike).
|
|
470
|
+
for (const document of embeddedDocuments) {
|
|
471
|
+
collectEmbeddedDocumentReferences(document, references);
|
|
472
|
+
}
|
|
473
|
+
// Meta-refresh targets navigate automatically; treat the URL part of the
|
|
474
|
+
// content directive as a browser-loaded local reference.
|
|
475
|
+
for (const rawContent of metaRefreshContents) {
|
|
476
|
+
const content = decodeHtmlReferences(rawContent);
|
|
477
|
+
const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(content);
|
|
478
|
+
if (target !== null) {
|
|
479
|
+
references.push(target[1] ?? target[2] ?? target[3] ?? '');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const rawReference of references) {
|
|
483
|
+
const reference = decodeHtmlReferences(rawReference).trim();
|
|
484
|
+
const schemeSeparated = /^([a-z][a-z0-9+.-]*):/iu.exec(reference);
|
|
485
|
+
if (reference.length === 0 || reference.startsWith('#') || reference.startsWith('data:')) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
// Scheme-relative (//host/path) and cross-origin URLs stay external;
|
|
489
|
+
// same-scheme references like "https:./file.png" resolve against the
|
|
490
|
+
// deployment like the browser does on an HTTPS page.
|
|
491
|
+
if (reference.startsWith('//')) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
let effectiveReference = reference;
|
|
495
|
+
if (schemeSeparated !== null && schemeSeparated[1] !== undefined) {
|
|
496
|
+
const scheme = schemeSeparated[1].toLowerCase();
|
|
497
|
+
if (scheme === 'https' || scheme === 'http') {
|
|
498
|
+
const afterScheme = reference.slice(schemeSeparated[1].length + 1);
|
|
499
|
+
if (afterScheme.startsWith('//')) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
effectiveReference = afterScheme;
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const withoutQuery = effectiveReference.split(/[?#]/u)[0] ?? effectiveReference;
|
|
509
|
+
if (withoutQuery.length === 0) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
let normalized = posix.normalize(withoutQuery.startsWith('/') ? withoutQuery.slice(1) : `./${withoutQuery}`);
|
|
513
|
+
normalized = normalized.split('\\').join('/');
|
|
514
|
+
try {
|
|
515
|
+
normalized = normalized
|
|
516
|
+
.split('/')
|
|
517
|
+
.map((segment) => decodeURIComponent(segment))
|
|
518
|
+
.join('/');
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
throw new Error(`The deployment index.html references a malformed percent-encoded URL: ${reference}`);
|
|
522
|
+
}
|
|
523
|
+
if (normalized.startsWith('../') || normalized === '..' || posix.isAbsolute(normalized)) {
|
|
524
|
+
throw new Error(`The deployment index.html references a path outside the artifact root: ${reference}`);
|
|
525
|
+
}
|
|
526
|
+
// Track the concrete directory request URL before resolving it to the
|
|
527
|
+
// index document, so redirect protection sees the URL browsers request.
|
|
528
|
+
if (normalized.endsWith('/')) {
|
|
529
|
+
referencedDirectoryUrls.add(`/${normalized.slice(0, -1)}/`);
|
|
530
|
+
}
|
|
531
|
+
else if (normalized === '.') {
|
|
532
|
+
referencedDirectoryUrls.add('/');
|
|
533
|
+
}
|
|
534
|
+
// Directory URLs resolve to their index document, matching how Pages
|
|
535
|
+
// serves '/' and '/legal/' from index.html files.
|
|
536
|
+
if (normalized === '.' || normalized.endsWith('/')) {
|
|
537
|
+
normalized = `${normalized === '.' ? '' : normalized}index.html`;
|
|
538
|
+
}
|
|
539
|
+
if (stylesheetHrefs.has(rawReference)) {
|
|
540
|
+
activeStylesheets.add(normalized);
|
|
541
|
+
}
|
|
542
|
+
referenced.add(normalized);
|
|
543
|
+
}
|
|
544
|
+
// Stylesheet links (and the @import chain they start) carry their own
|
|
545
|
+
// url()/@import targets, resolved relative to each stylesheet's directory.
|
|
546
|
+
// Comments are stripped first, external URLs are skipped, root-relative
|
|
547
|
+
// targets resolve from the deployment root, percent-encoded segments
|
|
548
|
+
// decode, escaping targets are rejected, and chained imports follow with a
|
|
549
|
+
// visited set so circular imports cannot loop forever.
|
|
550
|
+
const visitedStylesheets = new Set();
|
|
551
|
+
const stylesheetQueue = [...activeStylesheets];
|
|
552
|
+
while (stylesheetQueue.length > 0) {
|
|
553
|
+
const stylesheet = stylesheetQueue.shift();
|
|
554
|
+
if (stylesheet === undefined || visitedStylesheets.has(stylesheet)) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
visitedStylesheets.add(stylesheet);
|
|
558
|
+
const { cssUrls, cssImports } = extractStylesheetTargets(readFileSync(join(deploymentRoot, stylesheet), 'utf8'));
|
|
559
|
+
const cssDir = stylesheet.includes('/')
|
|
560
|
+
? `./${stylesheet.slice(0, stylesheet.lastIndexOf('/'))}`
|
|
561
|
+
: '.';
|
|
562
|
+
const resolvedUrls = [];
|
|
563
|
+
const resolvedImports = [];
|
|
564
|
+
const collectCssTarget = (rawTarget, sink) => {
|
|
565
|
+
const raw = decodeCssEscapes(rawTarget.trim());
|
|
566
|
+
if (raw.length === 0 || raw.startsWith('data:') || raw.startsWith('#')) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(raw);
|
|
570
|
+
if (scheme !== null
|
|
571
|
+
&& scheme[1] !== undefined
|
|
572
|
+
&& scheme[1].toLowerCase() !== 'https'
|
|
573
|
+
&& scheme[1].toLowerCase() !== 'http') {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const schemeText = scheme?.[1] ?? '';
|
|
577
|
+
let cssPath = scheme !== null ? raw.slice(schemeText.length + 1) : raw;
|
|
578
|
+
// Cross-scheme authority URLs (https://host/x, http:/host/x) are
|
|
579
|
+
// external; only same-scheme relative forms like https:./file.png
|
|
580
|
+
// resolve against the deployment.
|
|
581
|
+
if (scheme !== null && cssPath.startsWith('/')) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// Scheme-relative URLs without a scheme token (//host/x) are external.
|
|
585
|
+
if (scheme === null && cssPath.startsWith('//')) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
cssPath = cssPath.split('?')[0]?.split('#')[0] ?? cssPath;
|
|
589
|
+
const cssTarget = cssPath.startsWith('/') ? cssPath.slice(1) : `${cssDir}/${cssPath}`;
|
|
590
|
+
const resolved = posix.normalize(cssTarget);
|
|
591
|
+
if (resolved.startsWith('../') || resolved === '..' || posix.isAbsolute(resolved)) {
|
|
592
|
+
throw new Error(`The stylesheet ${stylesheet} references a path outside the artifact root: ${raw}`);
|
|
593
|
+
}
|
|
594
|
+
sink.push(decodeReferenceSegments(resolved, `stylesheet ${stylesheet}`));
|
|
595
|
+
};
|
|
596
|
+
for (const cssUrl of cssUrls) {
|
|
597
|
+
collectCssTarget(cssUrl, resolvedUrls);
|
|
598
|
+
}
|
|
599
|
+
for (const cssImport of cssImports) {
|
|
600
|
+
collectCssTarget(cssImport, resolvedImports);
|
|
601
|
+
}
|
|
602
|
+
for (const resolvedUrl of resolvedUrls) {
|
|
603
|
+
referenced.add(resolvedUrl);
|
|
604
|
+
}
|
|
605
|
+
for (const resolvedImport of resolvedImports) {
|
|
606
|
+
referenced.add(resolvedImport);
|
|
607
|
+
// Imports are stylesheets by syntax whatever their URL looks like, and
|
|
608
|
+
// deduplication against earlier references must not suppress
|
|
609
|
+
// traversal: the browser still applies the imported file as CSS.
|
|
610
|
+
if (deploymentPaths.has(resolvedImport)
|
|
611
|
+
&& !visitedStylesheets.has(resolvedImport)
|
|
612
|
+
&& !stylesheetQueue.includes(resolvedImport)) {
|
|
613
|
+
stylesheetQueue.push(resolvedImport);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const reference of [...referenced].sort()) {
|
|
618
|
+
if (pagesControlArtifactNames.has(reference)) {
|
|
619
|
+
throw new Error(`The deployment index.html references the Pages control file ${reference}; `
|
|
620
|
+
+ 'control artifacts are consumed by the host, not served to browsers.');
|
|
621
|
+
}
|
|
622
|
+
if (!deploymentPaths.has(reference)) {
|
|
623
|
+
throw new Error(`The deployment index.html references a missing file: ${reference} `
|
|
624
|
+
+ `(resolved under ${deploymentRoot}).`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Declared legal pages are deployment-promised documents; their local
|
|
628
|
+
// references resolve against the page's own directory.
|
|
629
|
+
for (const page of declaredLegalPages(deploymentRoot)) {
|
|
630
|
+
const rawPageHtml = readFileSync(join(deploymentRoot, page), 'utf8');
|
|
631
|
+
const pageStyleReferences = [];
|
|
632
|
+
const pageCssSource = stripScriptsAndComments(rawPageHtml);
|
|
633
|
+
for (const styleBody of pageCssSource.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
|
|
634
|
+
collectInlineCssReferences(styleBody[1] ?? '', pageStyleReferences);
|
|
635
|
+
}
|
|
636
|
+
for (const cssReference of pageStyleReferences) {
|
|
637
|
+
assertLocalReferenceResolves(cssReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
638
|
+
}
|
|
639
|
+
const strippedPageHtml = stripNonMarkupRanges(rawPageHtml);
|
|
640
|
+
if (/<base\b/iu.test(strippedPageHtml)) {
|
|
641
|
+
throw new Error(`The legal page ${page} declares a base element; reference resolution `
|
|
642
|
+
+ 'against a legal-page base is unsupported by this verifier.');
|
|
643
|
+
}
|
|
644
|
+
const metaTagPattern = /<meta\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>/giu;
|
|
645
|
+
for (const meta of rawPageHtml.matchAll(metaTagPattern)) {
|
|
646
|
+
const metaTag = meta[0] ?? '';
|
|
647
|
+
if (!/http-equiv\s*=\s*["']?refresh/iu.test(metaTag)) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const content = metaTag.match(/content\s*=\s*(?:"([^"]*)"|'([^']*)')/iu);
|
|
651
|
+
if (content === null) {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const decodedContent = decodeHtmlReferences(content[1] ?? '');
|
|
655
|
+
const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(decodedContent);
|
|
656
|
+
if (target !== null) {
|
|
657
|
+
assertLocalReferenceResolves(target[1] ?? target[2] ?? target[3] ?? '', page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const pageHtml = strippedPageHtml;
|
|
661
|
+
for (const tag of pageHtml.matchAll(/<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu)) {
|
|
662
|
+
const tagName = (tag[1] ?? '').toLowerCase();
|
|
663
|
+
const attributes = tag[2] ?? '';
|
|
664
|
+
const resourceNames = tagName === 'object'
|
|
665
|
+
? ['href', 'src', 'poster', 'data']
|
|
666
|
+
: ['href', 'src', 'poster', 'xlink:href'];
|
|
667
|
+
for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
|
|
668
|
+
const name = (attribute[1] ?? '').toLowerCase();
|
|
669
|
+
const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
|
|
670
|
+
if (name === 'style') {
|
|
671
|
+
const pageCssReferences = [];
|
|
672
|
+
collectInlineCssReferences(decodeHtmlReferences(value), pageCssReferences);
|
|
673
|
+
for (const cssReference of pageCssReferences) {
|
|
674
|
+
assertLocalReferenceResolves(cssReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
675
|
+
}
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (name === 'srcdoc' && value.length > 0) {
|
|
682
|
+
const embeddedReferences = [];
|
|
683
|
+
collectEmbeddedDocumentReferences(decodeHtmlReferences(value), embeddedReferences);
|
|
684
|
+
for (const embeddedReference of embeddedReferences) {
|
|
685
|
+
assertLocalReferenceResolves(embeddedReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (name === 'srcset') {
|
|
690
|
+
const candidates = [];
|
|
691
|
+
pushSrcsetCandidates(decodeHtmlReferences(value), candidates);
|
|
692
|
+
for (const candidate of candidates) {
|
|
693
|
+
assertLocalReferenceResolves(candidate, page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (value.length === 0) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
assertLocalReferenceResolves(value, page.slice(0, -'index.html'.length), deploymentPaths, page);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return [...referencedDirectoryUrls];
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Decode attribute values with standards-compliant HTML semantics using the
|
|
708
|
+
* package's existing entities dependency (named references, numeric
|
|
709
|
+
* references, and U+FFFD replacement for invalid code points).
|
|
710
|
+
*/
|
|
711
|
+
function decodeHtmlReferences(text) {
|
|
712
|
+
return decodeHTMLAttribute(text);
|
|
713
|
+
}
|
|
714
|
+
/** List the deployment's declared legal page index files. */
|
|
715
|
+
function declaredLegalPages(deploymentRoot) {
|
|
716
|
+
const manifestPath = join(deploymentRoot, 'legal-site.json');
|
|
717
|
+
if (!existsSync(manifestPath)) {
|
|
718
|
+
return [];
|
|
719
|
+
}
|
|
720
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
721
|
+
if (typeof parsed !== 'object'
|
|
722
|
+
|| parsed === null
|
|
723
|
+
|| !Array.isArray(parsed.pages)) {
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
return parsed.pages
|
|
727
|
+
.filter((page) => typeof page.path === 'string')
|
|
728
|
+
.map((page) => `${page.path.slice(1)}index.html`);
|
|
729
|
+
}
|
|
730
|
+
/** Resolve a legal-page reference against its directory and require the file. */
|
|
731
|
+
function assertLocalReferenceResolves(reference, pageDirectory, deploymentPaths, page) {
|
|
732
|
+
const decoded = decodeHtmlReferences(reference).trim();
|
|
733
|
+
const schemeSeparated = /^([a-z][a-z0-9+.-]*):/iu.exec(decoded);
|
|
734
|
+
if (decoded.length === 0 || decoded.startsWith('#') || decoded.startsWith('data:')) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (decoded.startsWith('//')) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
let effective = decoded;
|
|
741
|
+
if (schemeSeparated !== null && schemeSeparated[1] !== undefined) {
|
|
742
|
+
const scheme = schemeSeparated[1].toLowerCase();
|
|
743
|
+
if (scheme === 'https' || scheme === 'http') {
|
|
744
|
+
const afterScheme = decoded.slice(schemeSeparated[1].length + 1);
|
|
745
|
+
if (afterScheme.startsWith('//')) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
effective = afterScheme;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
let withoutQuery = effective.split(/[?#]/u)[0] ?? effective;
|
|
755
|
+
if (withoutQuery.length === 0) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
withoutQuery = withoutQuery.split('\\').join('/');
|
|
759
|
+
try {
|
|
760
|
+
withoutQuery = withoutQuery
|
|
761
|
+
.split('/')
|
|
762
|
+
.map((segment) => decodeURIComponent(segment))
|
|
763
|
+
.join('/');
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
throw new Error(`The legal page ${page} references a malformed percent-encoded URL: ${reference}`);
|
|
767
|
+
}
|
|
768
|
+
const fromDirectory = pageDirectory.length > 0 ? `./${pageDirectory}` : '.';
|
|
769
|
+
let normalized = posix.normalize(withoutQuery.startsWith('/') ? withoutQuery.slice(1) : `${fromDirectory}/${withoutQuery}`);
|
|
770
|
+
if (normalized.endsWith('/') || normalized === '.') {
|
|
771
|
+
normalized = normalized === '.' ? 'index.html' : `${normalized}index.html`;
|
|
772
|
+
}
|
|
773
|
+
if (normalized.startsWith('../') || normalized === '..' || posix.isAbsolute(normalized)) {
|
|
774
|
+
throw new Error(`The legal page ${page} references a path outside the deployment root: ${reference}`);
|
|
775
|
+
}
|
|
776
|
+
if (pagesControlArtifactNames.has(normalized)) {
|
|
777
|
+
throw new Error(`The legal page ${page} references the Pages control file ${normalized}; `
|
|
778
|
+
+ 'control artifacts are consumed by the host, not served to browsers.');
|
|
779
|
+
}
|
|
780
|
+
if (!deploymentPaths.has(normalized)) {
|
|
781
|
+
throw new Error(`The legal page ${page} references a missing file: ${reference} `
|
|
782
|
+
+ `(resolved to ${normalized}).`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/** Apply the full resource scanner to an embedded srcdoc document. */
|
|
786
|
+
function collectEmbeddedDocumentReferences(document, references) {
|
|
787
|
+
// The caller decoded this document's own attribute once, matching the
|
|
788
|
+
// browser's per-attribute decoding; style blocks are collected before
|
|
789
|
+
// stripping so their CSS references survive.
|
|
790
|
+
for (const styleBody of document.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
|
|
791
|
+
collectInlineCssReferences(styleBody[1] ?? '', references);
|
|
792
|
+
}
|
|
793
|
+
const stripped = stripNonMarkupRanges(document);
|
|
794
|
+
for (const tag of stripped.matchAll(/<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu)) {
|
|
795
|
+
const tagName = (tag[1] ?? '').toLowerCase();
|
|
796
|
+
const attributes = tag[2] ?? '';
|
|
797
|
+
const resourceNames = tagName === 'object'
|
|
798
|
+
? ['href', 'src', 'poster', 'data']
|
|
799
|
+
: ['href', 'src', 'poster', 'xlink:href'];
|
|
800
|
+
let embeddedMetaRefresh = false;
|
|
801
|
+
let embeddedMetaContent;
|
|
802
|
+
for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
|
|
803
|
+
const name = (attribute[1] ?? '').toLowerCase();
|
|
804
|
+
const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
|
|
805
|
+
if (tagName === 'meta' && name === 'http-equiv') {
|
|
806
|
+
embeddedMetaRefresh = value.trim().toLowerCase() === 'refresh';
|
|
807
|
+
}
|
|
808
|
+
if (tagName === 'meta' && name === 'content') {
|
|
809
|
+
embeddedMetaContent = value;
|
|
810
|
+
}
|
|
811
|
+
if (name === 'style') {
|
|
812
|
+
collectInlineCssReferences(decodeHtmlReferences(value), references);
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (svgUrlAttributeNames.has(name) && value.includes('url(')) {
|
|
816
|
+
collectInlineCssReferences(decodeHtmlReferences(value), references);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (name === 'srcdoc' && value.length > 0) {
|
|
823
|
+
collectEmbeddedDocumentReferences(decodeHtmlReferences(value), references);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (name !== 'srcset' && value.length > 0) {
|
|
827
|
+
references.push(value);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (embeddedMetaRefresh && embeddedMetaContent !== undefined) {
|
|
831
|
+
const decodedContent = decodeHtmlReferences(embeddedMetaContent);
|
|
832
|
+
const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(decodedContent);
|
|
833
|
+
if (target !== null) {
|
|
834
|
+
references.push(target[1] ?? target[2] ?? target[3] ?? '');
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const srcset = attributes.match(/(?:^|\s)srcset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/iu);
|
|
838
|
+
if (srcset !== null) {
|
|
839
|
+
const rawCandidates = srcset[1] ?? srcset[2] ?? srcset[3] ?? '';
|
|
840
|
+
pushSrcsetCandidates(decodeHtmlReferences(rawCandidates), references);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/** Tokenize srcset candidates into the reference list. */
|
|
845
|
+
/** Whether a link rel token list applies the target as a stylesheet. */
|
|
846
|
+
function isStylesheetRel(rel) {
|
|
847
|
+
return rel !== undefined && rel.split(/\s+/u).includes('stylesheet');
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Extract live url() targets and @import targets from a stylesheet with a
|
|
851
|
+
* small string-aware tokenizer:
|
|
852
|
+
*
|
|
853
|
+
* - comments are skipped;
|
|
854
|
+
* - url()/@import text inside quoted strings is inert content, so a
|
|
855
|
+
* `content: "url(./missing.png)"` rule never becomes a reference;
|
|
856
|
+
* - @import is honored only before the first top-level style block, because
|
|
857
|
+
* CSS parsing makes later imports inert;
|
|
858
|
+
* - @import supports both quoted-string and url() forms.
|
|
859
|
+
*/
|
|
860
|
+
function extractStylesheetTargets(css) {
|
|
861
|
+
const cssUrls = [];
|
|
862
|
+
const cssImports = [];
|
|
863
|
+
let index = 0;
|
|
864
|
+
let sawTopLevelBlock = false;
|
|
865
|
+
const skipString = () => {
|
|
866
|
+
const quote = css[index] ?? '';
|
|
867
|
+
index += 1;
|
|
868
|
+
while (index < css.length) {
|
|
869
|
+
const inner = css[index] ?? '';
|
|
870
|
+
index += 1;
|
|
871
|
+
if (inner === '\\') {
|
|
872
|
+
index += 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (inner === quote) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
while (index < css.length) {
|
|
881
|
+
const char = css[index] ?? '';
|
|
882
|
+
if (char === '"' || char === "'") {
|
|
883
|
+
skipString();
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (char === '/' && (css[index + 1] ?? '') === '*') {
|
|
887
|
+
const end = css.indexOf('*/', index + 2);
|
|
888
|
+
index = end === -1 ? css.length : end + 2;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (char === '{') {
|
|
892
|
+
sawTopLevelBlock = true;
|
|
893
|
+
index += 1;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const rest = css.slice(index);
|
|
897
|
+
const urlMatch = /^url\(/iu.exec(rest);
|
|
898
|
+
if (urlMatch !== null) {
|
|
899
|
+
index += urlMatch[0].length;
|
|
900
|
+
let quote = '';
|
|
901
|
+
let argument = '';
|
|
902
|
+
while (index < css.length) {
|
|
903
|
+
const inner = css[index] ?? '';
|
|
904
|
+
if (quote === '' && (inner === '"' || inner === "'")) {
|
|
905
|
+
quote = inner;
|
|
906
|
+
index += 1;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (quote !== '' && inner === quote) {
|
|
910
|
+
quote = '';
|
|
911
|
+
index += 1;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (quote !== '' && inner === '\\') {
|
|
915
|
+
argument += inner + (css[index + 1] ?? '');
|
|
916
|
+
index += 2;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (quote === '' && inner === ')') {
|
|
920
|
+
index += 1;
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
argument += inner;
|
|
924
|
+
index += 1;
|
|
925
|
+
}
|
|
926
|
+
cssUrls.push(argument.trim());
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const importMatch = !sawTopLevelBlock ? /^@import\b/iu.exec(rest) : null;
|
|
930
|
+
if (importMatch !== null) {
|
|
931
|
+
index += importMatch[0].length;
|
|
932
|
+
let captured = '';
|
|
933
|
+
while (index < css.length) {
|
|
934
|
+
const inner = css[index] ?? '';
|
|
935
|
+
if (inner === ';') {
|
|
936
|
+
index += 1;
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
captured += inner;
|
|
940
|
+
index += 1;
|
|
941
|
+
}
|
|
942
|
+
const trimmed = captured.trim();
|
|
943
|
+
const quotedForm = /^(['"])([\s\S]*)\1$/u.exec(trimmed);
|
|
944
|
+
if (quotedForm !== null) {
|
|
945
|
+
cssImports.push((quotedForm[2] ?? '').trim());
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
const urlForm = /^url\(\s*(['"]?)([^'")]*)\1\s*\)$/iu.exec(trimmed);
|
|
949
|
+
if (urlForm !== null) {
|
|
950
|
+
cssImports.push((urlForm[2] ?? '').trim());
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
index += 1;
|
|
956
|
+
}
|
|
957
|
+
return { cssUrls, cssImports };
|
|
958
|
+
}
|
|
959
|
+
/** Decode CSS string escapes (backslash-character and hex forms) like browsers do. */
|
|
960
|
+
function decodeCssEscapes(value) {
|
|
961
|
+
if (!value.includes('\\')) {
|
|
962
|
+
return value;
|
|
963
|
+
}
|
|
964
|
+
return value.replace(/\\(?:([0-9a-fA-F]{1,6})\s?|(.))/gu, (_match, hex, plain) => {
|
|
965
|
+
if (hex === undefined) {
|
|
966
|
+
return plain ?? '';
|
|
967
|
+
}
|
|
968
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
969
|
+
return codePoint > 1114111 || Number.isNaN(codePoint)
|
|
970
|
+
? '\uFFFD'
|
|
971
|
+
: String.fromCodePoint(codePoint);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/** Percent-decode each path segment, mirroring the HTML reference handling. */
|
|
975
|
+
function decodeReferenceSegments(path, label) {
|
|
976
|
+
try {
|
|
977
|
+
return path
|
|
978
|
+
.split('/')
|
|
979
|
+
.map((segment) => decodeURIComponent(segment))
|
|
980
|
+
.join('/');
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
throw new Error(`${label} references a malformed percent-encoded URL: ${path}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function pushSrcsetCandidates(candidates, references) {
|
|
987
|
+
let holdingUrl = false;
|
|
988
|
+
for (const token of candidates.split(/\s+/u)) {
|
|
989
|
+
for (const piece of token.split(/(?<=\d[xw]),/u)) {
|
|
990
|
+
const trimmed = piece.replace(/^[,]+|[,]+$/gu, '');
|
|
991
|
+
if (trimmed.length === 0) {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const isDescriptor = /^[\d.]+[wx]?$/u.test(trimmed);
|
|
995
|
+
if (holdingUrl && isDescriptor) {
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
references.push(trimmed);
|
|
999
|
+
const endedCandidate = /,$/u.test(piece);
|
|
1000
|
+
holdingUrl = !isDescriptor && !endedCandidate;
|
|
1001
|
+
}
|
|
1002
|
+
if (/^[\d.]+[wx],?$/u.test(token)) {
|
|
1003
|
+
holdingUrl = false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/** Collect local url() and @import targets from inline CSS. */
|
|
1008
|
+
function collectInlineCssReferences(html, references) {
|
|
1009
|
+
for (const match of html.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/giu)) {
|
|
1010
|
+
references.push(match[2] ?? '');
|
|
1011
|
+
}
|
|
1012
|
+
for (const match of html.matchAll(/@import\s+(['"])([^'"]+)\1/giu)) {
|
|
1013
|
+
references.push(match[2] ?? '');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** Drop comments, script bodies, and style bodies from HTML before scanning. */
|
|
1017
|
+
/** Strip scripts and HTML comments while keeping style blocks intact. */
|
|
1018
|
+
function stripScriptsAndComments(html) {
|
|
1019
|
+
return html
|
|
1020
|
+
.replace(/<!--[\s\S]*?-->/gu, '')
|
|
1021
|
+
.replace(/<script\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/script\s*>/giu, '')
|
|
1022
|
+
.replace(/<template\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>[\s\S]*?<\/template\s*>/giu, '');
|
|
1023
|
+
}
|
|
1024
|
+
function stripNonMarkupRanges(html) {
|
|
1025
|
+
return html
|
|
1026
|
+
.replace(/<!--[\s\S]*?-->/gu, '')
|
|
1027
|
+
.replace(/<script\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/script\s*>/giu, '<script$1></script>')
|
|
1028
|
+
.replace(/<textarea\b[^>]*>[\s\S]*?<\/textarea\s*>/giu, '<textarea></textarea>')
|
|
1029
|
+
.replace(/<template\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>[\s\S]*?<\/template\s*>/giu, '<template></template>')
|
|
1030
|
+
.replace(/<title\b[^>]*>[\s\S]*?<\/title\s*>/giu, '<title></title>')
|
|
1031
|
+
.replace(/<style\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/style\s*>/giu, '<style$1></style>');
|
|
1032
|
+
}
|
|
1033
|
+
function verifyCloudflarePagesRoutes(deploymentRoot, profile) {
|
|
1034
|
+
const routesPath = `${deploymentRoot}/_routes.json`;
|
|
1035
|
+
if (!existsSync(routesPath)) {
|
|
1036
|
+
throw new Error('The deployment is missing _routes.json.');
|
|
1037
|
+
}
|
|
1038
|
+
const routes = JSON.parse(readFileSync(routesPath, 'utf8'));
|
|
1039
|
+
if (typeof routes !== 'object' || routes === null) {
|
|
1040
|
+
throw new Error('The deployment _routes.json must be an object.');
|
|
1041
|
+
}
|
|
1042
|
+
const record = routes;
|
|
1043
|
+
const include = record.include;
|
|
1044
|
+
const exclude = record.exclude;
|
|
1045
|
+
if (record.version !== 1 || !Array.isArray(include) || !Array.isArray(exclude)) {
|
|
1046
|
+
throw new Error('The deployment _routes.json must declare version 1 with include and exclude arrays.');
|
|
1047
|
+
}
|
|
1048
|
+
const expectedInclude = profile === 'api-canonical-index'
|
|
1049
|
+
? ['/api/*', '/index.html']
|
|
1050
|
+
: ['/api/*'];
|
|
1051
|
+
const actual = {
|
|
1052
|
+
version: 1,
|
|
1053
|
+
include: [...include],
|
|
1054
|
+
exclude: [...exclude],
|
|
1055
|
+
};
|
|
1056
|
+
const expected = {
|
|
1057
|
+
version: 1,
|
|
1058
|
+
include: expectedInclude,
|
|
1059
|
+
exclude: [],
|
|
1060
|
+
};
|
|
1061
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
1062
|
+
throw new Error(`The deployment _routes.json does not match the reviewed ${profile} profile. `
|
|
1063
|
+
+ `Expected ${JSON.stringify(expected)} but found ${JSON.stringify(actual)}. `
|
|
1064
|
+
+ 'Broader worker routes are rejected because they silently bypass the '
|
|
1065
|
+
+ 'static PWA serving this verification guarantees.');
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
include: actual.include.map(String),
|
|
1069
|
+
exclude: actual.exclude.map(String),
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function assertNoSourceRoutesShadowed(routes, sourceFiles, profile) {
|
|
1073
|
+
// The api-canonical-index profile deliberately routes /index.html through
|
|
1074
|
+
// the worker, whose reviewed handler serves the canonical index bytes from
|
|
1075
|
+
// the deployment assets; every other route collision is a real shadowing.
|
|
1076
|
+
const shadowingRoutes = routes.include.filter((route) => !(profile === 'api-canonical-index' && route === '/index.html'));
|
|
1077
|
+
for (const file of sourceFiles) {
|
|
1078
|
+
const requestPath = `/${file.path}`;
|
|
1079
|
+
for (const route of shadowingRoutes) {
|
|
1080
|
+
if (cloudflarePagesPathMatches(route, requestPath)) {
|
|
1081
|
+
throw new Error(`The source artifact file ${file.path} is served at ${requestPath}, which the `
|
|
1082
|
+
+ `Pages worker route ${route} intercepts; the reviewed profiles route /api/* `
|
|
1083
|
+
+ 'through the worker, so precached files must not live under those paths.');
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/** Declared legal pages must be served statically, not intercepted by the worker. */
|
|
1089
|
+
function assertNoLegalRoutesShadowed(routes, legalPages, profile) {
|
|
1090
|
+
for (const page of legalPages) {
|
|
1091
|
+
const requestPath = `/${page.slice(0, -'index.html'.length)}`;
|
|
1092
|
+
for (const route of routes.include) {
|
|
1093
|
+
if (cloudflarePagesPathMatches(route, requestPath)
|
|
1094
|
+
&& !(profile === 'api-canonical-index' && route === '/index.html')) {
|
|
1095
|
+
throw new Error(`The declared legal page ${page} is served at ${requestPath}, which the `
|
|
1096
|
+
+ `Pages worker route ${route} intercepts instead of serving the file.`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function verifyCloudflarePagesHeaders(deploymentRoot, sourceFiles, deploymentFiles, declaredLegalDirectories, workerRoutes) {
|
|
1102
|
+
const headersPath = `${deploymentRoot}/_headers`;
|
|
1103
|
+
if (!existsSync(headersPath)) {
|
|
1104
|
+
throw new Error('The deployment is missing _headers.');
|
|
1105
|
+
}
|
|
1106
|
+
const blocks = parseCloudflarePagesHeaders(readFileSync(headersPath, 'utf8'));
|
|
1107
|
+
const sourcePaths = new Set(sourceFiles.map((file) => file.path));
|
|
1108
|
+
const deploymentPaths = new Set(deploymentFiles.map((file) => file.path));
|
|
1109
|
+
const requirements = [];
|
|
1110
|
+
// Host-owned legal content must stay fresh: the deployment declares the
|
|
1111
|
+
// pages and ships legal-site.json, and stale privacy or terms pages after
|
|
1112
|
+
// an update are a compliance problem, so they carry revalidation policy.
|
|
1113
|
+
for (const file of deploymentFiles) {
|
|
1114
|
+
if (file.path === 'legal-site.json') {
|
|
1115
|
+
requirements.push({
|
|
1116
|
+
requestPath: '/legal-site.json',
|
|
1117
|
+
expected: freshCacheControl,
|
|
1118
|
+
label: 'legal-site.json',
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
const declaredLegalPage = file.path.endsWith('/index.html')
|
|
1122
|
+
&& file.path.includes('/')
|
|
1123
|
+
&& declaredLegalDirectories.has(`${file.path.slice(0, -'index.html'.length - 1)}/`);
|
|
1124
|
+
if ((declaredLegalPage || (!sourcePaths.has(file.path) && file.path.includes('/')))
|
|
1125
|
+
&& file.path.endsWith('/index.html')) {
|
|
1126
|
+
const directoryUrl = `/${file.path.slice(0, -'index.html'.length)}`;
|
|
1127
|
+
requirements.push({
|
|
1128
|
+
requestPath: directoryUrl,
|
|
1129
|
+
expected: freshCacheControl,
|
|
1130
|
+
label: `${file.path} directory URL`,
|
|
1131
|
+
});
|
|
1132
|
+
requirements.push({
|
|
1133
|
+
requestPath: `/${file.path}`,
|
|
1134
|
+
expected: freshCacheControl,
|
|
1135
|
+
label: `${file.path} index URL`,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (sourcePaths.has('index.html')) {
|
|
1140
|
+
// Under api-canonical-index the worker serves /index.html and Pages
|
|
1141
|
+
// _headers rules do not apply to function responses, so the index.html
|
|
1142
|
+
// policy is only certifiable for the statically served root path.
|
|
1143
|
+
requirements.push({ requestPath: '/', expected: freshCacheControl, label: 'the PWA root' });
|
|
1144
|
+
if (workerRoutes.include.includes('/index.html')) {
|
|
1145
|
+
requirements.push({
|
|
1146
|
+
requestPath: '/index.html',
|
|
1147
|
+
expected: freshCacheControl,
|
|
1148
|
+
label: 'index.html (worker-served; static _headers policy not certified)',
|
|
1149
|
+
skip: true,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
requirements.push({
|
|
1154
|
+
requestPath: '/index.html',
|
|
1155
|
+
expected: freshCacheControl,
|
|
1156
|
+
label: 'index.html',
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
for (const file of [...freshCacheControlFiles, ...noStoreCacheControlFiles]) {
|
|
1161
|
+
if (sourcePaths.has(file.slice(1))) {
|
|
1162
|
+
requirements.push({
|
|
1163
|
+
requestPath: file,
|
|
1164
|
+
expected: noStoreCacheControlFiles.has(file)
|
|
1165
|
+
? noStoreCacheControl
|
|
1166
|
+
: freshCacheControl,
|
|
1167
|
+
label: file.slice(1),
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
// Directory-scoped policies are evaluated against every actual file path so
|
|
1172
|
+
// placeholder-shaped blocks that do not cover the real URLs cannot satisfy
|
|
1173
|
+
// the requirement by coincidence. Only filenames that carry a content
|
|
1174
|
+
// hash may be cached immutably; stable names require revalidation so a
|
|
1175
|
+
// later release at the same URL is served fresh.
|
|
1176
|
+
for (const file of sourceFiles) {
|
|
1177
|
+
const directory = file.path.includes('/') ? (file.path.split('/')[0] ?? '') : '';
|
|
1178
|
+
if (directory.length === 0) {
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (immutableCacheControlDirectories.has(directory)) {
|
|
1182
|
+
// Filename-based hash detection cannot distinguish hashes from words
|
|
1183
|
+
// after six review rounds of counterexamples, so the contract is now
|
|
1184
|
+
// explicit-policy: every asset URL must carry a deliberate caching
|
|
1185
|
+
// strategy — immutable for hashed pipelines, revalidation for stable
|
|
1186
|
+
// names — and only a missing or conflicting policy fails.
|
|
1187
|
+
requirements.push({
|
|
1188
|
+
requestPath: `/${file.path}`,
|
|
1189
|
+
expected: immutableCacheControl,
|
|
1190
|
+
alsoAccepts: freshCacheControl,
|
|
1191
|
+
label: `asset ${file.path}`,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
else if (noStoreCacheControlDirectories.has(directory)) {
|
|
1195
|
+
requirements.push({
|
|
1196
|
+
requestPath: `/${file.path}`,
|
|
1197
|
+
expected: noStoreCacheControl,
|
|
1198
|
+
label: `stable-name ${file.path}`,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
for (const requirement of requirements) {
|
|
1203
|
+
if (requirement.skip === true) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const effective = evaluateCloudflarePagesHeader(blocks, requirement.requestPath, 'Cache-Control');
|
|
1207
|
+
if (effective.additions.length > 1) {
|
|
1208
|
+
throw new Error(`Conflicting Cloudflare Pages cache policy for ${requirement.label}: `
|
|
1209
|
+
+ `${requirement.requestPath} receives comma-joined Cache-Control values `
|
|
1210
|
+
+ `(${effective.additions.join(', ')}) because more than one matching block `
|
|
1211
|
+
+ 'adds the header. Cloudflare joins duplicate headers with commas instead '
|
|
1212
|
+
+ 'of overriding them.');
|
|
1213
|
+
}
|
|
1214
|
+
if (effective.value === undefined) {
|
|
1215
|
+
throw new Error(`Cloudflare Pages cache policy for ${requirement.label} is missing: no matching `
|
|
1216
|
+
+ `block sets Cache-Control to ${requirement.expected} for `
|
|
1217
|
+
+ `${requirement.requestPath}`
|
|
1218
|
+
+ (effective.removed
|
|
1219
|
+
? ' (a matching block removed the header with a removal directive)'
|
|
1220
|
+
: '')
|
|
1221
|
+
+ '.');
|
|
1222
|
+
}
|
|
1223
|
+
const acceptedValues = [requirement.expected, requirement.alsoAccepts]
|
|
1224
|
+
.filter((value) => value !== undefined)
|
|
1225
|
+
.map(normalizeHeaderDirectiveValue);
|
|
1226
|
+
if (!acceptedValues.includes(normalizeHeaderDirectiveValue(effective.value))) {
|
|
1227
|
+
throw new Error(`Cloudflare Pages cache policy for ${requirement.label} is wrong: expected `
|
|
1228
|
+
+ `${requirement.expected}${requirement.alsoAccepts === undefined ? '' : ` or ${requirement.alsoAccepts}`} `
|
|
1229
|
+
+ `for ${requirement.requestPath} but the effective value is ${effective.value}.`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/** Whether a filename carries a Vite-style content hash segment. */
|
|
1234
|
+
function carriesContentHash(portablePath) {
|
|
1235
|
+
const name = portablePath.split('/').pop() ?? '';
|
|
1236
|
+
// Only unambiguous hexadecimal segments earn the immutable policy. Vite's
|
|
1237
|
+
// URL-safe base64 segments cannot be told apart from mixed-case words like
|
|
1238
|
+
// "Player2D" lexically, so they take the fail-safe revalidation policy:
|
|
1239
|
+
// a misclassified hash merely revalidates, while a misclassified stable
|
|
1240
|
+
// name would serve stale release bytes for a year.
|
|
1241
|
+
return /(^|[/.-])[0-9a-f]{8,}\.[a-z0-9]+$/iu.test(name);
|
|
1242
|
+
}
|
|
1243
|
+
function verifyCloudflarePagesRedirects(deploymentRoot, sourceFiles, deploymentLegalPaths, referencedDirectoryUrls) {
|
|
1244
|
+
const redirectsPath = `${deploymentRoot}/_redirects`;
|
|
1245
|
+
if (!existsSync(redirectsPath)) {
|
|
1246
|
+
throw new Error('The deployment is missing _redirects.');
|
|
1247
|
+
}
|
|
1248
|
+
const rules = parseCloudflarePagesRedirects(readFileSync(redirectsPath, 'utf8'));
|
|
1249
|
+
const protectedPaths = [
|
|
1250
|
+
...protectedPwaMetadataRequestPaths,
|
|
1251
|
+
...sourceFiles.map((file) => `/${file.path}`),
|
|
1252
|
+
...deploymentLegalPaths,
|
|
1253
|
+
...referencedDirectoryUrls,
|
|
1254
|
+
];
|
|
1255
|
+
for (const rule of rules) {
|
|
1256
|
+
for (const protectedPath of protectedPaths) {
|
|
1257
|
+
if (cloudflarePagesPathMatches(rule.source, protectedPath)) {
|
|
1258
|
+
throw new Error(`The deployment _redirects rule ${rule.source} covers the protected PWA path `
|
|
1259
|
+
+ `${protectedPath} and moves it to ${rule.destination}; the game root and `
|
|
1260
|
+
+ 'PWA-critical files must be served directly.');
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|