@metamask/snaps-utils
Version:
A collection of utilities for MetaMask Snaps
255 lines • 9.89 kB
JavaScript
import { getErrorMessage } from "@metamask/snaps-sdk";
import { assert, isPlainObject } from "@metamask/utils";
import { promises as fs } from "fs";
import pathUtils from "path";
import { hasFixes, runValidators } from "./validator.mjs";
import { deepClone } from "../deep-clone.mjs";
import { readJsonFile } from "../fs.mjs";
import { parseJson } from "../json.mjs";
import { NpmSnapFileNames } from "../types.mjs";
import { readVirtualFile, VirtualFile } from "../virtual-file/node.mjs";
const MANIFEST_SORT_ORDER = {
$schema: 1,
version: 2,
description: 3,
proposedName: 4,
repository: 5,
source: 6,
initialConnections: 7,
initialPermissions: 8,
platformVersion: 9,
manifestVersion: 10,
};
/**
* 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.
* @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, { updateAndWriteManifest = true, sourceCode, writeFileFn = fs.writeFile, } = {}) {
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 = {
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);
let manifestResults = {
updated: false,
files: validatorResults.files,
reports: validatorResults.reports,
};
if (updateAndWriteManifest && hasFixes(manifestResults)) {
const fixedResults = await runFixes(validatorResults);
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.
* @returns The updated manifest and whether it was updated.
*/
export async function runFixes(results, rules) {
let shouldRunFixes = true;
const MAX_ATTEMPTS = 10;
assert(results.files);
let fixResults = results;
assert(fixResults.files);
fixResults.files.manifest = fixResults.files.manifest.clone();
for (let attempts = 1; shouldRunFixes && attempts <= MAX_ATTEMPTS; attempts++) {
assert(fixResults.files);
let manifest = fixResults.files.manifest.result;
const fixable = fixResults.reports.filter((report) => report.fix);
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);
}
const initialReports = deepClone(results.reports);
// Was fixed
if (!shouldRunFixes) {
for (const report of initialReports) {
if (report.fix) {
report.wasFixed = true;
delete report.fix;
}
}
return {
files: fixResults.files,
updated: true,
reports: initialReports,
};
}
for (const report of initialReports) {
delete report.fix;
}
return {
files: results.files,
updated: false,
reports: initialReports,
};
}
/**
* 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, manifest, sourceCode) {
if (!isPlainObject(manifest)) {
return undefined;
}
const sourceFilePath = manifest.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, manifest) {
if (!isPlainObject(manifest)) {
return undefined;
}
const iconPath = manifest.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, selector) {
if (!isPlainObject(manifest)) {
return undefined;
}
const snapManifest = manifest;
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, paths, encoding = 'utf8') {
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) {
const { repository, ...remaining } = manifest;
const keys = Object.keys(repository ? { ...remaining, repository } : remaining);
const writableManifest = keys
.sort((a, b) => MANIFEST_SORT_ORDER[a] - MANIFEST_SORT_ORDER[b])
.reduce((result, key) => ({
...result,
[key]: manifest[key],
}), {});
return writableManifest;
}
//# sourceMappingURL=manifest.mjs.map