diff --git a/packages/snaps-cli/src/cmds/init/initHandler.ts b/packages/snaps-cli/src/cmds/init/initHandler.ts index 8a8a06c0f5..51a4b7bb65 100644 --- a/packages/snaps-cli/src/cmds/init/initHandler.ts +++ b/packages/snaps-cli/src/cmds/init/initHandler.ts @@ -4,6 +4,8 @@ import { readJsonFile, satisfiesVersionRange, NpmSnapPackageJson, + SemVerVersion, + SemVerRange, } from '@metamask/snaps-utils'; import { promises as fs } from 'fs'; import pathUtils from 'path'; @@ -19,7 +21,7 @@ import { yarnInstall, } from './initUtils'; -const SATISFIED_VERSION = '>=16'; +const SATISFIED_VERSION = '>=16' as SemVerRange; /** * Creates a new snap package, based on one of the provided templates. This @@ -35,7 +37,7 @@ export async function initHandler(argv: YargsArgs) { const { directory } = argv; const isVersionSupported = satisfiesVersionRange( - process.version, + process.version as SemVerVersion, SATISFIED_VERSION, ); diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 875aafec1b..40410254df 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -3,6 +3,7 @@ import { HandlerType, deepClone, TruncatedSnap, + SemVerVersion, } from '@metamask/snaps-utils'; import { MOCK_ORIGIN, @@ -77,6 +78,8 @@ const MOCK_CRONJOB_SINGLE_JOB_PERMISSION = { parentCapability: SnapEndowments.Cronjob, }; +const MOCK_VERSION = '1.0' as SemVerVersion; + describe('CronjobController', () => { beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); @@ -315,7 +318,7 @@ describe('CronjobController', () => { id: MOCK_SNAP_ID, initialPermissions: {}, permissionName: '', - version: '', + version: MOCK_VERSION, }; // @ts-expect-error Accessing private property cronjobController._handleEventSnapInstalled(snapInfo); @@ -367,7 +370,7 @@ describe('CronjobController', () => { id: MOCK_SNAP_ID, initialPermissions: {}, permissionName: '', - version: '', + version: MOCK_VERSION, }; // @ts-expect-error Accessing private property @@ -432,7 +435,7 @@ describe('CronjobController', () => { id: MOCK_SNAP_ID, initialPermissions: {}, permissionName: '', - version: '', + version: MOCK_VERSION, }; // @ts-expect-error Accessing private property diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.ts b/packages/snaps-controllers/src/snaps/SnapController.test.ts index 0b5c6e2a9c..e9f872a490 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_ENDOWMENTS, getSnapSourceShasum, HandlerType, + SemVerVersion, SnapCaveatType, SnapManifest, SnapStatus, @@ -2545,7 +2546,7 @@ describe('SnapController', () => { fetchSnapSpy.mockImplementationOnce(async () => { const manifest: SnapManifest = { ...getSnapManifest(), - version: '1.1.0', + version: '1.1.0' as SemVerVersion, }; return Promise.resolve({ @@ -2580,7 +2581,7 @@ describe('SnapController', () => { fetchSnapSpy.mockImplementationOnce(async () => { const manifest: SnapManifest = { ...getSnapManifest(), - version: '0.9.0', + version: '0.9.0' as SemVerVersion, }; return Promise.resolve({ @@ -2625,7 +2626,7 @@ describe('SnapController', () => { .mockImplementationOnce(async () => { const manifest: SnapManifest = { ...getSnapManifest(), - version: '1.1.0', + version: '1.1.0' as SemVerVersion, }; return Promise.resolve({ @@ -2757,7 +2758,7 @@ describe('SnapController', () => { fetchSnapSpy.mockImplementationOnce(async () => { const manifest: SnapManifest = { ...getSnapManifest(), - version: '1.1.0', + version: '1.1.0' as SemVerVersion, }; return Promise.resolve({ @@ -2864,7 +2865,7 @@ describe('SnapController', () => { fetchSnapSpy.mockImplementationOnce(async () => { const manifest: SnapManifest = { ...getSnapManifest(), - version: '1.1.0', + version: '1.1.0' as SemVerVersion, }; return Promise.resolve({ @@ -3949,6 +3950,9 @@ describe('SnapController', () => { ) { return true; } + // TypeScript doesn't allow variadic list of arguments into non-variadic functions. + // messenger.call is a union of functions that take a constant amount of arguments, and + // so, TypeScript errors. return (originalCall as any)(method, ...args); }); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 79587bb621..b259c91440 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -28,14 +28,15 @@ import { getSnapPrefix, gtVersion, InstallSnapsResult, - isValidSnapVersionRange, + isValidSemVerRange, LOCALHOST_HOSTNAMES, NpmSnapFileNames, PersistedSnap, ProcessSnapResult, RequestedSnapPermissions, - resolveVersion, + resolveVersionRange, satisfiesVersionRange, + SemVerRange, Snap, SnapId, SnapIdPrefixes, @@ -536,7 +537,7 @@ type SnapControllerArgs = { type AddSnapArgsBase = { id: SnapId; origin: string; - versionRange?: string; + versionRange?: SemVerRange; }; // A snap can either be added directly, with manifest and source code, or it @@ -1535,10 +1536,8 @@ export class SnapController extends BaseController< await Promise.all( Object.entries(requestedSnaps).map( async ([snapId, { version: rawVersion }]) => { - const version = resolveVersion(rawVersion); - const permissionName = getSnapPermissionName(snapId); - - if (!isValidSnapVersionRange(version)) { + const [error, version] = resolveVersionRange(rawVersion); + if (error) { result[snapId] = { error: ethErrors.rpc.invalidParams( `The "version" field must be a valid SemVer version range if specified. Received: "${version}".`, @@ -1546,6 +1545,7 @@ export class SnapController extends BaseController< }; return; } + const permissionName = getSnapPermissionName(snapId); if ( this.messagingSystem.call( @@ -1585,7 +1585,7 @@ export class SnapController extends BaseController< private async processRequestedSnap( origin: string, snapId: SnapId, - versionRange: string, + versionRange: SemVerRange, ): Promise { try { validateSnapId(snapId); @@ -1688,7 +1688,7 @@ export class SnapController extends BaseController< ): Promise { const snap = this.getExpect(snapId); - if (!isValidSnapVersionRange(newVersionRange)) { + if (!isValidSemVerRange(newVersionRange)) { throw new Error( `Received invalid snap version range: "${newVersionRange}".`, ); @@ -2052,7 +2052,7 @@ export class SnapController extends BaseController< packageName: string, versionRange: string, ): Promise { - if (!isValidSnapVersionRange(versionRange)) { + if (!isValidSemVerRange(versionRange)) { throw new Error( `Received invalid Snap version range: "${versionRange}".`, ); diff --git a/packages/snaps-controllers/src/snaps/utils/npm.ts b/packages/snaps-controllers/src/snaps/utils/npm.ts index 2d3efee1fb..510e14a959 100644 --- a/packages/snaps-controllers/src/snaps/utils/npm.ts +++ b/packages/snaps-controllers/src/snaps/utils/npm.ts @@ -4,6 +4,9 @@ import { isValidUrl, getTargetVersion, validateNpmSnap, + assertIsSemVerVersion, + SemVerRange, + SemVerVersion, } from '@metamask/snaps-utils'; import { isObject } from '@metamask/utils'; import createGunzipStream from 'gunzip-maybe'; @@ -26,7 +29,7 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; */ export async function fetchNpmSnap( packageName: string, - versionRange: string, + versionRange: SemVerRange, registryUrl = DEFAULT_NPM_REGISTRY, fetchFunction = fetch, ): Promise { @@ -56,7 +59,7 @@ export async function fetchNpmSnap( // object if they exist. return validateNpmSnap( snapFiles, - `npm Snap "${packageName}@${actualVersion}" validation error: `, + `npm Snap "${packageName}@${actualVersion}" validation error: ` as `${string}: `, ); } @@ -75,10 +78,10 @@ export async function fetchNpmSnap( */ async function fetchNpmTarball( packageName: string, - versionRange: string, + versionRange: SemVerRange, registryUrl = DEFAULT_NPM_REGISTRY, fetchFunction = fetch, -): Promise<[ReadableStream, string]> { +): Promise<[ReadableStream, SemVerVersion]> { const packageMetadata = await ( await fetchFunction(new URL(packageName, registryUrl).toString()) ).json(); @@ -89,11 +92,15 @@ async function fetchNpmTarball( ); } - const targetVersion = getTargetVersion( - Object.keys((packageMetadata as any)?.versions ?? {}), - versionRange, + const versions = Object.keys((packageMetadata as any)?.versions ?? {}).map( + (version) => { + assertIsSemVerVersion(version); + return version; + }, ); + const targetVersion = getTargetVersion(versions, versionRange); + if (targetVersion === null) { throw new Error( `Failed to find a matching version in npm metadata for package "${packageName}" and requested semver range "${versionRange}"`, diff --git a/packages/snaps-utils/jest.config.js b/packages/snaps-utils/jest.config.js index 03abb3f1aa..a84cb899d8 100644 --- a/packages/snaps-utils/jest.config.js +++ b/packages/snaps-utils/jest.config.js @@ -14,10 +14,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 93.15, - functions: 95.18, - lines: 98.07, - statements: 98.07, + branches: 93.45, + functions: 95.45, + lines: 98.34, + statements: 98.34, }, }, testTimeout: 2500, diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 09d423c7da..ebfe4e8c92 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -20,7 +20,8 @@ import { import { CronjobSpecificationArrayStruct } from '../cronjob'; import { NamespacesStruct } from '../namespace'; -import { NameStruct, NpmSnapFileNames, VersionStruct } from '../types'; +import { NameStruct, NpmSnapFileNames } from '../types'; +import { VersionStruct } from '../versions'; // 0xd36e6170 - 0x80000000 export const SIP_6_MAGIC_VALUE = `1399742832'` as `${number}'`; diff --git a/packages/snaps-utils/src/snaps.ts b/packages/snaps-utils/src/snaps.ts index 3f0b021ee3..4e925c7bf7 100644 --- a/packages/snaps-utils/src/snaps.ts +++ b/packages/snaps-utils/src/snaps.ts @@ -5,6 +5,7 @@ import { SerializedEthereumRpcError } from 'eth-rpc-errors/dist/classes'; import { SnapManifest, SnapPermissions } from './manifest/validation'; import { SnapId, SnapIdPrefixes, SnapValidationFailureReason } from './types'; +import { SemVerVersion } from './versions'; export const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); export const SNAP_PREFIX = 'wallet_snap_'; @@ -119,7 +120,7 @@ export type Snap = { /** * The version of the Snap. */ - version: string; + version: SemVerVersion; /** * The version history of the Snap. diff --git a/packages/snaps-utils/src/test-utils/common.ts b/packages/snaps-utils/src/test-utils/common.ts new file mode 100644 index 0000000000..a15d6c88f5 --- /dev/null +++ b/packages/snaps-utils/src/test-utils/common.ts @@ -0,0 +1,12 @@ +import { SemVerVersion } from '../versions'; + +/** + * Tens/hundreds legacy tests use creation utils. + * + * Updating them to use proper type casting is unfeasible at this time. + * We use this function to make creation utils backwards compatible, + * until we're ready to update tests. + */ +export type MakeSemVer = { + [K in keyof T]: K extends 'version' ? SemVerVersion | string : T[K]; +}; diff --git a/packages/snaps-utils/src/test-utils/manifest.ts b/packages/snaps-utils/src/test-utils/manifest.ts index 68f3192130..d045780d47 100644 --- a/packages/snaps-utils/src/test-utils/manifest.ts +++ b/packages/snaps-utils/src/test-utils/manifest.ts @@ -6,9 +6,11 @@ import { SessionNamespace, } from '../namespace'; import { NpmSnapPackageJson } from '../types'; +import { SemVerVersion } from '../versions'; +import { MakeSemVer } from './common'; import { DEFAULT_SNAP_SHASUM } from './snap'; -type GetSnapManifestOptions = Partial> & { +type GetSnapManifestOptions = Partial> & { shasum?: string; filePath?: string; packageName?: string; @@ -16,6 +18,8 @@ type GetSnapManifestOptions = Partial> & { iconPath?: string; }; +type GetPackageJsonOptions = Partial>; + /** * Get the default package repository, in a format compatible with * `package.json`. @@ -47,7 +51,7 @@ export const getDefaultRepository = () => { * @returns The snap manifest. */ export const getSnapManifest = ({ - version = '1.0.0', + version = '1.0.0' as SemVerVersion, description = 'The test example snap!', proposedName = '@metamask/example-snap', // eslint-disable-next-line @typescript-eslint/naming-convention @@ -59,7 +63,7 @@ export const getSnapManifest = ({ iconPath = 'images/icon.svg', }: GetSnapManifestOptions = {}): SnapManifest => { return { - version, + version: version as SemVerVersion, description, proposedName, repository, @@ -94,14 +98,14 @@ export const getSnapManifest = ({ */ export const getPackageJson = ({ name = '@metamask/example-snap', - version = '1.0.0', + version = '1.0.0' as SemVerVersion, description = 'The test example snap!', main = 'src/index.js', repository = getDefaultRepository(), -}: Partial = {}): NpmSnapPackageJson => { +}: GetPackageJsonOptions = {}): NpmSnapPackageJson => { return { name, - version, + version: version as SemVerVersion, description, main, repository, diff --git a/packages/snaps-utils/src/test-utils/snap.ts b/packages/snaps-utils/src/test-utils/snap.ts index 603efc078a..dcae80cca0 100644 --- a/packages/snaps-utils/src/test-utils/snap.ts +++ b/packages/snaps-utils/src/test-utils/snap.ts @@ -5,6 +5,8 @@ import { SnapStatus, TruncatedSnap, } from '../snaps'; +import { SemVerVersion } from '../versions'; +import { MakeSemVer } from './common'; import { getSnapManifest } from './manifest'; /** @@ -27,6 +29,10 @@ export const MOCK_SNAP_ID = 'npm:@metamask/example-snap'; export const MOCK_LOCAL_SNAP_ID = 'local:@metamask/example-snap'; export const MOCK_ORIGIN = 'example.com'; +type GetPersistedSnapObjectOptions = Partial>; +type GetSnapObjectOptions = Partial>; +type GetTruncatedSnapOptions = Partial>; + export const getPersistedSnapObject = ({ blocked = false, enabled = true, @@ -40,13 +46,13 @@ export const getPersistedSnapObject = ({ versionHistory = [ { origin: MOCK_ORIGIN, version: '1.0.0', date: expect.any(Number) }, ], -}: Partial = {}): PersistedSnap => { +}: GetPersistedSnapObjectOptions = {}): PersistedSnap => { return { blocked, initialPermissions, id, permissionName, - version, + version: version as SemVerVersion, manifest, status, enabled, @@ -67,13 +73,13 @@ export const getSnapObject = ({ versionHistory = [ { origin: MOCK_ORIGIN, version: '1.0.0', date: expect.any(Number) }, ], -}: Partial = {}): Snap => { +}: GetSnapObjectOptions = {}): Snap => { return { blocked, initialPermissions, id, permissionName, - version, + version: version as SemVerVersion, manifest, status, enabled, @@ -88,12 +94,12 @@ export const getTruncatedSnap = ({ version = getSnapManifest().version, enabled = true, blocked = false, -}: Partial = {}): TruncatedSnap => { +}: GetTruncatedSnapOptions = {}): TruncatedSnap => { return { initialPermissions, id, permissionName, - version, + version: version as SemVerVersion, enabled, blocked, } as const; diff --git a/packages/snaps-utils/src/types.ts b/packages/snaps-utils/src/types.ts index b3aae7ff6f..29020359b7 100644 --- a/packages/snaps-utils/src/types.ts +++ b/packages/snaps-utils/src/types.ts @@ -3,33 +3,25 @@ import { SnapKeyring as Keyring, } from '@metamask/snaps-types'; import { assertStruct, Json } from '@metamask/utils'; -import { valid as validSemver } from 'semver'; import { Infer, is, object, optional, pattern, - refine, size, string, type, } from 'superstruct'; import { SnapManifest } from './manifest/validation'; +import { VersionStruct } from './versions'; export enum NpmSnapFileNames { PackageJson = 'package.json', Manifest = 'snap.manifest.json', } -/** - * A struct for validating a version string. - */ -export const VersionStruct = refine(string(), 'Version', (value) => { - return validSemver(value) !== null; -}); - export const NameStruct = size( pattern( string(), @@ -166,3 +158,8 @@ type KeyringParameters = KeyringParameter; export type SnapExportsParameters = | ObjectParameters | KeyringParameters; + +// We use a symbol property name instead of { _type: Brand }, because that would show up in IDE suggestions, +// while internal symbols do not. +declare const brand: unique symbol; +export type Opaque = Base & { [brand]: Brand }; diff --git a/packages/snaps-utils/src/versions.test.ts b/packages/snaps-utils/src/versions.test.ts index 87b28c14c4..7874a0e46b 100644 --- a/packages/snaps-utils/src/versions.test.ts +++ b/packages/snaps-utils/src/versions.test.ts @@ -1,25 +1,43 @@ +import { assert } from '@metamask/utils'; + import { getSnapPrefix } from './snaps'; import { SnapIdPrefixes } from './types'; import { + assertIsSemVerRange, + assertIsSemVerVersion, DEFAULT_REQUESTED_SNAP_VERSION, getTargetVersion, gtVersion, - isValidSnapVersionRange, - resolveVersion, + isValidSemVerRange, + isValidSemVerVersion, + resolveVersionRange, satisfiesVersionRange, + SemVerRange, + SemVerVersion, } from './versions'; describe('resolveVersion', () => { it('defaults "latest" to DEFAULT_REQUESTED_SNAP_VERSION', () => { - expect(resolveVersion('latest')).toBe(DEFAULT_REQUESTED_SNAP_VERSION); + expect(resolveVersionRange('latest')[1]).toBe( + DEFAULT_REQUESTED_SNAP_VERSION, + ); }); it('defaults an undefined version to DEFAULT_REQUESTED_SNAP_VERSION', () => { - expect(resolveVersion(undefined)).toBe(DEFAULT_REQUESTED_SNAP_VERSION); + expect(resolveVersionRange(undefined)[1]).toBe( + DEFAULT_REQUESTED_SNAP_VERSION, + ); }); it('returns the requested version for everything else', () => { - expect(resolveVersion('1.2.3')).toBe('1.2.3'); + expect(resolveVersionRange('1.2.3')[1]).toBe('1.2.3'); + }); + + it.each([null, 1, {}, Error])('returns error on invalid input', (value) => { + const [error, result] = resolveVersionRange(value); + assert(error !== undefined); + expect(error.message).toMatch('Expected a string, but received: '); + expect(result).toBeUndefined(); }); }); @@ -39,99 +57,242 @@ describe('getSnapPrefix', () => { }); }); -describe('isValidSnapVersionRange', () => { +describe('assertIsSemVerVersion', () => { + it('shows descriptive errors', () => { + expect(() => assertIsSemVerVersion('>1.2')).toThrow( + 'Expected SemVer version, got', + ); + }); +}); + +describe('assertIsSemVerRange', () => { + it('shows descriptive errors', () => { + expect(() => assertIsSemVerRange('.')).toThrow( + 'Expected SemVer range, got', + ); + }); +}); + +describe('isValidSemVerVersion', () => { + it.each([ + 'asd', + '()()', + '..', + '.', + '.1', + null, + undefined, + 2, + true, + {}, + Error, + ])('rejects invalid version', (version) => { + expect(isValidSemVerVersion(version)).toBe(false); + }); + + it('supports normal version ranges', () => { + expect(isValidSemVerVersion('1.5.0')).toBe(true); + }); + + it('supports pre-release versions', () => { + expect(isValidSemVerVersion('1.0.0-beta.1')).toBe(true); + }); +}); + +describe('isValidSemVerRange', () => { it('supports *', () => { - expect(isValidSnapVersionRange('*')).toBe(true); + expect(isValidSemVerRange('*')).toBe(true); }); it('supports normal version ranges', () => { - expect(isValidSnapVersionRange('^1.2.3')).toBe(true); - expect(isValidSnapVersionRange('1.5.0')).toBe(true); + expect(isValidSemVerRange('^1.2.3')).toBe(true); + expect(isValidSemVerRange('1.5.0')).toBe(true); }); it('supports pre-release versions', () => { - expect(isValidSnapVersionRange('1.0.0-beta.1')).toBe(true); - expect(isValidSnapVersionRange('^1.0.0-beta.1')).toBe(true); + expect(isValidSemVerRange('1.0.0-beta.1')).toBe(true); + expect(isValidSemVerRange('^1.0.0-beta.1')).toBe(true); }); it('rejects non strings', () => { - expect(isValidSnapVersionRange(null)).toBe(false); - expect(isValidSnapVersionRange(undefined)).toBe(false); - expect(isValidSnapVersionRange(2)).toBe(false); - expect(isValidSnapVersionRange(true)).toBe(false); - expect(isValidSnapVersionRange({})).toBe(false); + expect(isValidSemVerRange(null)).toBe(false); + expect(isValidSemVerRange(undefined)).toBe(false); + expect(isValidSemVerRange(2)).toBe(false); + expect(isValidSemVerRange(true)).toBe(false); + expect(isValidSemVerRange({})).toBe(false); }); + + it.each(['asd', '()()(', '..', '.', '1.'])( + 'rejects invalid ranges', + (range) => { + expect(isValidSemVerRange(range)).toBe(false); + }, + ); }); describe('gtVersion', () => { it('supports regular versions', () => { - expect(gtVersion('1.2.3', '1.0.0')).toBe(true); - expect(gtVersion('2.0.0', '1.0.0')).toBe(true); - expect(gtVersion('1.0.0', '1.2.3')).toBe(false); - expect(gtVersion('1.0.0', '2.0.0')).toBe(false); + expect(gtVersion('1.2.3' as SemVerVersion, '1.0.0' as SemVerVersion)).toBe( + true, + ); + + expect(gtVersion('2.0.0' as SemVerVersion, '1.0.0' as SemVerVersion)).toBe( + true, + ); + + expect(gtVersion('1.0.0' as SemVerVersion, '1.2.3' as SemVerVersion)).toBe( + false, + ); + + expect(gtVersion('1.0.0' as SemVerVersion, '2.0.0' as SemVerVersion)).toBe( + false, + ); }); it('supports pre-release versions', () => { - expect(gtVersion('1.0.0-beta.2', '1.0.0-beta.1')).toBe(true); - expect(gtVersion('1.0.0-beta.2', '1.2.3')).toBe(false); - expect(gtVersion('1.0.0', '1.0.0-beta.2')).toBe(true); - expect(gtVersion('1.2.3-beta.1', '1.0.0')).toBe(true); - expect(gtVersion('1.2.3-beta.1', '1.2.3-alpha.2')).toBe(true); + expect( + gtVersion( + '1.0.0-beta.2' as SemVerVersion, + '1.0.0-beta.1' as SemVerVersion, + ), + ).toBe(true); + + expect( + gtVersion('1.0.0-beta.2' as SemVerVersion, '1.2.3' as SemVerVersion), + ).toBe(false); + + expect( + gtVersion('1.0.0' as SemVerVersion, '1.0.0-beta.2' as SemVerVersion), + ).toBe(true); + + expect( + gtVersion('1.2.3-beta.1' as SemVerVersion, '1.0.0' as SemVerVersion), + ).toBe(true); + + expect( + gtVersion( + '1.2.3-beta.1' as SemVerVersion, + '1.2.3-alpha.2' as SemVerVersion, + ), + ).toBe(true); }); }); describe('getTargetVersion', () => { it('supports *', () => { - expect(getTargetVersion(['1.2.3', '3.0.0'], '*')).toBe('3.0.0'); + expect( + getTargetVersion( + ['1.2.3', '3.0.0'] as SemVerVersion[], + '*' as SemVerRange, + ), + ).toBe('3.0.0'); }); it('supports pre-release versions', () => { - expect(getTargetVersion(['1.0.0-beta.1'], '*')).toBe('1.0.0-beta.1'); - expect(getTargetVersion(['1.0.0-beta.1', '1.0.0-beta.2'], '*')).toBe( - '1.0.0-beta.2', - ); + expect( + getTargetVersion(['1.0.0-beta.1'] as SemVerVersion[], '*' as SemVerRange), + ).toBe('1.0.0-beta.1'); expect( - getTargetVersion(['1.0.0-beta.1', '1.0.0-beta.2'], '^1.0.0-beta.1'), + getTargetVersion( + ['1.0.0-beta.1', '1.0.0-beta.2'] as SemVerVersion[], + '*' as SemVerRange, + ), ).toBe('1.0.0-beta.2'); - expect(getTargetVersion(['1.0.0-alpha.2', '1.0.0-beta.1'], '*')).toBe( - '1.0.0-beta.1', - ); + expect( + getTargetVersion( + ['1.0.0-beta.1', '1.0.0-beta.2'] as SemVerVersion[], + '^1.0.0-beta.1' as SemVerRange, + ), + ).toBe('1.0.0-beta.2'); + + expect( + getTargetVersion( + ['1.0.0-alpha.2', '1.0.0-beta.1'] as SemVerVersion[], + '*' as SemVerRange, + ), + ).toBe('1.0.0-beta.1'); - expect(getTargetVersion(['0.9.0', '1.0.0-alpha.0'], '*')).toBe('0.9.0'); + expect( + getTargetVersion( + ['0.9.0', '1.0.0-alpha.0'] as SemVerVersion[], + '*' as SemVerRange, + ), + ).toBe('0.9.0'); }); - it('doesnt return pre-release versions by default', () => { - expect(getTargetVersion(['1.0.0-beta.1', '1.0.0', '1.2.3'], '*')).toBe( - '1.2.3', - ); + it("doesn't return pre-release versions by default", () => { + expect( + getTargetVersion( + ['1.0.0-beta.1', '1.0.0', '1.2.3'] as SemVerVersion[], + '*' as SemVerRange, + ), + ).toBe('1.2.3'); - expect(getTargetVersion(['1.0.0-beta.1', '1.0.0', '1.2.3'], '^1.0.0')).toBe( - '1.2.3', - ); - expect(getTargetVersion(['1.0.0-beta.1', '1.0.0'], '*')).toBe('1.0.0'); + expect( + getTargetVersion( + ['1.0.0-beta.1', '1.0.0', '1.2.3'] as SemVerVersion[], + '^1.0.0' as SemVerRange, + ), + ).toBe('1.2.3'); + + expect( + getTargetVersion( + ['1.0.0-beta.1', '1.0.0'] as SemVerVersion[], + '*' as SemVerRange, + ), + ).toBe('1.0.0'); }); }); -describe('satifiesVersionRange', () => { +describe('satisfiesVersionRange', () => { it('supports *', () => { - expect(satisfiesVersionRange('3.0.0', '*')).toBe(true); + expect( + satisfiesVersionRange('3.0.0' as SemVerVersion, '*' as SemVerRange), + ).toBe(true); }); it('supports exact versions', () => { - expect(satisfiesVersionRange('1.0.0-beta.1', '1.0.0-beta.1')).toBe(true); - expect(satisfiesVersionRange('1.0.0', '1.0.0')).toBe(true); - expect(satisfiesVersionRange('1.2.3', '1.0.0')).toBe(false); + expect( + satisfiesVersionRange( + '1.0.0-beta.1' as SemVerVersion, + '1.0.0-beta.1' as SemVerRange, + ), + ).toBe(true); + + expect( + satisfiesVersionRange('1.0.0' as SemVerVersion, '1.0.0' as SemVerRange), + ).toBe(true); + + expect( + satisfiesVersionRange('1.2.3' as SemVerVersion, '1.0.0' as SemVerRange), + ).toBe(false); }); it('supports non-exact version ranges', () => { - expect(satisfiesVersionRange('1.2.3', '^1.0.0')).toBe(true); - expect(satisfiesVersionRange('2.0.0', '^1.0.0')).toBe(false); + expect( + satisfiesVersionRange('1.2.3' as SemVerVersion, '^1.0.0' as SemVerRange), + ).toBe(true); + + expect( + satisfiesVersionRange('2.0.0' as SemVerVersion, '^1.0.0' as SemVerRange), + ).toBe(false); }); - it('prereleases can satisfy version range', () => { - expect(satisfiesVersionRange('1.0.0-beta.1', '*')).toBe(true); - expect(satisfiesVersionRange('1.0.0-beta.1', '^1.0.0')).toBe(false); + it('pre-releases can satisfy version range', () => { + expect( + satisfiesVersionRange( + '1.0.0-beta.1' as SemVerVersion, + '*' as SemVerRange, + ), + ).toBe(true); + + expect( + satisfiesVersionRange( + '1.0.0-beta.1' as SemVerVersion, + '^1.0.0' as SemVerRange, + ), + ).toBe(false); }); }); diff --git a/packages/snaps-utils/src/versions.ts b/packages/snaps-utils/src/versions.ts index 574e1650f8..8fcd36553a 100644 --- a/packages/snaps-utils/src/versions.ts +++ b/packages/snaps-utils/src/versions.ts @@ -1,12 +1,131 @@ -import { Json } from '@metamask/utils'; +import { assertStruct, Json } from '@metamask/utils'; import { gt as gtSemver, maxSatisfying as maxSatisfyingSemver, satisfies as satisfiesSemver, - validRange as validRangeSemver, + valid as validSemVerVersion, + validRange as validSemVerRange, } from 'semver'; +import { is, refine, string, Struct, validate } from 'superstruct'; -export const DEFAULT_REQUESTED_SNAP_VERSION = '*'; +import { Opaque } from './types'; + +export const DEFAULT_REQUESTED_SNAP_VERSION = '*' as SemVerRange; + +/** + * {@link https://codemix.com/opaque-types-in-javascript/ Opaque} type for SemVer ranges. + * + * @example Use {@link assertIsSemVerRange} and {@link isValidSemVerRange} to cast to proper type. + * ```typescript + * const unsafeRange: string = dataFromUser(); + * assertIsSemVerRange(unsafeRange); + * unsafeRange + * // ^? SemVerRange + * ``` + * @example If you know what you're doing and want to side-step type safety, casting from a string works correctly. + * ```typescript + * const unsafeRange: string = dataFromUser(); + * unsafeRange as SemVerRange; + * // ^? SemVerRange + * ``` + * @see {@link assertIsSemVerRange} + * @see {@link isValidSemVerRange} + */ +export type SemVerRange = Opaque; +declare const semVerRange: unique symbol; + +/** + * {@link https://codemix.com/opaque-types-in-javascript/ Opaque} type for singular SemVer version. + * + * @example Use {@link assertIsSemVerVersion} and {@link isValidSemVerVersion} to cast to proper type. + * ```typescript + * const unsafeVersion: string = dataFromUser(); + * assertIsSemVerVersion(unsafeRange); + * unsafeVersion + * // ^? SemVerVersion + * ``` + * @example If you know what you're doing and want to side-step type safety, casting from a string works correctly. + * ```typescript + * const unsafeVersion: string = dataFromUser(); + * unsafeRange as SemVerVersion; + * // ^? SemVerVersion + * ``` + * @see {@link assertIsSemVerVersion} + * @see {@link isValidSemVerVersion} + */ +export type SemVerVersion = Opaque; +declare const semVerVersion: unique symbol; + +/** + * A struct for validating a version string. + */ +export const VersionStruct = refine( + string() as unknown as Struct, + 'Version', + (value) => { + if (validSemVerVersion(value) === null) { + return `Expected SemVer version, got "${value}"`; + } + return true; + }, +); + +export const VersionRangeStruct = refine( + string() as unknown as Struct, + 'Version range', + (value) => { + if (validSemVerRange(value) === null) { + return `Expected SemVer range, got "${value}"`; + } + return true; + }, +); + +/** + * Checks whether a SemVer version is valid. + * + * @param version - A potential version. + * @returns `true` if the version is valid, and `false` otherwise. + */ +export function isValidSemVerVersion( + version: unknown, +): version is SemVerVersion { + return is(version, VersionStruct); +} + +/** + * Checks whether a SemVer version range is valid. + * + * @param versionRange - A potential version range. + * @returns `true` if the version range is valid, and `false` otherwise. + */ +export function isValidSemVerRange( + versionRange: unknown, +): versionRange is SemVerRange { + return is(versionRange, VersionRangeStruct); +} + +/** + * Asserts that a value is a valid concrete SemVer version. + * + * @param version - A potential SemVer concrete version. + */ +export function assertIsSemVerVersion( + version: unknown, +): asserts version is SemVerVersion { + assertStruct(version, VersionStruct); +} + +/** + * Asserts that a value is a valid SemVer range. + * + * @param range - A potential SemVer range. + */ +export function assertIsSemVerRange( + range: unknown, +): asserts range is SemVerRange { + assertStruct(range, VersionRangeStruct); +} /** * Checks whether a SemVer version is greater than another. @@ -15,7 +134,10 @@ export const DEFAULT_REQUESTED_SNAP_VERSION = '*'; * @param version2 - The right-hand version. * @returns `version1 > version2`. */ -export function gtVersion(version1: string, version2: string): boolean { +export function gtVersion( + version1: SemVerVersion, + version2: SemVerVersion, +): boolean { return gtSemver(version1, version2); } @@ -27,8 +149,8 @@ export function gtVersion(version1: string, version2: string): boolean { * @returns Whether the version satisfied the version range. */ export function satisfiesVersionRange( - version: string, - versionRange: string, + version: SemVerVersion, + versionRange: SemVerRange, ): boolean { return satisfiesSemver(version, versionRange, { includePrerelease: true, @@ -46,9 +168,9 @@ export function satisfiesVersionRange( * or `null` if none of them do. */ export function getTargetVersion( - versions: string[], - versionRange: string, -): string | null { + versions: SemVerVersion[], + versionRange: SemVerRange, +): SemVerVersion | null { const maxSatisfyingNonPreRelease = maxSatisfyingSemver( versions, versionRange, @@ -72,24 +194,11 @@ export function getTargetVersion( * @returns `*` if the version is `undefined` or `latest", otherwise returns * the specified version. */ -export function resolveVersion(version?: Json): Json { +export function resolveVersionRange( + version?: Json, +): [error: undefined, range: SemVerRange] | [error: Error, range: undefined] { if (version === undefined || version === 'latest') { - return DEFAULT_REQUESTED_SNAP_VERSION; + return [undefined, DEFAULT_REQUESTED_SNAP_VERSION]; } - return version; -} - -/** - * Checks whether a SemVer version range is valid. - * - * @param versionRange - A potential version range. - * @returns `true` if the version range is valid, and `false` otherwise. - */ -export function isValidSnapVersionRange( - versionRange: unknown, -): versionRange is string { - return Boolean( - typeof versionRange === 'string' && - validRangeSemver(versionRange, { includePrerelease: true }) !== null, - ); + return validate(version, VersionRangeStruct); }