-
Notifications
You must be signed in to change notification settings - Fork 654
Expand file tree
/
Copy pathmanifest.ts
More file actions
461 lines (403 loc) · 13 KB
/
Copy pathmanifest.ts
File metadata and controls
461 lines (403 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
import { getErrorMessage } from '@metamask/snaps-sdk';
import type { Json } from '@metamask/utils';
import { assert, isPlainObject } from '@metamask/utils';
import { promises as fs } from 'fs';
import pathUtils from 'path';
import type { SnapManifest } from './validation';
import type { ValidatorResults } from './validator';
import { isReportFixable, hasFixes, runValidators } from './validator';
import type { ValidatorMeta, ValidatorReport } from './validator-types';
import * as defaultValidators from './validators';
import { deepClone } from '../deep-clone';
import { readJsonFile } from '../fs';
import { parseJson } from '../json';
import type { SnapFiles, UnvalidatedSnapFiles } from '../types';
import { NpmSnapFileNames } from '../types';
import { readVirtualFile, VirtualFile } from '../virtual-file/node';
const MANIFEST_SORT_ORDER: Record<keyof SnapManifest, number> = {
$schema: 1,
version: 2,
description: 3,
proposedName: 4,
repository: 5,
source: 6,
initialConnections: 7,
initialPermissions: 8,
platformVersion: 9,
manifestVersion: 10,
};
export type CheckManifestReport = Omit<ValidatorReport, 'fix'> & {
wasFixed?: boolean;
};
/**
* The options for the `checkManifest` function.
*/
export type CheckManifestOptions = {
/**
* Whether to auto-magically try to fix errors and then write the manifest to
* disk.
*/
updateAndWriteManifest?: boolean;
/**
* The source code of the Snap.
*/
sourceCode?: string;
/**
* The function to use to write the manifest to disk.
*/
writeFileFn?: WriteFileFunction;
/**
* The exports detected by evaluating the bundle. This may be used by one or
* more validators to determine whether the Snap is valid.
*/
exports?: string[];
/**
* An object containing the names of the handlers and their respective
* permission name. This must be provided to avoid circular dependencies
* between `@metamask/snaps-utils` and `@metamask/snaps-rpc-methods`.
*/
handlerEndowments?: Record<string, string | null>;
/**
* Whether the compiler is running in watch mode. This is used to determine
* whether to fix warnings or errors only.
*/
watchMode?: boolean;
};
/**
* The result from the `checkManifest` function.
*
* @property manifest - The fixed manifest object.
* @property updated - Whether the manifest was written and updated.
*/
export type CheckManifestResult = {
files?: SnapFiles;
updated: boolean;
reports: CheckManifestReport[];
};
export type WriteFileFunction = (path: string, data: string) => Promise<void>;
/**
* Validates a snap.manifest.json file. Attempts to fix the manifest and write
* the fixed version to disk if `writeManifest` is true. Throws if validation
* fails.
*
* @param basePath - The path to the folder with the manifest files.
* @param options - Additional options for the function.
* @param options.sourceCode - The source code of the Snap.
* @param options.writeFileFn - The function to use to write the manifest to
* disk.
* @param options.updateAndWriteManifest - Whether to auto-magically try to fix
* errors and then write the manifest to disk.
* @param options.exports - The exports detected by evaluating the bundle. This
* may be used by one or more validators to determine whether the Snap is valid.
* @param options.handlerEndowments - An object containing the names of the
* handlers and their respective permission name. This must be provided to avoid
* circular dependencies between `@metamask/snaps-utils` and
* `@metamask/snaps-rpc-methods`.
* @param options.watchMode - Whether the compiler is running in watch mode.
* This is used to determine whether to fix warnings or errors only.
* @returns Whether the manifest was updated, and an array of warnings that
* were encountered during processing of the manifest files.
*/
export async function checkManifest(
basePath: string,
{
updateAndWriteManifest = true,
sourceCode,
writeFileFn = fs.writeFile,
exports,
handlerEndowments,
watchMode = false,
}: CheckManifestOptions = {},
): Promise<CheckManifestResult> {
const manifestPath = pathUtils.join(basePath, NpmSnapFileNames.Manifest);
const manifestFile = await readJsonFile(manifestPath);
const unvalidatedManifest = manifestFile.result;
const packageFile = await readJsonFile(
pathUtils.join(basePath, NpmSnapFileNames.PackageJson),
);
const auxiliaryFilePaths = getSnapFilePaths(
unvalidatedManifest,
(manifest) => manifest?.source?.files,
);
const localizationFilePaths = getSnapFilePaths(
unvalidatedManifest,
(manifest) => manifest?.source?.locales,
);
const localizationFiles =
(await getSnapFiles(basePath, localizationFilePaths)) ?? [];
for (const localization of localizationFiles) {
try {
localization.result = parseJson(localization.toString());
} catch (error) {
assert(error instanceof SyntaxError, error);
throw new Error(
`Failed to parse localization file "${localization.path}" as JSON.`,
);
}
}
const snapFiles: UnvalidatedSnapFiles = {
manifest: manifestFile,
packageJson: packageFile,
sourceCode: await getSnapSourceCode(
basePath,
unvalidatedManifest,
sourceCode,
),
svgIcon: await getSnapIcon(basePath, unvalidatedManifest),
// Intentionally pass null as the encoding here since the files may be binary
auxiliaryFiles:
(await getSnapFiles(basePath, auxiliaryFilePaths, null)) ?? [],
localizationFiles,
};
const validatorResults = await runValidators(
snapFiles,
Object.values(defaultValidators),
{ exports, handlerEndowments },
);
let manifestResults: CheckManifestResult = {
updated: false,
files: validatorResults.files,
reports: validatorResults.reports,
};
if (updateAndWriteManifest && hasFixes(manifestResults, watchMode)) {
const fixedResults = await runFixes(validatorResults, undefined, watchMode);
if (fixedResults.updated) {
manifestResults = fixedResults;
assert(manifestResults.files);
try {
await writeFileFn(
pathUtils.join(basePath, NpmSnapFileNames.Manifest),
manifestResults.files.manifest.toString(),
);
} catch (error) {
// Note: This error isn't pushed to the errors array, because it's not an
// error in the manifest itself.
throw new Error(
`Failed to update "snap.manifest.json": ${getErrorMessage(error)}`,
);
}
}
}
return manifestResults;
}
/**
* Run the algorithm for automatically fixing errors in manifest.
*
* The algorithm updates the manifest by fixing all fixable problems,
* and then run validation again to check if the new manifest is now correct.
* If not correct, the algorithm will use the manifest from previous iteration
* and try again `MAX_ATTEMPTS` times to update it before bailing and
* resulting in failure.
*
* @param results - Results of the initial run of validation.
* @param rules - Optional list of rules to run the fixes with.
* @param errorsOnly - Whether to only run fixes for errors, not warnings.
* @returns The updated manifest and whether it was updated.
*/
export async function runFixes(
results: ValidatorResults,
rules?: ValidatorMeta[],
errorsOnly = false,
): Promise<CheckManifestResult> {
let shouldRunFixes = true;
const MAX_ATTEMPTS = 10;
assert(results.files);
let fixResults: ValidatorResults = results;
assert(fixResults.files);
fixResults.files.manifest = fixResults.files.manifest.clone();
const mergedReports: ValidatorReport[] = deepClone(fixResults.reports);
for (
let attempts = 1;
shouldRunFixes && attempts <= MAX_ATTEMPTS;
attempts++
) {
assert(fixResults.files);
let manifest = fixResults.files.manifest.result;
const fixable = fixResults.reports.filter((report) =>
isReportFixable(report, errorsOnly),
);
for (const report of fixable) {
assert(report.fix);
({ manifest } = await report.fix({ manifest }));
}
fixResults.files.manifest.value = `${JSON.stringify(
getWritableManifest(manifest),
null,
2,
)}\n`;
fixResults.files.manifest.result = manifest;
fixResults = await runValidators(fixResults.files, rules);
shouldRunFixes = hasFixes(fixResults, errorsOnly);
mergedReports.push(
...fixResults.reports.filter(
(report) =>
!mergedReports.find((mergedReport) => mergedReport.id === report.id),
),
);
}
const allReports: (CheckManifestReport & ValidatorReport)[] =
deepClone(mergedReports);
// Was fixed
if (!shouldRunFixes) {
for (const report of allReports) {
if (report.fix) {
report.wasFixed = true;
delete report.fix;
}
}
return {
files: fixResults.files,
updated: true,
reports: allReports,
};
}
for (const report of allReports) {
delete report.fix;
}
return {
files: results.files,
updated: false,
reports: allReports,
};
}
/**
* Given an unvalidated Snap manifest, attempts to extract the location of the
* bundle source file location and read the file.
*
* @param basePath - The path to the folder with the manifest files.
* @param manifest - The unvalidated Snap manifest file contents.
* @param sourceCode - Override source code for plugins.
* @returns The contents of the bundle file, if any.
*/
export async function getSnapSourceCode(
basePath: string,
manifest: Json,
sourceCode?: string,
): Promise<VirtualFile | undefined> {
if (!isPlainObject(manifest)) {
return undefined;
}
const sourceFilePath = (manifest as Partial<SnapManifest>).source?.location
?.npm?.filePath;
if (!sourceFilePath) {
return undefined;
}
if (sourceCode) {
return new VirtualFile({
path: pathUtils.join(basePath, sourceFilePath),
value: sourceCode,
});
}
try {
const virtualFile = await readVirtualFile(
pathUtils.join(basePath, sourceFilePath),
'utf8',
);
return virtualFile;
} catch (error) {
throw new Error(
`Failed to read snap bundle file: ${getErrorMessage(error)}`,
);
}
}
/**
* Given an unvalidated Snap manifest, attempts to extract the location of the
* icon and read the file.
*
* @param basePath - The path to the folder with the manifest files.
* @param manifest - The unvalidated Snap manifest file contents.
* @returns The contents of the icon, if any.
*/
export async function getSnapIcon(
basePath: string,
manifest: Json,
): Promise<VirtualFile | undefined> {
if (!isPlainObject(manifest)) {
return undefined;
}
const iconPath = (manifest as Partial<SnapManifest>).source?.location?.npm
?.iconPath;
if (!iconPath) {
return undefined;
}
try {
const virtualFile = await readVirtualFile(
pathUtils.join(basePath, iconPath),
'utf8',
);
return virtualFile;
} catch (error) {
throw new Error(`Failed to read snap icon file: ${getErrorMessage(error)}`);
}
}
/**
* Get an array of paths from an unvalidated Snap manifest.
*
* @param manifest - The unvalidated Snap manifest file contents.
* @param selector - A function that returns the paths to the files.
* @returns The paths to the files, if any.
*/
export function getSnapFilePaths(
manifest: Json,
selector: (manifest: Partial<SnapManifest>) => string[] | undefined,
) {
if (!isPlainObject(manifest)) {
return undefined;
}
const snapManifest = manifest as Partial<SnapManifest>;
const paths = selector(snapManifest);
if (!Array.isArray(paths)) {
return undefined;
}
return paths;
}
/**
* Given an unvalidated Snap manifest, attempts to extract the files with the
* given paths and read them.
*
* @param basePath - The path to the folder with the manifest files.
* @param paths - The paths to the files.
* @param encoding - An optional encoding to pass down to readVirtualFile.
* @returns A list of auxiliary files and their contents, if any.
*/
export async function getSnapFiles(
basePath: string,
paths: string[] | undefined,
encoding: BufferEncoding | null = 'utf8',
): Promise<VirtualFile[] | undefined> {
if (!paths) {
return undefined;
}
try {
return await Promise.all(
paths.map(async (filePath) =>
readVirtualFile(pathUtils.join(basePath, filePath), encoding),
),
);
} catch (error) {
throw new Error(`Failed to read snap files: ${getErrorMessage(error)}`);
}
}
/**
* Sorts the given manifest in our preferred sort order and removes the
* `repository` field if it is falsy (it may be `null`).
*
* @param manifest - The manifest to sort and modify.
* @returns The disk-ready manifest.
*/
export function getWritableManifest(manifest: SnapManifest): SnapManifest {
const { repository, ...remaining } = manifest;
const keys = Object.keys(
repository ? { ...remaining, repository } : remaining,
) as (keyof SnapManifest)[];
const writableManifest = keys
.sort((a, b) => MANIFEST_SORT_ORDER[a] - MANIFEST_SORT_ORDER[b])
.reduce<Partial<SnapManifest>>(
(result, key) => ({
...result,
[key]: manifest[key],
}),
{},
);
return writableManifest as SnapManifest;
}