@mpgd/cli 0.13.0 → 0.14.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.
Files changed (27) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +363 -3
  3. package/dist/microsoft-store-package-acceptance.d.ts +54 -0
  4. package/dist/microsoft-store-package-acceptance.js +787 -0
  5. package/dist/microsoft-store-package-generation-contract.d.ts +129 -0
  6. package/dist/microsoft-store-package-generation-contract.js +3 -0
  7. package/dist/microsoft-store-package-generation-download.d.ts +22 -0
  8. package/dist/microsoft-store-package-generation-download.js +453 -0
  9. package/dist/microsoft-store-package-generation-input.d.ts +3 -0
  10. package/dist/microsoft-store-package-generation-input.js +384 -0
  11. package/dist/microsoft-store-package-generation-integrity.d.ts +5 -0
  12. package/dist/microsoft-store-package-generation-integrity.js +100 -0
  13. package/dist/microsoft-store-package-generation-manifest.d.ts +3 -0
  14. package/dist/microsoft-store-package-generation-manifest.js +67 -0
  15. package/dist/microsoft-store-package-generation-zip.d.ts +1 -0
  16. package/dist/microsoft-store-package-generation-zip.js +180 -0
  17. package/dist/microsoft-store-package-generation.d.ts +24 -0
  18. package/dist/microsoft-store-package-generation.js +430 -0
  19. package/dist/microsoft-store-submission.d.ts +79 -0
  20. package/dist/microsoft-store-submission.js +952 -0
  21. package/dist/microsoft-store-supported-locales.d.ts +1 -0
  22. package/dist/microsoft-store-supported-locales.js +33 -0
  23. package/package.json +6 -5
  24. package/templates/phaser-game/README.md +38 -0
  25. package/templates/phaser-game/gitignore +1 -0
  26. package/templates/phaser-game/mpgd.microsoft-store.json +29 -0
  27. package/templates/phaser-game/package.json +2 -0
@@ -0,0 +1,952 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { isIP } from 'node:net';
4
+ import path from 'node:path';
5
+ import { inflateSync } from 'node:zlib';
6
+ import { escapeMarkdownInline, escapeMarkdownTable, formatError, relativeOrAbsolute, } from './evidence-io.js';
7
+ import { isMicrosoftStoreSupportedListingLocale } from './microsoft-store-supported-locales.js';
8
+ export const microsoftStoreSubmissionSchemaVersion = 1;
9
+ // Microsoft Store listing and package identity limits documented by Microsoft Learn.
10
+ const maximumStoreDescriptionCharacters = 10000;
11
+ const maximumStoreDesktopScreenshots = 10;
12
+ const maximumStoreManifestIcons = 32;
13
+ const maximumStoreScreenshotBytes = 50 * 1024 * 1024;
14
+ const maximumStoreIconBytes = 2 * 1024 * 1024;
15
+ const minimumStoreScreenshotLongEdge = 1366;
16
+ const minimumStoreScreenshotShortEdge = 768;
17
+ const maximumDecodedScreenshotBytes = 256 * 1024 * 1024;
18
+ const maximumDecodedStoreIconBytes = 16 * 1024 * 1024;
19
+ const reservedPackageStringPrefix = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/u;
20
+ const microsoftStorePublisherAttributes = new Set([
21
+ 'CN',
22
+ 'L',
23
+ 'O',
24
+ 'OU',
25
+ 'E',
26
+ 'C',
27
+ 'S',
28
+ 'STREET',
29
+ 'T',
30
+ 'G',
31
+ 'I',
32
+ 'SN',
33
+ 'DC',
34
+ 'SERIALNUMBER',
35
+ ]);
36
+ const microsoftStorePublisherOidAttribute = /^OID\.(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*))+$/u;
37
+ const supportedManifestIconPurposes = new Set(['any', 'maskable', 'monochrome']);
38
+ const pngCrc32Table = createPngCrc32Table();
39
+ export function runMicrosoftStoreSubmissionPreflight(input) {
40
+ const gameRoot = readCanonicalDirectory(input.gameRoot, 'game root');
41
+ const artifactRoot = readCanonicalDirectoryInside(gameRoot, input.artifactRoot, 'Microsoft Store artifact root');
42
+ const configFile = readCanonicalFileInside(gameRoot, input.configFile, 'Microsoft Store submission config');
43
+ const config = parseMicrosoftStoreSubmissionConfig(readJson(configFile, 'submission config'));
44
+ const manifestFile = readCanonicalFileInside(artifactRoot, path.join(artifactRoot, 'manifest.webmanifest'), 'Microsoft Store web app manifest');
45
+ const manifestSnapshot = readJsonSnapshot(manifestFile, 'web app manifest', 1024 * 1024);
46
+ const manifest = parseManifest(manifestSnapshot.value, artifactRoot);
47
+ const warnings = collectManifestWarnings(manifest, config.productIdentity.reservedName);
48
+ const protectedFiles = [
49
+ { file: configFile, label: 'Microsoft Store submission config' },
50
+ { file: manifestFile, label: 'Microsoft Store web app manifest' },
51
+ ...manifest.icons.map((icon) => ({
52
+ file: icon.file,
53
+ label: 'Microsoft Store web app manifest icon',
54
+ })),
55
+ ];
56
+ const locales = Object.fromEntries(Object.entries(config.listing.locales)
57
+ .sort(([left], [right]) => compareCodeUnits(left, right))
58
+ .map(([locale, listing]) => [
59
+ locale,
60
+ {
61
+ description: listing.description,
62
+ screenshots: listing.screenshots.map((file) => {
63
+ const screenshotFile = readCanonicalFileInside(gameRoot, path.resolve(gameRoot, file), `Microsoft Store ${locale} screenshot`);
64
+ protectedFiles.push({
65
+ file: screenshotFile,
66
+ label: `Microsoft Store ${locale} screenshot`,
67
+ });
68
+ const image = readMicrosoftStoreScreenshot(screenshotFile);
69
+ return {
70
+ file: relativeOrAbsolute(gameRoot, screenshotFile),
71
+ sha256: image.sha256,
72
+ width: image.width,
73
+ height: image.height,
74
+ };
75
+ }),
76
+ },
77
+ ]));
78
+ const evidence = {
79
+ schemaVersion: microsoftStoreSubmissionSchemaVersion,
80
+ target: 'microsoft-store',
81
+ configFile: relativeOrAbsolute(gameRoot, configFile),
82
+ artifactRoot: relativeOrAbsolute(gameRoot, artifactRoot),
83
+ productIdentity: config.productIdentity,
84
+ manifest: {
85
+ file: relativeOrAbsolute(gameRoot, manifestFile),
86
+ sha256: hashBytes(manifestSnapshot.bytes),
87
+ id: manifest.id,
88
+ name: manifest.name,
89
+ shortName: manifest.shortName,
90
+ startUrl: manifest.startUrl,
91
+ scope: manifest.scope,
92
+ iconCount: manifest.icons.length,
93
+ icons: manifest.icons.map((icon) => ({
94
+ file: relativeOrAbsolute(gameRoot, icon.file),
95
+ sha256: icon.sha256,
96
+ width: icon.width,
97
+ height: icon.height,
98
+ })),
99
+ },
100
+ listing: {
101
+ category: config.listing.category,
102
+ supportUrl: config.listing.supportUrl,
103
+ personalData: config.listing.personalData,
104
+ locales,
105
+ },
106
+ ageRating: config.ageRating,
107
+ commerce: config.commerce,
108
+ warnings,
109
+ };
110
+ const jsonFile = resolveOutputFileInside(gameRoot, input.jsonFile, 'submission evidence JSON');
111
+ const markdownFile = resolveOutputFileInside(gameRoot, input.markdownFile, 'submission evidence Markdown');
112
+ assertDistinctEvidenceFiles([
113
+ { file: jsonFile, label: 'submission evidence JSON' },
114
+ { file: markdownFile, label: 'submission evidence Markdown' },
115
+ ], protectedFiles);
116
+ writeFileSync(jsonFile, `${JSON.stringify(evidence, null, 2)}\n`);
117
+ writeFileSync(markdownFile, renderMicrosoftStoreSubmissionMarkdown(evidence));
118
+ return evidence;
119
+ }
120
+ export function parseMicrosoftStoreSubmissionConfig(input) {
121
+ const root = requireRecord(input, 'Microsoft Store submission config');
122
+ if (root.schemaVersion !== microsoftStoreSubmissionSchemaVersion) {
123
+ throw new Error('Microsoft Store submission config schemaVersion must be 1.');
124
+ }
125
+ const productIdentity = requireRecord(root.productIdentity, 'productIdentity');
126
+ const listing = requireRecord(root.listing, 'listing');
127
+ const personalData = requireRecord(listing.personalData, 'listing.personalData');
128
+ const ageRating = requireRecord(root.ageRating, 'ageRating');
129
+ const commerce = requireRecord(root.commerce, 'commerce');
130
+ const packageId = requireIdentityToken(productIdentity.packageId, 'productIdentity.packageId');
131
+ const publisherId = requirePublisherDistinguishedName(productIdentity.publisherId, 'productIdentity.publisherId');
132
+ if (listing.category !== 'Games') {
133
+ throw new Error('listing.category must be Games.');
134
+ }
135
+ const accessedOrTransmitted = requireBoolean(personalData.accessedOrTransmitted, 'listing.personalData.accessedOrTransmitted');
136
+ const privacyPolicyUrl = optionalPublicHttpsUrl(personalData.privacyPolicyUrl, 'listing.personalData.privacyPolicyUrl');
137
+ if (accessedOrTransmitted && privacyPolicyUrl === undefined) {
138
+ throw new Error('listing.personalData.privacyPolicyUrl is required when personal data is accessed or transmitted.');
139
+ }
140
+ const locales = parseListings(listing.locales);
141
+ if (ageRating.questionnaireCompleted !== true) {
142
+ throw new Error('ageRating.questionnaireCompleted must be true before submission.');
143
+ }
144
+ if (commerce.mode !== 'disabled') {
145
+ throw new Error('commerce.mode must stay disabled until Microsoft Store commerce is backed by server-side ledger verification.');
146
+ }
147
+ const iarcId = optionalProductionString(ageRating.iarcId, 'ageRating.iarcId');
148
+ return {
149
+ schemaVersion: microsoftStoreSubmissionSchemaVersion,
150
+ productIdentity: {
151
+ packageId,
152
+ publisherId,
153
+ publisherDisplayName: requireProductionString(productIdentity.publisherDisplayName, 'productIdentity.publisherDisplayName'),
154
+ reservedName: requireProductionString(productIdentity.reservedName, 'productIdentity.reservedName'),
155
+ },
156
+ listing: {
157
+ category: 'Games',
158
+ supportUrl: requirePublicHttpsUrl(listing.supportUrl, 'listing.supportUrl'),
159
+ personalData: {
160
+ accessedOrTransmitted,
161
+ ...(privacyPolicyUrl === undefined ? {} : { privacyPolicyUrl }),
162
+ },
163
+ locales,
164
+ },
165
+ ageRating: {
166
+ questionnaireCompleted: true,
167
+ ...(iarcId === undefined ? {} : { iarcId }),
168
+ },
169
+ commerce: { mode: 'disabled' },
170
+ };
171
+ }
172
+ export function renderMicrosoftStoreSubmissionMarkdown(evidence) {
173
+ const lines = [
174
+ '# Microsoft Store Submission Preflight',
175
+ '',
176
+ `- Target: ${evidence.target}`,
177
+ `- Package ID: ${escapeMarkdownInline(evidence.productIdentity.packageId)}`,
178
+ `- Publisher ID: ${escapeMarkdownInline(evidence.productIdentity.publisherId)}`,
179
+ `- Reserved name: ${escapeMarkdownInline(evidence.productIdentity.reservedName)}`,
180
+ `- Manifest: ${escapeMarkdownInline(evidence.manifest.file)} (${evidence.manifest.sha256})`,
181
+ `- Manifest icons: ${evidence.manifest.iconCount}`,
182
+ `- Commerce: ${evidence.commerce.mode}`,
183
+ `- Personal data accessed or transmitted: ${String(evidence.listing.personalData.accessedOrTransmitted)}`,
184
+ `- Privacy policy: ${escapeMarkdownInline(evidence.listing.personalData.privacyPolicyUrl ?? 'Not required')}`,
185
+ '',
186
+ '## Store Listings',
187
+ '',
188
+ '| Locale | Screenshots | Description |',
189
+ '| --- | ---: | --- |',
190
+ ];
191
+ for (const [locale, listing] of Object.entries(evidence.listing.locales)) {
192
+ lines.push(`| ${escapeMarkdownTable(locale)} | ${listing.screenshots.length} | ${escapeMarkdownTable(listing.description)} |`);
193
+ }
194
+ lines.push('', '## Warnings', '');
195
+ if (evidence.warnings.length === 0) {
196
+ lines.push('- None.');
197
+ }
198
+ else {
199
+ lines.push(...evidence.warnings.map((warning) => `- ${escapeMarkdownInline(warning)}`));
200
+ }
201
+ return `${lines.join('\n')}\n`;
202
+ }
203
+ function parseManifest(input, artifactRoot) {
204
+ const manifest = requireRecord(input, 'web app manifest');
205
+ const icons = requireArray(manifest.icons, 'web app manifest icons');
206
+ if (icons.length === 0) {
207
+ throw new Error('Web app manifest icons must not be empty.');
208
+ }
209
+ if (icons.length > maximumStoreManifestIcons) {
210
+ throw new Error(`Web app manifest icons must contain at most ${maximumStoreManifestIcons} entries.`);
211
+ }
212
+ const purposes = new Set();
213
+ const sizes = new Set();
214
+ const parsedIcons = icons.map((icon, index) => {
215
+ const record = requireRecord(icon, `web app manifest icons[${index}]`);
216
+ const label = `web app manifest icons[${index}]`;
217
+ const src = requireManifestUrl(record.src, `${label}.src`);
218
+ const type = requireNonEmptyString(record.type, `${label}.type`);
219
+ const iconSizes = requireNonEmptyString(record.sizes, `${label}.sizes`);
220
+ const purpose = requireNonEmptyString(record.purpose, `${label}.purpose`);
221
+ const sizeMatch = /^(\d+)x(\d+)$/u.exec(iconSizes);
222
+ if (type !== 'image/png') {
223
+ throw new Error(`${label}.type must be image/png.`);
224
+ }
225
+ if (sizeMatch === null) {
226
+ throw new Error(`${label}.sizes must declare one width and height in pixels.`);
227
+ }
228
+ for (const token of purpose.split(/[\t\n\f\r ]+/u)) {
229
+ if (token.length > 0) {
230
+ if (!supportedManifestIconPurposes.has(token)) {
231
+ throw new Error(`${label}.purpose contains an unsupported token: ${token}.`);
232
+ }
233
+ purposes.add(token);
234
+ }
235
+ }
236
+ sizes.add(iconSizes);
237
+ const declaredWidth = Number(sizeMatch[1]);
238
+ const declaredHeight = Number(sizeMatch[2]);
239
+ const iconFile = readManifestAssetFile(artifactRoot, src, label);
240
+ const image = readMicrosoftStorePng(iconFile, {
241
+ label,
242
+ maximumBytes: maximumStoreIconBytes,
243
+ maximumDecodedBytes: maximumDecodedStoreIconBytes,
244
+ validateDimensions: (width, height) => {
245
+ if (width !== declaredWidth || height !== declaredHeight) {
246
+ throw new Error(`${label} dimensions must match ${iconSizes}: ${iconFile}`);
247
+ }
248
+ },
249
+ });
250
+ return { file: iconFile, ...image };
251
+ });
252
+ for (const purpose of ['any', 'maskable']) {
253
+ if (!purposes.has(purpose)) {
254
+ throw new Error(`Web app manifest icons must include purpose: ${purpose}.`);
255
+ }
256
+ }
257
+ for (const size of ['192x192', '512x512']) {
258
+ if (!sizes.has(size)) {
259
+ throw new Error(`Web app manifest icons must include size: ${size}.`);
260
+ }
261
+ }
262
+ if (manifest.display !== 'standalone') {
263
+ throw new Error('Web app manifest display must be standalone.');
264
+ }
265
+ const startUrl = requireManifestUrl(manifest.start_url, 'web app manifest start_url');
266
+ const scope = requireManifestUrl(manifest.scope, 'web app manifest scope');
267
+ assertManifestStartUrlInsideScope(startUrl, scope);
268
+ return {
269
+ source: manifest,
270
+ id: requireProductionString(manifest.id, 'web app manifest id'),
271
+ name: requireProductionString(manifest.name, 'web app manifest name'),
272
+ shortName: requireProductionString(manifest.short_name, 'web app manifest short_name'),
273
+ startUrl,
274
+ scope,
275
+ icons: parsedIcons,
276
+ };
277
+ }
278
+ function collectManifestWarnings(manifest, reservedName) {
279
+ const warnings = [];
280
+ if (manifest.name !== reservedName) {
281
+ warnings.push('The web app manifest name differs from the Partner Center reserved name.');
282
+ }
283
+ for (const [field, label] of [
284
+ ['description', 'description'],
285
+ ['background_color', 'splash background color'],
286
+ ['orientation', 'orientation'],
287
+ ['screenshots', 'manifest screenshots'],
288
+ ['categories', 'categories'],
289
+ ]) {
290
+ if (manifest.source[field] === undefined) {
291
+ warnings.push(`The web app manifest does not declare the recommended ${label}.`);
292
+ }
293
+ }
294
+ return warnings;
295
+ }
296
+ function parseListings(input) {
297
+ const locales = requireRecord(input, 'listing.locales');
298
+ const entries = Object.entries(locales);
299
+ if (entries.length === 0) {
300
+ throw new Error('listing.locales must contain at least one locale.');
301
+ }
302
+ return Object.fromEntries(entries.map(([locale, inputListing]) => {
303
+ assertLocale(locale);
304
+ const listing = requireRecord(inputListing, `listing.locales.${locale}`);
305
+ const screenshots = requireArray(listing.screenshots, `listing.locales.${locale}.screenshots`).map((file, index) => requireRelativePath(file, `listing.locales.${locale}.screenshots[${index}]`));
306
+ if (screenshots.length === 0) {
307
+ throw new Error(`listing.locales.${locale}.screenshots must not be empty.`);
308
+ }
309
+ if (screenshots.length > maximumStoreDesktopScreenshots) {
310
+ throw new Error(`listing.locales.${locale}.screenshots must contain at most ${maximumStoreDesktopScreenshots} files.`);
311
+ }
312
+ const description = requireProductionString(listing.description, `listing.locales.${locale}.description`);
313
+ if (Array.from(description).length > maximumStoreDescriptionCharacters) {
314
+ throw new Error(`listing.locales.${locale}.description must not exceed ${maximumStoreDescriptionCharacters} characters.`);
315
+ }
316
+ return [locale, {
317
+ description,
318
+ screenshots,
319
+ }];
320
+ }));
321
+ }
322
+ function requireManifestUrl(input, label) {
323
+ const value = requireNonEmptyString(input, label);
324
+ const base = new URL('https://mpgd.invalid/');
325
+ const url = new URL(value, base);
326
+ if (!value.startsWith('./')
327
+ || url.origin !== base.origin
328
+ || url.pathname.startsWith('//')
329
+ || value !== `.${url.pathname}${url.search}${url.hash}`) {
330
+ throw new Error(`${label} must be an artifact-relative URL beginning with ./`);
331
+ }
332
+ return value;
333
+ }
334
+ function assertManifestStartUrlInsideScope(startUrl, scope) {
335
+ const base = new URL('https://mpgd.invalid/');
336
+ const resolvedStartUrl = new URL(startUrl, base);
337
+ const resolvedScope = new URL(scope, base);
338
+ const scopePath = resolvedScope.pathname.endsWith('/')
339
+ ? resolvedScope.pathname
340
+ : `${resolvedScope.pathname}/`;
341
+ if (resolvedStartUrl.pathname !== resolvedScope.pathname
342
+ && !resolvedStartUrl.pathname.startsWith(scopePath)) {
343
+ throw new Error('Web app manifest start_url must stay within scope.');
344
+ }
345
+ }
346
+ function readManifestAssetFile(artifactRoot, src, label) {
347
+ const url = new URL(src, 'https://mpgd.invalid/');
348
+ if (url.search.length > 0 || url.hash.length > 0) {
349
+ throw new Error(`${label}.src must identify one artifact file without a query or fragment.`);
350
+ }
351
+ let pathname;
352
+ try {
353
+ pathname = decodeURIComponent(url.pathname);
354
+ }
355
+ catch {
356
+ throw new Error(`${label}.src must use valid URL encoding.`);
357
+ }
358
+ return readCanonicalFileInside(artifactRoot, path.resolve(artifactRoot, `.${pathname}`), label);
359
+ }
360
+ function requirePublicHttpsUrl(input, label) {
361
+ const value = requireProductionString(input, label);
362
+ let url;
363
+ try {
364
+ url = new URL(value);
365
+ }
366
+ catch {
367
+ throw new Error(`${label} must be a valid public HTTPS URL.`);
368
+ }
369
+ const hostname = url.hostname.toLowerCase().replace(/\.$/u, '');
370
+ const unbracketedHostname = hostname.replace(/^\[|\]$/gu, '');
371
+ if (url.protocol !== 'https:'
372
+ || url.username.length > 0
373
+ || url.password.length > 0
374
+ || hostname === 'localhost'
375
+ || hostname.endsWith('.localhost')
376
+ || hostname.endsWith('.local')
377
+ || hostname === 'example.com'
378
+ || hostname.endsWith('.example.com')
379
+ || hostname === 'example.net'
380
+ || hostname.endsWith('.example.net')
381
+ || hostname === 'example.org'
382
+ || hostname.endsWith('.example.org')
383
+ || ['invalid', 'test', 'example'].some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
384
+ || isIP(unbracketedHostname) !== 0) {
385
+ throw new Error(`${label} must be a valid public HTTPS URL.`);
386
+ }
387
+ return url.href;
388
+ }
389
+ function optionalPublicHttpsUrl(input, label) {
390
+ return input === undefined ? undefined : requirePublicHttpsUrl(input, label);
391
+ }
392
+ function requirePublisherDistinguishedName(input, label) {
393
+ const value = requireProductionString(input, label);
394
+ const components = splitDistinguishedName(value, label);
395
+ for (const [index, component] of components.entries()) {
396
+ const separator = findDistinguishedNameEquals(component);
397
+ if (separator <= 0 || separator === component.length - 1) {
398
+ throw new Error(`${label} must be a complete X.509 distinguished name.`);
399
+ }
400
+ const attribute = component.slice(0, separator);
401
+ const attributeValue = component.slice(separator + 1);
402
+ if (/\s$/u.test(attribute) || /^\s/u.test(attributeValue)) {
403
+ throw new Error(`${label} must not contain whitespace around attribute separators.`);
404
+ }
405
+ const normalizedAttribute = attribute.toUpperCase();
406
+ if (!microsoftStorePublisherAttributes.has(normalizedAttribute)
407
+ && !microsoftStorePublisherOidAttribute.test(normalizedAttribute)) {
408
+ throw new Error(`${label} must use Microsoft Store-supported X.509 attributes.`);
409
+ }
410
+ if (attributeValue.length === 0
411
+ || /[\u0000-\u001f\u007f]/u.test(attributeValue)
412
+ || (index === 0 && normalizedAttribute !== 'CN')) {
413
+ throw new Error(`${label} must be a complete X.509 distinguished name beginning with CN=.`);
414
+ }
415
+ requireProductionString(unwrapDistinguishedNameValue(attributeValue), `${label} ${attribute} value`);
416
+ }
417
+ return value;
418
+ }
419
+ function unwrapDistinguishedNameValue(value) {
420
+ return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
421
+ }
422
+ function splitDistinguishedName(value, label) {
423
+ const components = [];
424
+ let current = '';
425
+ let escaped = false;
426
+ let quoted = false;
427
+ for (const character of value) {
428
+ if (escaped) {
429
+ current += character;
430
+ escaped = false;
431
+ }
432
+ else if (character === '\\') {
433
+ current += character;
434
+ escaped = true;
435
+ }
436
+ else if (character === '"') {
437
+ current += character;
438
+ quoted = !quoted;
439
+ }
440
+ else if (!quoted && character === '+') {
441
+ throw new Error(`${label} must not contain a multivalued RDN.`);
442
+ }
443
+ else if (!quoted && character === ',') {
444
+ components.push(current.trim());
445
+ current = '';
446
+ }
447
+ else {
448
+ current += character;
449
+ }
450
+ }
451
+ components.push(current.trim());
452
+ if (escaped || quoted || components.some((component) => component.length === 0)) {
453
+ throw new Error(`${label} must be a complete X.509 distinguished name.`);
454
+ }
455
+ return components;
456
+ }
457
+ function findDistinguishedNameEquals(component) {
458
+ let escaped = false;
459
+ let quoted = false;
460
+ for (let index = 0; index < component.length; index += 1) {
461
+ const character = component[index];
462
+ if (escaped) {
463
+ escaped = false;
464
+ }
465
+ else if (character === '\\') {
466
+ escaped = true;
467
+ }
468
+ else if (character === '"') {
469
+ quoted = !quoted;
470
+ }
471
+ else if (!quoted && character === '=') {
472
+ return index;
473
+ }
474
+ }
475
+ return -1;
476
+ }
477
+ function requireIdentityToken(input, label) {
478
+ const value = requireProductionString(input, label);
479
+ const normalized = value.toLowerCase();
480
+ if (value.length < 3 || value.length > 50 || !/^[A-Za-z0-9.-]+$/u.test(value)) {
481
+ throw new Error(`${label} must be a Windows package string of 3 to 50 letters, digits, periods, or hyphens.`);
482
+ }
483
+ if (normalized === '.'
484
+ || normalized === '..'
485
+ || reservedPackageStringPrefix.test(normalized)
486
+ || normalized.startsWith('xn--')
487
+ || normalized.endsWith('.')
488
+ || normalized.includes('.xn--')) {
489
+ throw new Error(`${label} violates Windows package string restrictions.`);
490
+ }
491
+ return value;
492
+ }
493
+ function requireProductionString(input, label) {
494
+ const value = requireNonEmptyString(input, label);
495
+ const exactTemplateToken = /^(?:TODO|FIXME|PLACEHOLDER|DUMMY|(?:CHANGE|REPLACE)[-_ ]?ME|LOREM(?:_IPSUM)?)$/iu;
496
+ const templateIdentityField = label.startsWith('productIdentity.')
497
+ || label.startsWith('web app manifest')
498
+ || label === 'ageRating.iarcId';
499
+ if (exactTemplateToken.test(value)
500
+ || (templateIdentityField
501
+ && /contoso|\b(?:change|replace)[-_ ]?me\b|^your[-_ ][A-Za-z0-9_-]+$/iu.test(value))) {
502
+ throw new Error(`${label} still contains placeholder content.`);
503
+ }
504
+ return value;
505
+ }
506
+ function optionalProductionString(input, label) {
507
+ return input === undefined ? undefined : requireProductionString(input, label);
508
+ }
509
+ function requireNonEmptyString(input, label) {
510
+ if (typeof input !== 'string' || input.length === 0 || input.trim() !== input) {
511
+ throw new Error(`${label} must be a non-empty string without surrounding whitespace.`);
512
+ }
513
+ return input;
514
+ }
515
+ function requireRelativePath(input, label) {
516
+ const value = requireNonEmptyString(input, label);
517
+ if (path.isAbsolute(value) || value.split(/[\\/]/u).includes('..')) {
518
+ throw new Error(`${label} must be a game-relative path without parent traversal.`);
519
+ }
520
+ return value;
521
+ }
522
+ function requireBoolean(input, label) {
523
+ if (typeof input !== 'boolean') {
524
+ throw new Error(`${label} must be a boolean.`);
525
+ }
526
+ return input;
527
+ }
528
+ function requireArray(input, label) {
529
+ if (!Array.isArray(input)) {
530
+ throw new Error(`${label} must be an array.`);
531
+ }
532
+ return input;
533
+ }
534
+ function requireRecord(input, label) {
535
+ if (!isRecord(input)) {
536
+ throw new Error(`${label} must be an object.`);
537
+ }
538
+ return input;
539
+ }
540
+ function isRecord(input) {
541
+ return typeof input === 'object' && input !== null && !Array.isArray(input);
542
+ }
543
+ function assertLocale(locale) {
544
+ if (!isMicrosoftStoreSupportedListingLocale(locale)) {
545
+ throw new Error(`listing locale must be supported by Microsoft Store: ${locale}`);
546
+ }
547
+ }
548
+ function readMicrosoftStoreScreenshot(file) {
549
+ return readMicrosoftStorePng(file, {
550
+ label: 'Microsoft Store screenshot',
551
+ maximumBytes: maximumStoreScreenshotBytes,
552
+ maximumDecodedBytes: maximumDecodedScreenshotBytes,
553
+ validateDimensions: (width, height) => {
554
+ if (width === height
555
+ || Math.max(width, height) < minimumStoreScreenshotLongEdge
556
+ || Math.min(width, height) < minimumStoreScreenshotShortEdge) {
557
+ throw new Error(`Microsoft Store desktop screenshot must be landscape or portrait and at least ${minimumStoreScreenshotLongEdge} x ${minimumStoreScreenshotShortEdge}: ${file}`);
558
+ }
559
+ },
560
+ });
561
+ }
562
+ function readMicrosoftStorePng(file, input) {
563
+ if (path.extname(file).toLowerCase() !== '.png') {
564
+ throw new Error(`${input.label} must be PNG: ${file}`);
565
+ }
566
+ const descriptor = openSync(file, 'r');
567
+ try {
568
+ const before = fstatSync(descriptor);
569
+ if (before.size > input.maximumBytes) {
570
+ throw new Error(`${input.label} exceeds its maximum file size: ${file}`);
571
+ }
572
+ const header = Buffer.alloc(29);
573
+ if (readSync(descriptor, header, 0, header.length, 0) !== header.length) {
574
+ throw new Error(`${input.label} must be a valid PNG: ${file}`);
575
+ }
576
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
577
+ if (!header.subarray(0, 8).equals(signature)
578
+ || header.readUInt32BE(8) !== 13
579
+ || header.toString('ascii', 12, 16) !== 'IHDR') {
580
+ throw new Error(`${input.label} must be a valid PNG: ${file}`);
581
+ }
582
+ const width = header.readUInt32BE(16);
583
+ const height = header.readUInt32BE(20);
584
+ const bitDepth = header[24] ?? 0;
585
+ const colorType = header[25] ?? 0;
586
+ const compressionMethod = header[26] ?? 0;
587
+ const filterMethod = header[27] ?? 0;
588
+ const interlaceMethod = header[28] ?? 0;
589
+ input.validateDimensions(width, height);
590
+ assertDecodedPng({
591
+ descriptor,
592
+ size: before.size,
593
+ file,
594
+ width,
595
+ height,
596
+ bitDepth,
597
+ colorType,
598
+ compressionMethod,
599
+ filterMethod,
600
+ interlaceMethod,
601
+ label: input.label,
602
+ maximumDecodedBytes: input.maximumDecodedBytes,
603
+ });
604
+ const sha256 = hashOpenFile(descriptor);
605
+ const after = fstatSync(descriptor);
606
+ if (before.size !== after.size
607
+ || before.mtimeMs !== after.mtimeMs
608
+ || before.ctimeMs !== after.ctimeMs) {
609
+ throw new Error(`${input.label} changed while it was being validated: ${file}`);
610
+ }
611
+ return { width, height, sha256 };
612
+ }
613
+ finally {
614
+ closeSync(descriptor);
615
+ }
616
+ }
617
+ function assertDecodedPng(input) {
618
+ const channels = pngColorChannels(input.colorType);
619
+ const supportedBitDepths = pngSupportedBitDepths(input.colorType);
620
+ if (input.width === 0
621
+ || input.height === 0
622
+ || !supportedBitDepths.includes(input.bitDepth)
623
+ || input.compressionMethod !== 0
624
+ || input.filterMethod !== 0
625
+ || (input.interlaceMethod !== 0 && input.interlaceMethod !== 1)) {
626
+ throw new Error(`${input.label} must be a valid PNG: ${input.file}`);
627
+ }
628
+ const passes = calculatePngPasses(input.width, input.height, channels * input.bitDepth, input.interlaceMethod, input.file, input.label);
629
+ let offset = 8;
630
+ let chunkIndex = 0;
631
+ let foundImageData = false;
632
+ let foundEnd = false;
633
+ let imageDataEnded = false;
634
+ let foundPalette = false;
635
+ const imageData = [];
636
+ const chunkHeader = Buffer.alloc(8);
637
+ const knownCriticalChunks = new Set(['IHDR', 'PLTE', 'IDAT', 'IEND']);
638
+ while (offset + 12 <= input.size) {
639
+ if (readSync(input.descriptor, chunkHeader, 0, chunkHeader.length, offset)
640
+ !== chunkHeader.length) {
641
+ break;
642
+ }
643
+ const dataLength = chunkHeader.readUInt32BE(0);
644
+ const type = chunkHeader.toString('ascii', 4, 8);
645
+ const critical = ((chunkHeader[4] ?? 0) & 0x20) === 0;
646
+ const nextOffset = offset + 12 + dataLength;
647
+ if (nextOffset > input.size
648
+ || !/^[A-Za-z]{4}$/u.test(type)
649
+ || (critical && !knownCriticalChunks.has(type))
650
+ || (chunkIndex === 0 && (type !== 'IHDR' || dataLength !== 13))
651
+ || (chunkIndex > 0 && type === 'IHDR')) {
652
+ break;
653
+ }
654
+ const data = Buffer.alloc(dataLength);
655
+ const storedCrc = Buffer.alloc(4);
656
+ if (readSync(input.descriptor, data, 0, data.length, offset + 8) !== data.length
657
+ || readSync(input.descriptor, storedCrc, 0, 4, offset + 8 + dataLength) !== 4
658
+ || storedCrc.readUInt32BE(0) !== crc32Png([chunkHeader.subarray(4, 8), data])) {
659
+ break;
660
+ }
661
+ if (type === 'PLTE') {
662
+ const paletteEntries = dataLength / 3;
663
+ if (foundPalette
664
+ || foundImageData
665
+ || dataLength === 0
666
+ || dataLength % 3 !== 0
667
+ || dataLength > 768
668
+ || input.colorType === 0
669
+ || input.colorType === 4
670
+ || (input.colorType === 3 && paletteEntries > 2 ** input.bitDepth)) {
671
+ break;
672
+ }
673
+ foundPalette = true;
674
+ }
675
+ if (type === 'IDAT') {
676
+ if (imageDataEnded) {
677
+ break;
678
+ }
679
+ foundImageData = true;
680
+ imageData.push(data);
681
+ }
682
+ else if (foundImageData && type !== 'IEND') {
683
+ imageDataEnded = true;
684
+ }
685
+ if (type === 'IEND') {
686
+ foundEnd = dataLength === 0 && nextOffset === input.size;
687
+ break;
688
+ }
689
+ offset = nextOffset;
690
+ chunkIndex += 1;
691
+ }
692
+ if (!foundImageData || !foundEnd || (input.colorType === 3 && !foundPalette)) {
693
+ throw new Error(`${input.label} must be a valid PNG: ${input.file}`);
694
+ }
695
+ const expectedBytes = passes.reduce((total, pass) => total + (pass.rowBytes + 1) * pass.rowCount, 0);
696
+ if (!Number.isSafeInteger(expectedBytes) || expectedBytes > input.maximumDecodedBytes) {
697
+ throw new Error(`${input.label} decoded pixel data is too large: ${input.file}`);
698
+ }
699
+ const compressedImageData = Buffer.concat(imageData);
700
+ let decoded;
701
+ try {
702
+ const result = inflateSync(compressedImageData, {
703
+ info: true,
704
+ maxOutputLength: expectedBytes,
705
+ });
706
+ if (result.engine.bytesWritten !== compressedImageData.length) {
707
+ throw new Error('trailing compressed bytes');
708
+ }
709
+ decoded = result.buffer;
710
+ }
711
+ catch {
712
+ throw new Error(`${input.label} must be a valid PNG: ${input.file}`);
713
+ }
714
+ if (decoded.length !== expectedBytes) {
715
+ throw new Error(`${input.label} must be a valid PNG: ${input.file}`);
716
+ }
717
+ let decodedOffset = 0;
718
+ for (const pass of passes) {
719
+ for (let row = 0; row < pass.rowCount; row += 1) {
720
+ if ((decoded[decodedOffset] ?? 5) > 4) {
721
+ throw new Error(`${input.label} must be a valid PNG: ${input.file}`);
722
+ }
723
+ decodedOffset += pass.rowBytes + 1;
724
+ }
725
+ }
726
+ }
727
+ function pngColorChannels(colorType) {
728
+ const channels = new Map([
729
+ [0, 1],
730
+ [2, 3],
731
+ [3, 1],
732
+ [4, 2],
733
+ [6, 4],
734
+ ]).get(colorType);
735
+ if (channels === undefined) {
736
+ return 0;
737
+ }
738
+ return channels;
739
+ }
740
+ function pngSupportedBitDepths(colorType) {
741
+ switch (colorType) {
742
+ case 0:
743
+ return [1, 2, 4, 8, 16];
744
+ case 2:
745
+ case 4:
746
+ case 6:
747
+ return [8, 16];
748
+ case 3:
749
+ return [1, 2, 4, 8];
750
+ default:
751
+ return [];
752
+ }
753
+ }
754
+ function calculatePngPasses(width, height, bitsPerPixel, interlaceMethod, file, label) {
755
+ const patterns = interlaceMethod === 0
756
+ ? [[0, 0, 1, 1]]
757
+ : [
758
+ [0, 0, 8, 8],
759
+ [4, 0, 8, 8],
760
+ [0, 4, 4, 8],
761
+ [2, 0, 4, 4],
762
+ [0, 2, 2, 4],
763
+ [1, 0, 2, 2],
764
+ [0, 1, 1, 2],
765
+ ];
766
+ return patterns.flatMap(([xStart, yStart, xStep, yStep]) => {
767
+ const passWidth = pngPassLength(width, xStart, xStep);
768
+ const rowCount = pngPassLength(height, yStart, yStep);
769
+ if (passWidth === 0 || rowCount === 0) {
770
+ return [];
771
+ }
772
+ const rowBytes = Math.ceil((passWidth * bitsPerPixel) / 8);
773
+ if (!Number.isSafeInteger(rowBytes) || !Number.isSafeInteger((rowBytes + 1) * rowCount)) {
774
+ throw new Error(`${label} decoded pixel data is too large: ${file}`);
775
+ }
776
+ return [{ rowBytes, rowCount }];
777
+ });
778
+ }
779
+ function pngPassLength(size, start, step) {
780
+ return size <= start ? 0 : Math.ceil((size - start) / step);
781
+ }
782
+ function crc32Png(parts) {
783
+ let crc = 4294967295;
784
+ for (const part of parts) {
785
+ for (const byte of part) {
786
+ crc = (crc >>> 8) ^ (pngCrc32Table[(crc ^ byte) & 0xff] ?? 0);
787
+ }
788
+ }
789
+ return (crc ^ 4294967295) >>> 0;
790
+ }
791
+ function createPngCrc32Table() {
792
+ const table = new Uint32Array(256);
793
+ for (let value = 0; value < table.length; value += 1) {
794
+ let crc = value;
795
+ for (let bit = 0; bit < 8; bit += 1) {
796
+ crc = (crc >>> 1) ^ (crc & 1 ? 3988292384 : 0);
797
+ }
798
+ table[value] = crc >>> 0;
799
+ }
800
+ return table;
801
+ }
802
+ function readJson(file, label) {
803
+ try {
804
+ return JSON.parse(readFileSync(file, 'utf8'));
805
+ }
806
+ catch (error) {
807
+ throw new Error(`Failed to read ${label} ${file}: ${formatError(error)}`);
808
+ }
809
+ }
810
+ function readJsonSnapshot(file, label, maximumBytes) {
811
+ let descriptor;
812
+ try {
813
+ descriptor = openSync(file, 'r');
814
+ const before = fstatSync(descriptor);
815
+ if (before.size > maximumBytes) {
816
+ throw new Error(`exceeds ${maximumBytes} bytes`);
817
+ }
818
+ const bytes = Buffer.alloc(before.size);
819
+ let offset = 0;
820
+ while (offset < bytes.length) {
821
+ const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, null);
822
+ if (bytesRead === 0) {
823
+ throw new Error('changed while it was being read');
824
+ }
825
+ offset += bytesRead;
826
+ }
827
+ const after = fstatSync(descriptor);
828
+ if (before.size !== after.size
829
+ || before.mtimeMs !== after.mtimeMs
830
+ || before.ctimeMs !== after.ctimeMs) {
831
+ throw new Error('changed while it was being read');
832
+ }
833
+ return { bytes, value: JSON.parse(bytes.toString('utf8')) };
834
+ }
835
+ catch (error) {
836
+ throw new Error(`Failed to read ${label} ${file}: ${formatError(error)}`);
837
+ }
838
+ finally {
839
+ if (descriptor !== undefined) {
840
+ closeSync(descriptor);
841
+ }
842
+ }
843
+ }
844
+ function hashOpenFile(descriptor) {
845
+ const hash = createHash('sha256');
846
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
847
+ while (true) {
848
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
849
+ if (bytesRead === 0) {
850
+ return hash.digest('hex');
851
+ }
852
+ hash.update(buffer.subarray(0, bytesRead));
853
+ }
854
+ }
855
+ function hashBytes(bytes) {
856
+ return createHash('sha256').update(bytes).digest('hex');
857
+ }
858
+ function readCanonicalDirectory(input, label) {
859
+ let canonical;
860
+ try {
861
+ canonical = realpathSync(input);
862
+ }
863
+ catch (error) {
864
+ throw new Error(`${label} must exist: ${input} (${formatError(error)})`);
865
+ }
866
+ if (!lstatSync(canonical).isDirectory()) {
867
+ throw new Error(`${label} must be a directory: ${canonical}`);
868
+ }
869
+ return canonical;
870
+ }
871
+ function readCanonicalDirectoryInside(root, input, label) {
872
+ const canonical = readCanonicalDirectory(input, label);
873
+ assertInside(root, canonical, label);
874
+ return canonical;
875
+ }
876
+ function readCanonicalFileInside(root, input, label) {
877
+ let canonical;
878
+ try {
879
+ canonical = realpathSync(input);
880
+ }
881
+ catch (error) {
882
+ throw new Error(`${label} must exist: ${input} (${formatError(error)})`);
883
+ }
884
+ assertInside(root, canonical, label);
885
+ if (!lstatSync(canonical).isFile()) {
886
+ throw new Error(`${label} must be a regular file: ${canonical}`);
887
+ }
888
+ return canonical;
889
+ }
890
+ function resolveOutputFileInside(root, file, label) {
891
+ const parent = readCanonicalDirectory(path.dirname(file), `${label} directory`);
892
+ assertInside(root, parent, label);
893
+ let metadata;
894
+ try {
895
+ metadata = lstatSync(file);
896
+ }
897
+ catch (error) {
898
+ if (!isRecord(error) || error.code !== 'ENOENT') {
899
+ throw error;
900
+ }
901
+ }
902
+ if (metadata !== undefined) {
903
+ if (metadata.isSymbolicLink()) {
904
+ throw new Error(`${label} must not be a symbolic link: ${file}`);
905
+ }
906
+ if (!metadata.isFile()) {
907
+ throw new Error(`${label} must be a regular file when it already exists: ${file}`);
908
+ }
909
+ }
910
+ let resolved = path.join(parent, path.basename(file));
911
+ if (metadata !== undefined) {
912
+ try {
913
+ resolved = realpathSync(file);
914
+ }
915
+ catch (error) {
916
+ if (!isRecord(error) || error.code !== 'ENOENT') {
917
+ throw error;
918
+ }
919
+ }
920
+ }
921
+ assertInside(root, resolved, label);
922
+ return resolved;
923
+ }
924
+ function assertDistinctEvidenceFiles(outputs, protectedFiles) {
925
+ for (const [index, output] of outputs.entries()) {
926
+ for (const candidate of [...outputs.slice(index + 1), ...protectedFiles]) {
927
+ if (sameFile(output.file, candidate.file)) {
928
+ throw new Error(`${output.label} must not alias ${candidate.label}: ${output.file}`);
929
+ }
930
+ }
931
+ }
932
+ }
933
+ function sameFile(left, right) {
934
+ if (path.relative(left, right).length === 0) {
935
+ return true;
936
+ }
937
+ if (!existsSync(left) || !existsSync(right)) {
938
+ return false;
939
+ }
940
+ const leftMetadata = statSync(left);
941
+ const rightMetadata = statSync(right);
942
+ return leftMetadata.dev === rightMetadata.dev && leftMetadata.ino === rightMetadata.ino;
943
+ }
944
+ function assertInside(root, candidate, label) {
945
+ const relative = path.relative(root, candidate);
946
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
947
+ throw new Error(`${label} must stay inside the game root.`);
948
+ }
949
+ }
950
+ function compareCodeUnits(left, right) {
951
+ return left < right ? -1 : left > right ? 1 : 0;
952
+ }