From bbc33e9f1dee375abef6bec0ac833bf90b76c35d Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 22 Oct 2024 16:04:48 +0200 Subject: [PATCH] Add platform version field to manifest (#2803) This adds a new field to the manifest, `platformVersion`. It is intended to be set to the current version of the Snaps SDK, and if set, is checked by the Snap controller to make sure the current supported version by the Snaps Platform is equal or newer than the specified `platformVersion`. I've added a feature flag `rejectInvalidPlatformVersion` which, when enabled, will throw an error upon installation if specified platform version is too new. Otherwise it simply logs a warning in the console. --- .../src/commands/build/implementation.test.ts | 13 ++- .../commands/manifest/implementation.test.ts | 26 +++-- packages/snaps-controllers/coverage.json | 6 +- packages/snaps-controllers/package.json | 2 + .../src/snaps/SnapController.test.tsx | 104 ++++++++++++++++++ .../src/snaps/SnapController.ts | 45 +++++++- .../src/snaps/registry/json.test.ts | 2 +- .../src/test-utils/controller.ts | 5 +- packages/snaps-utils/coverage.json | 4 +- packages/snaps-utils/src/index.ts | 1 + .../snaps-utils/src/manifest/manifest.test.ts | 67 ++++++++--- packages/snaps-utils/src/manifest/manifest.ts | 3 +- .../snaps-utils/src/manifest/validation.ts | 1 + .../src/manifest/validators/index.ts | 2 + .../validators/platform-version.test.ts | 87 +++++++++++++++ .../manifest/validators/platform-version.ts | 44 ++++++++ .../snaps-utils/src/platform-version.test.ts | 10 ++ packages/snaps-utils/src/platform-version.ts | 13 +++ .../snaps-utils/src/test-utils/manifest.ts | 11 +- .../snaps-webpack-plugin/src/manifest.test.ts | 9 +- yarn.lock | 2 + 21 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 packages/snaps-utils/src/manifest/validators/platform-version.test.ts create mode 100644 packages/snaps-utils/src/manifest/validators/platform-version.ts create mode 100644 packages/snaps-utils/src/platform-version.test.ts create mode 100644 packages/snaps-utils/src/platform-version.ts diff --git a/packages/snaps-cli/src/commands/build/implementation.test.ts b/packages/snaps-cli/src/commands/build/implementation.test.ts index 40e6246cc8..79ff01c15a 100644 --- a/packages/snaps-cli/src/commands/build/implementation.test.ts +++ b/packages/snaps-cli/src/commands/build/implementation.test.ts @@ -1,5 +1,8 @@ +import { getPlatformVersion } from '@metamask/snaps-utils'; import { DEFAULT_SNAP_BUNDLE, + DEFAULT_SNAP_ICON, + getMockSnapFilesWithUpdatedChecksum, getPackageJson, getSnapManifest, } from '@metamask/snaps-utils/test-utils'; @@ -61,15 +64,21 @@ jest.mock('../../webpack/utils', () => ({ describe('build', () => { beforeEach(async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + platformVersion: getPlatformVersion(), + }), + }); + await fs.mkdir('/snap'); await fs.writeFile('/snap/input.js', DEFAULT_SNAP_BUNDLE); await fs.writeFile( '/snap/snap.manifest.json', - JSON.stringify(getSnapManifest()), + JSON.stringify(manifest.result), ); await fs.writeFile('/snap/package.json', JSON.stringify(getPackageJson())); await fs.mkdir('/snap/images'); - await fs.writeFile('/snap/images/icon.svg', ''); + await fs.writeFile('/snap/images/icon.svg', DEFAULT_SNAP_ICON); await fs.mkdir(dirname(BROWSERSLIST_FILE), { recursive: true }); await fs.writeFile( BROWSERSLIST_FILE, diff --git a/packages/snaps-cli/src/commands/manifest/implementation.test.ts b/packages/snaps-cli/src/commands/manifest/implementation.test.ts index 792e100dc1..8ad1dcf7cb 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.test.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.test.ts @@ -1,5 +1,8 @@ +import { getPlatformVersion } from '@metamask/snaps-utils'; import { DEFAULT_SNAP_BUNDLE, + DEFAULT_SNAP_ICON, + getMockSnapFilesWithUpdatedChecksum, getPackageJson, getSnapManifest, } from '@metamask/snaps-utils/test-utils'; @@ -32,19 +35,23 @@ jest.mock('../../webpack', () => ({ describe('manifest', () => { beforeEach(async () => { + const { manifest: newManifest } = await getMockSnapFilesWithUpdatedChecksum( + { + manifest: getSnapManifest({ + platformVersion: getPlatformVersion(), + }), + }, + ); + await fs.mkdir('/snap/dist', { recursive: true }); await fs.writeFile('/snap/dist/bundle.js', DEFAULT_SNAP_BUNDLE); await fs.writeFile( '/snap/snap.manifest.json', - JSON.stringify( - getSnapManifest({ - shasum: 'G/W5b2JZVv+epgNX9pkN63X6Lye9EJVJ4NLSgAw/afc=', - }), - ), + JSON.stringify(newManifest.result), ); await fs.writeFile('/snap/package.json', JSON.stringify(getPackageJson())); await fs.mkdir('/snap/images'); - await fs.writeFile('/snap/images/icon.svg', ''); + await fs.writeFile('/snap/images/icon.svg', DEFAULT_SNAP_ICON); }); afterEach(async () => { @@ -52,9 +59,9 @@ describe('manifest', () => { }); it('validates a snap manifest', async () => { - const error = jest.spyOn(console, 'error').mockImplementation(); + const error = jest.spyOn(console, 'error'); const warn = jest.spyOn(console, 'warn').mockImplementation(); - const log = jest.spyOn(console, 'log').mockImplementation(); + const log = jest.spyOn(console, 'log'); const spinner = ora(); const result = await manifest('/snap/snap.manifest.json', false, spinner); @@ -157,7 +164,7 @@ describe('manifest', () => { "url": "https://github.com/MetaMask/example-snap.git" }, "source": { - "shasum": "d4W7f1lzpVGMj8jjCn1lYhhHmKc/9TSk5QLH5ldKQoI=", + "shasum": "itjh0enng42nO6BxNCEhDH8wm3yl4xlVclfd5LsZ2wA=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -172,6 +179,7 @@ describe('manifest', () => { "chains": ["eip155:1", "eip155:2", "eip155:3"] } }, + "platformVersion": "1.0.0", "manifestVersion": "0.1" } " diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index cb15fa2386..5620fbae4d 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.63, + "branches": 92.67, "functions": 96.65, - "lines": 97.97, - "statements": 97.67 + "lines": 97.98, + "statements": 97.68 } diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index a94355dce6..daf6f486a6 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -102,6 +102,7 @@ "nanoid": "^3.1.31", "readable-stream": "^3.6.2", "readable-web-to-node-stream": "^3.0.2", + "semver": "^7.5.4", "tar-stream": "^3.1.7" }, "devDependencies": { @@ -125,6 +126,7 @@ "@types/mocha": "^10.0.1", "@types/node": "18.14.2", "@types/readable-stream": "^4.0.15", + "@types/semver": "^7.5.0", "@types/tar-stream": "^3.1.1", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index dad16aed3f..575b495317 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -23,6 +23,7 @@ import { AuxiliaryFileEncoding, text } from '@metamask/snaps-sdk'; import { Text } from '@metamask/snaps-sdk/jsx'; import type { SnapPermissions, RpcOrigins } from '@metamask/snaps-utils'; import { + getPlatformVersion, DEFAULT_ENDOWMENTS, DEFAULT_REQUESTED_SNAP_VERSION, getLocalizedSnapManifest, @@ -64,6 +65,7 @@ import { webcrypto } from 'crypto'; import fetchMock from 'jest-fetch-mock'; import { pipeline } from 'readable-stream'; import type { Duplex } from 'readable-stream'; +import semver from 'semver'; import { setupMultiplex } from '../services'; import type { NodeThreadExecutionService } from '../services/node'; @@ -5360,6 +5362,108 @@ describe('SnapController', () => { controller.destroy(); }); + it('does not throw an error if the manifest does not specify a platform version', async () => { + const rawManifest = getSnapManifest(); + delete rawManifest.platformVersion; + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: rawManifest, + }); + + const messenger = getSnapControllerMessenger(); + const controller = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: loopbackDetect({ + manifest: manifest.result, + }), + }), + ); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }), + // eslint-disable-next-line jest/no-restricted-matchers + ).resolves.not.toThrow(); + + controller.destroy(); + }); + + it('throws an error if the specified platform version is newer than the supported platform version', async () => { + const newerVersion = semver.inc( + getPlatformVersion(), + 'minor', + ) as SemVerVersion; + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + platformVersion: newerVersion, + }), + }); + + const messenger = getSnapControllerMessenger(); + const controller = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: loopbackDetect({ + manifest: manifest.result, + }), + }), + ); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }), + ).rejects.toThrow( + `The Snap "${MOCK_SNAP_ID}" requires platform version "${newerVersion}" which is greater than the current platform version "${getPlatformVersion()}".`, + ); + + controller.destroy(); + }); + + it('logs a warning if the specified platform version is newer than the supported platform version and `rejectInvalidPlatformVersion` is disabled', async () => { + const log = jest.spyOn(console, 'warn').mockImplementation(); + + const newerVersion = semver.inc( + getPlatformVersion(), + 'minor', + ) as SemVerVersion; + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + platformVersion: newerVersion, + }), + }); + + const messenger = getSnapControllerMessenger(); + const controller = getSnapController( + getSnapControllerOptions({ + messenger, + featureFlags: { + rejectInvalidPlatformVersion: false, + }, + detectSnapLocation: loopbackDetect({ + manifest: manifest.result, + }), + }), + ); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }), + // eslint-disable-next-line jest/no-restricted-matchers + ).resolves.not.toThrow(); + + expect(log).toHaveBeenCalledWith( + `The Snap "${MOCK_SNAP_ID}" requires platform version "${newerVersion}" which is greater than the current platform version "${getPlatformVersion()}".`, + ); + + controller.destroy(); + }); + it('maps permission caveats to the proper format', async () => { const initialPermissions = { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 532ddf41d1..a29da71243 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -63,6 +63,8 @@ import type { TruncatedSnapFields, } from '@metamask/snaps-utils'; import { + logWarning, + getPlatformVersion, assertIsSnapManifest, assertIsValidSnapId, DEFAULT_ENDOWMENTS, @@ -108,6 +110,7 @@ import type { StateMachine } from '@xstate/fsm'; import { createMachine, interpret } from '@xstate/fsm'; import type { Patch } from 'immer'; import { nanoid } from 'nanoid'; +import semver from 'semver'; import { forceStrict, validateMachine } from '../fsm'; import type { CreateInterface, GetInterface } from '../interface'; @@ -601,6 +604,7 @@ type FeatureFlags = { requireAllowlist?: boolean; allowLocalSnaps?: boolean; disableSnapInstallation?: boolean; + rejectInvalidPlatformVersion?: boolean; }; type DynamicFeatureFlags = { @@ -1332,11 +1336,18 @@ export class SnapController extends BaseController< async #assertIsInstallAllowed( snapId: SnapId, - snapInfo: SnapsRegistryInfo & { permissions: SnapPermissions }, + { + platformVersion, + ...snapInfo + }: SnapsRegistryInfo & { + permissions: SnapPermissions; + platformVersion: string | undefined; + }, ) { const results = await this.messagingSystem.call('SnapsRegistry:get', { [snapId]: snapInfo, }); + const result = results[snapId]; if (result.status === SnapsRegistryStatus.Blocked) { throw new Error( @@ -1365,6 +1376,8 @@ export class SnapController extends BaseController< }`, ); } + + this.#validatePlatformVersion(snapId, platformVersion); } /** @@ -2554,6 +2567,7 @@ export class SnapController extends BaseController< version: newVersion, checksum: manifest.source.shasum, permissions: manifest.initialPermissions, + platformVersion: manifest.platformVersion, }); const processedPermissions = processSnapPermissions( @@ -2739,6 +2753,7 @@ export class SnapController extends BaseController< version: manifest.version, checksum: manifest.source.shasum, permissions: manifest.initialPermissions, + platformVersion: manifest.platformVersion, }); return this.#set({ @@ -3010,6 +3025,34 @@ export class SnapController extends BaseController< ); } + /** + * Validate that the platform version specified in the manifest (if any) is + * compatible with the current platform version. + * + * @param snapId - The ID of the Snap. + * @param platformVersion - The platform version to validate against. + * @throws If the platform version is greater than the current platform + * version. + */ + #validatePlatformVersion( + snapId: SnapId, + platformVersion: string | undefined, + ) { + if (platformVersion === undefined) { + return; + } + + if (semver.gt(platformVersion, getPlatformVersion())) { + const message = `The Snap "${snapId}" requires platform version "${platformVersion}" which is greater than the current platform version "${getPlatformVersion()}".`; + + if (this.#featureFlags.rejectInvalidPlatformVersion) { + throw new Error(message); + } + + logWarning(message); + } + } + /** * Initiates a request for the given snap's initial permissions. * Must be called in order. See processRequestedSnap. diff --git a/packages/snaps-controllers/src/snaps/registry/json.test.ts b/packages/snaps-controllers/src/snaps/registry/json.test.ts index b31afd1f78..eed626f789 100644 --- a/packages/snaps-controllers/src/snaps/registry/json.test.ts +++ b/packages/snaps-controllers/src/snaps/registry/json.test.ts @@ -66,7 +66,7 @@ const MOCK_DATABASE: SnapsRegistryDatabase = { // 3. Run the `sign-registry` script. // 4. Copy the signature from the `signature.json` file. const MOCK_SIGNATURE = - '0x304402201bfe1a98837631b669643135766de58deb426dc3eeb0a908c8000f85a047db3102207ac621072ea59737287099ac830323b34e59bfc41fb62119b16ce24d0c433f9e'; + '0x3045022100fd130773d66931560f199e783c48cf7d8c28d73ea8366add5b64ebcf61f98eca02206f6c56070d5d5899a50fea68add84570d5171c6fae812d4c3a89d5ccdcf396b2'; const MOCK_SIGNATURE_FILE = { signature: MOCK_SIGNATURE, curve: 'secp256k1', diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index bcb7e27708..52978aacaf 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -565,7 +565,10 @@ export const getSnapControllerOptions = ( environmentEndowmentPermissions: [], closeAllConnections: jest.fn(), messenger: getSnapControllerMessenger(), - featureFlags: { dappsCanUpdateSnaps: true }, + featureFlags: { + dappsCanUpdateSnaps: true, + rejectInvalidPlatformVersion: true, + }, state: undefined, fetchFunction: jest.fn(), getMnemonic: async () => Promise.resolve(TEST_SECRET_RECOVERY_PHRASE_BYTES), diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 8676915a5a..185df0be9d 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.74, - "functions": 98.92, + "functions": 98.93, "lines": 99.46, - "statements": 96.32 + "statements": 96.36 } diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index c3488c1d33..7cfb076bdf 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -22,6 +22,7 @@ export * from './logging'; export * from './manifest'; export * from './namespace'; export * from './path'; +export * from './platform-version'; export * from './snaps'; export * from './strings'; export * from './structs'; diff --git a/packages/snaps-utils/src/manifest/manifest.test.ts b/packages/snaps-utils/src/manifest/manifest.test.ts index c8486fa2bc..1b22eaabf2 100644 --- a/packages/snaps-utils/src/manifest/manifest.test.ts +++ b/packages/snaps-utils/src/manifest/manifest.test.ts @@ -2,10 +2,11 @@ import { promises as fs } from 'fs'; import { join } from 'path'; import { readJsonFile } from '../fs'; +import { getPlatformVersion } from '../platform-version'; +import { getSnapChecksum } from '../snaps'; import { DEFAULT_SNAP_BUNDLE, DEFAULT_SNAP_ICON, - DEFAULT_SNAP_SHASUM, MOCK_AUXILIARY_FILE, getPackageJson, getSnapManifest, @@ -33,6 +34,23 @@ const BASE_PATH = '/snap'; const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest); const PACKAGE_JSON_PATH = join(BASE_PATH, NpmSnapFileNames.PackageJson); +/** + * Get the default manifest for the current platform version. + * + * @returns The default manifest. + */ +// TODO: When we support top-level await, we can make this a constant variable, +// and remove this function. +async function getDefaultManifest() { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + platformVersion: getPlatformVersion(), + }), + }); + + return manifest.result; +} + /** * Clears out all the files in the in-memory file system, and writes the default * files to the `BASE_PATH` folder, including sub-folders. @@ -46,7 +64,7 @@ async function resetFileSystem() { await fs.mkdir(join(BASE_PATH, 'src'), { recursive: true }); // Write default files. - await fs.writeFile(MANIFEST_PATH, JSON.stringify(getSnapManifest())); + await fs.writeFile(MANIFEST_PATH, JSON.stringify(await getDefaultManifest())); await fs.writeFile(PACKAGE_JSON_PATH, JSON.stringify(getPackageJson())); await fs.writeFile(join(BASE_PATH, 'dist/bundle.js'), DEFAULT_SNAP_BUNDLE); await fs.writeFile(join(BASE_PATH, 'images/icon.svg'), DEFAULT_SNAP_ICON); @@ -70,6 +88,7 @@ describe('checkManifest', () => { JSON.stringify( getSnapManifest({ shasum: '29MYwcRiruhy9BEJpN/TBIhxoD3t0P4OdXztV9rW8tc=', + platformVersion: getPlatformVersion(), }), ), ); @@ -78,14 +97,16 @@ describe('checkManifest', () => { const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); - expect(files?.manifest.result).toStrictEqual(getSnapManifest()); + const defaultManifest = await getDefaultManifest(); + + expect(files?.manifest.result).toStrictEqual(defaultManifest); expect(updated).toBe(true); expect(unfixed).toHaveLength(0); expect(fixed).toHaveLength(1); const file = await readJsonFile(MANIFEST_PATH); const { source } = file.result; - expect(source.shasum).toBe(DEFAULT_SNAP_SHASUM); + expect(source.shasum).toBe(defaultManifest.source.shasum); }); it('fixes multiple problems in the manifest', async () => { @@ -95,6 +116,7 @@ describe('checkManifest', () => { getSnapManifest({ version: '0.0.1', shasum: '29MYwcRiruhy9BEJpN/TBIhxoD3t0P4OdXztV9rW8tc=', + platformVersion: getPlatformVersion(), }), ), ); @@ -103,14 +125,16 @@ describe('checkManifest', () => { const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); - expect(files?.manifest.result).toStrictEqual(getSnapManifest()); + const defaultManifest = await getDefaultManifest(); + + expect(files?.manifest.result).toStrictEqual(defaultManifest); expect(updated).toBe(true); expect(unfixed).toHaveLength(0); expect(fixed).toHaveLength(2); const file = await readJsonFile(MANIFEST_PATH); const { source, version } = file.result; - expect(source.shasum).toBe(DEFAULT_SNAP_SHASUM); + expect(source.shasum).toBe(defaultManifest.source.shasum); expect(version).toBe('1.0.0'); }); @@ -148,7 +172,9 @@ describe('checkManifest', () => { }); it('returns a warning if manifest has with a non 1:1 ratio', async () => { - const manifest = getSnapManifest(); + const manifest = getSnapManifest({ + platformVersion: getPlatformVersion(), + }); await fs.writeFile( join(BASE_PATH, 'images/icon.svg'), @@ -165,27 +191,31 @@ describe('checkManifest', () => { }); it('return errors if the manifest is invalid', async () => { - await fs.writeFile( - MANIFEST_PATH, - JSON.stringify( - getSnapManifest({ - version: '0.0.1', - shasum: '1234567890123456789012345678901234567890123=', - }), - ), - ); + const manifest = getSnapManifest({ + version: '0.0.1', + shasum: '1234567890123456789012345678901234567890123=', + platformVersion: getPlatformVersion(), + }); + + await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); const { reports } = await checkManifest(BASE_PATH, { updateAndWriteManifest: false, }); + const expectedChecksum = await getSnapChecksum( + getMockSnapFiles({ + manifest, + }), + ); + expect(reports).toHaveLength(2); // Make this test order independent // eslint-disable-next-line jest/prefer-strict-equal expect(reports.map(({ message }) => message)).toEqual( expect.arrayContaining([ '"snap.manifest.json" npm package version ("0.0.1") does not match the "package.json" "version" field ("1.0.0").', - '"snap.manifest.json" "shasum" field does not match computed shasum. Got "1234567890123456789012345678901234567890123=", expected "TVOA4znZze3/eDErYSzrFF6z67fu9cL70+ZfgUM6nCQ=".', + `"snap.manifest.json" "shasum" field does not match computed shasum. Got "1234567890123456789012345678901234567890123=", expected "${expectedChecksum}".`, ]), ); }); @@ -196,6 +226,7 @@ describe('checkManifest', () => { JSON.stringify( getSnapManifest({ locales: ['foo.json'], + platformVersion: getPlatformVersion(), }), ), ); @@ -215,6 +246,7 @@ describe('checkManifest', () => { const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ manifest: getSnapManifest({ locales: ['locales/en.json'], + platformVersion: getPlatformVersion(), }), localizationFiles: [localizationFile], }); @@ -242,6 +274,7 @@ describe('checkManifest', () => { manifest: getSnapManifest({ proposedName: '{{ name }}', locales: ['locales/en.json'], + platformVersion: getPlatformVersion(), }), localizationFiles: [localizationFile], }); diff --git a/packages/snaps-utils/src/manifest/manifest.ts b/packages/snaps-utils/src/manifest/manifest.ts index 8ae7554629..6e0e5cf312 100644 --- a/packages/snaps-utils/src/manifest/manifest.ts +++ b/packages/snaps-utils/src/manifest/manifest.ts @@ -24,7 +24,8 @@ const MANIFEST_SORT_ORDER: Record = { source: 6, initialConnections: 7, initialPermissions: 8, - manifestVersion: 9, + platformVersion: 9, + manifestVersion: 10, }; export type CheckManifestReport = Omit & { diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 6ed252ae52..dd65132a10 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -292,6 +292,7 @@ export const SnapManifestStruct = object({ initialConnections: optional(InitialConnectionsStruct), initialPermissions: PermissionsStruct, manifestVersion: literal('0.1'), + platformVersion: optional(VersionStruct), $schema: optional(string()), // enables JSON-Schema linting in VSC and other IDEs }); diff --git a/packages/snaps-utils/src/manifest/validators/index.ts b/packages/snaps-utils/src/manifest/validators/index.ts index e27230b9cf..50ba6fe985 100644 --- a/packages/snaps-utils/src/manifest/validators/index.ts +++ b/packages/snaps-utils/src/manifest/validators/index.ts @@ -8,6 +8,8 @@ export * from './is-snap-manifest'; export * from './manifest-localization'; export * from './package-json-recommended-fields'; export * from './package-name-match'; +// TODO: Uncomment the following line after the next release. +// export * from './platform-version'; export * from './repository-match'; export * from './version-match'; export * from './icon-declared'; diff --git a/packages/snaps-utils/src/manifest/validators/platform-version.test.ts b/packages/snaps-utils/src/manifest/validators/platform-version.test.ts new file mode 100644 index 0000000000..5d101530a8 --- /dev/null +++ b/packages/snaps-utils/src/manifest/validators/platform-version.test.ts @@ -0,0 +1,87 @@ +import type { SemVerVersion } from '@metamask/utils'; +import assert from 'assert'; +import { createRequire } from 'module'; + +import { getMockSnapFiles, getSnapManifest } from '../../test-utils'; +import { platformVersion } from './platform-version'; + +describe('platformVersion', () => { + const require = createRequire(__filename); + const packageJson = require.resolve('@metamask/snaps-sdk/package.json'); + // eslint-disable-next-line import/no-dynamic-require + const sdkVersion = require(packageJson).version; + + it('does nothing if the version matches', async () => { + const report = jest.fn(); + assert(platformVersion.semanticCheck); + + await platformVersion.semanticCheck( + getMockSnapFiles({ + manifest: getSnapManifest({ platformVersion: sdkVersion }), + manifestPath: __filename, + }), + { report }, + ); + + expect(report).toHaveBeenCalledTimes(0); + }); + + it('reports if the version is not set', async () => { + const report = jest.fn(); + assert(platformVersion.semanticCheck); + + const rawManifest = getSnapManifest(); + delete rawManifest.platformVersion; + + const files = getMockSnapFiles({ + manifest: rawManifest, + manifestPath: __filename, + }); + + await platformVersion.semanticCheck(files, { report }); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + expect.stringContaining( + `The "platformVersion" field is missing from the manifest.`, + ), + expect.any(Function), + ); + + const fix = report.mock.calls[0][1]; + expect(fix).toBeInstanceOf(Function); + assert(fix); + + const { manifest } = await fix(files); + expect(manifest.platformVersion).toStrictEqual(sdkVersion); + }); + + it('reports if the version does not match', async () => { + const report = jest.fn(); + assert(platformVersion.semanticCheck); + + const files = getMockSnapFiles({ + manifest: getSnapManifest({ + platformVersion: '1.2.3' as SemVerVersion, + }), + manifestPath: __filename, + }); + + await platformVersion.semanticCheck(files, { report }); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + expect.stringContaining( + `The "platformVersion" field in the manifest must match the version of the Snaps SDK. Got "1.2.3", expected "${sdkVersion}".`, + ), + expect.any(Function), + ); + + const fix = report.mock.calls[0][1]; + expect(fix).toBeInstanceOf(Function); + assert(fix); + + const { manifest } = await fix(files); + expect(manifest.platformVersion).toStrictEqual(sdkVersion); + }); +}); diff --git a/packages/snaps-utils/src/manifest/validators/platform-version.ts b/packages/snaps-utils/src/manifest/validators/platform-version.ts new file mode 100644 index 0000000000..f3393e8d23 --- /dev/null +++ b/packages/snaps-utils/src/manifest/validators/platform-version.ts @@ -0,0 +1,44 @@ +import { createRequire } from 'module'; + +import type { ValidatorMeta } from '../validator-types'; + +/** + * Check if the platform version in manifest matches the version of the Snaps + * SDK. + */ +export const platformVersion: ValidatorMeta = { + severity: 'error', + async semanticCheck(files, context) { + const manifestPlatformVersion = files.manifest.result.platformVersion; + + // Create a require function in the context of the location of the manifest + // file to avoid potentially loading the wrong version of the Snaps SDK. + const require = createRequire(files.manifest.path); + + const packageJson = require.resolve('@metamask/snaps-sdk/package.json'); + // eslint-disable-next-line import/no-dynamic-require + const actualVersion = require(packageJson).version; + + if (!manifestPlatformVersion) { + context.report( + 'The "platformVersion" field is missing from the manifest.', + ({ manifest }) => { + manifest.platformVersion = actualVersion; + return { manifest }; + }, + ); + + return; + } + + if (manifestPlatformVersion !== actualVersion) { + context.report( + `The "platformVersion" field in the manifest must match the version of the Snaps SDK. Got "${manifestPlatformVersion}", expected "${actualVersion}".`, + async ({ manifest }) => { + manifest.platformVersion = actualVersion; + return { manifest }; + }, + ); + } + }, +}; diff --git a/packages/snaps-utils/src/platform-version.test.ts b/packages/snaps-utils/src/platform-version.test.ts new file mode 100644 index 0000000000..16c12892c1 --- /dev/null +++ b/packages/snaps-utils/src/platform-version.test.ts @@ -0,0 +1,10 @@ +import { isValidSemVerVersion } from '@metamask/utils'; + +import { getPlatformVersion } from './platform-version'; + +describe('getPlatformVersion', () => { + it('returns the version of the SDK', () => { + const version = getPlatformVersion(); + expect(isValidSemVerVersion(version)).toBe(true); + }); +}); diff --git a/packages/snaps-utils/src/platform-version.ts b/packages/snaps-utils/src/platform-version.ts new file mode 100644 index 0000000000..c2880ebe2a --- /dev/null +++ b/packages/snaps-utils/src/platform-version.ts @@ -0,0 +1,13 @@ +/** + * Get the current supported platform version. + * + * Note: This function assumes that the same SDK version is used across all + * dependencies. If this is not the case, the version of the SDK that is + * closest to the `snaps-utils` package will be returned. + * + * @returns The platform version. + */ +export function getPlatformVersion() { + // eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + return require('@metamask/snaps-sdk/package.json').version; +} diff --git a/packages/snaps-utils/src/test-utils/manifest.ts b/packages/snaps-utils/src/test-utils/manifest.ts index b6e2fff84f..a07bf7ed83 100644 --- a/packages/snaps-utils/src/test-utils/manifest.ts +++ b/packages/snaps-utils/src/test-utils/manifest.ts @@ -16,6 +16,7 @@ type GetSnapManifestOptions = Partial> & { iconPath?: string; files?: string[]; locales?: string[]; + platformVersion?: string; }; type GetPackageJsonOptions = Partial>; @@ -69,7 +70,8 @@ export const ALTERNATIVE_SNAP_ICON = // This will need to be recalculated if the checksum inputs change. export const DEFAULT_SNAP_SHASUM = - 'rNyfINgNh161cBmUop+F7xlE+GSEDZH53Y/HDpGLGGg='; + '/17SwI03+Cn9sk45Z6Czp+Sktru1oLzOmkJW+YbP9WE='; + /** * Get a mock snap manifest, based on the provided options. This is useful for * quickly generating a manifest file, while being able to override any of the @@ -88,6 +90,7 @@ export const DEFAULT_SNAP_SHASUM = * @param manifest.files - Auxiliary files loaded at runtime by the snap. * @param manifest.locales - Localization files of the snap. * @param manifest.initialConnections - Initial connections for the snap. + * @param manifest.platformVersion - The platform version of the snap. * @returns The snap manifest. */ export const getSnapManifest = ({ @@ -103,6 +106,7 @@ export const getSnapManifest = ({ files = undefined, locales = undefined, initialConnections = undefined, + platformVersion = '1.0.0' as SemVerVersion, }: GetSnapManifestOptions = {}): SnapManifest => { return { version: version as SemVerVersion, @@ -124,6 +128,7 @@ export const getSnapManifest = ({ }, ...(initialConnections ? { initialConnections } : {}), initialPermissions, + platformVersion, manifestVersion: '0.1' as const, }; }; @@ -159,6 +164,7 @@ export const getPackageJson = ({ export const getMockSnapFiles = ({ manifest = getSnapManifest(), + manifestPath = DEFAULT_MANIFEST_PATH, packageJson = getPackageJson(), sourceCode = DEFAULT_SNAP_BUNDLE, svgIcon = DEFAULT_SNAP_ICON, @@ -166,6 +172,7 @@ export const getMockSnapFiles = ({ localizationFiles = [], }: { manifest?: SnapManifest | VirtualFile; + manifestPath?: string; sourceCode?: string | VirtualFile; packageJson?: NpmSnapPackageJson; svgIcon?: string | VirtualFile; @@ -179,7 +186,7 @@ export const getMockSnapFiles = ({ : new VirtualFile({ value: JSON.stringify(manifest), result: manifest, - path: DEFAULT_MANIFEST_PATH, + path: manifestPath, }), packageJson: new VirtualFile({ value: JSON.stringify(packageJson), diff --git a/packages/snaps-webpack-plugin/src/manifest.test.ts b/packages/snaps-webpack-plugin/src/manifest.test.ts index 163fb71551..451ccb5701 100644 --- a/packages/snaps-webpack-plugin/src/manifest.test.ts +++ b/packages/snaps-webpack-plugin/src/manifest.test.ts @@ -26,7 +26,7 @@ describe('writeManifest', () => { "url": "https://github.com/MetaMask/example-snap.git" }, "source": { - "shasum": "rNyfINgNh161cBmUop+F7xlE+GSEDZH53Y/HDpGLGGg=", + "shasum": "/17SwI03+Cn9sk45Z6Czp+Sktru1oLzOmkJW+YbP9WE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -40,6 +40,7 @@ describe('writeManifest', () => { "snap_dialog": {}, "endowment:rpc": { "snaps": true, "dapps": false } }, + "platformVersion": "1.0.0", "manifestVersion": "0.1" } " @@ -63,7 +64,7 @@ describe('writeManifest', () => { "url": "https://github.com/MetaMask/example-snap.git" }, "source": { - "shasum": "rNyfINgNh161cBmUop+F7xlE+GSEDZH53Y/HDpGLGGg=", + "shasum": "/17SwI03+Cn9sk45Z6Czp+Sktru1oLzOmkJW+YbP9WE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -77,6 +78,7 @@ describe('writeManifest', () => { "snap_dialog": {}, "endowment:rpc": { "snaps": true, "dapps": false } }, + "platformVersion": "1.0.0", "manifestVersion": "0.1" } " @@ -99,7 +101,7 @@ describe('writeManifest', () => { "url": "https://github.com/MetaMask/example-snap.git" }, "source": { - "shasum": "rNyfINgNh161cBmUop+F7xlE+GSEDZH53Y/HDpGLGGg=", + "shasum": "/17SwI03+Cn9sk45Z6Czp+Sktru1oLzOmkJW+YbP9WE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -113,6 +115,7 @@ describe('writeManifest', () => { "snap_dialog": {}, "endowment:rpc": { "snaps": true, "dapps": false } }, + "platformVersion": "1.0.0", "manifestVersion": "0.1" } " diff --git a/yarn.lock b/yarn.lock index 835c9e7f6f..b2742a032e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5746,6 +5746,7 @@ __metadata: "@types/mocha": "npm:^10.0.1" "@types/node": "npm:18.14.2" "@types/readable-stream": "npm:^4.0.15" + "@types/semver": "npm:^7.5.0" "@types/tar-stream": "npm:^3.1.1" "@typescript-eslint/eslint-plugin": "npm:^5.42.1" "@typescript-eslint/parser": "npm:^6.21.0" @@ -5786,6 +5787,7 @@ __metadata: readable-stream: "npm:^3.6.2" readable-web-to-node-stream: "npm:^3.0.2" rimraf: "npm:^4.1.2" + semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" ts-node: "npm:^10.9.1" typescript: "npm:~5.3.3"