Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SemVer handling is now through opaque types #961

Merged
merged 13 commits into from
Nov 18, 2022
Merged

SemVer handling is now through opaque types #961

merged 13 commits into from
Nov 18, 2022

Conversation

ritave
Copy link
Member

@ritave ritave commented Nov 14, 2022

Manifest handling is very sensitive part of Snaps. It's loaded inside the main extension process and contains a list of permissions.

Any tools that can help us make handling manifest related operations more secure are deeply appreciated.

This PR introduces two new types SemVerRange and SemVerVersion, both of which are strings, but behave as opaque types. Meaning that any type casting between those types needs to be explicit and disallows duck typing from and to string.

This provides not only better intention handling in the code, it increases security by requiring validation before casting.

@ritave ritave requested a review from a team as a code owner November 14, 2022 19:42
Copy link
Member

@Mrtenz Mrtenz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I agree with this in general. I'd prefer if we could implement this in a way with template literal types, i.e., ${string}.${string}.${string} for proper version types. This avoids the need for type casts as well.

@@ -92,7 +93,7 @@ describe('CronjobController', () => {

const callActionMock = jest
.spyOn(controllerMessenger, 'call')
.mockImplementation((method) => {
.mockImplementation((method, ..._) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Member Author

@ritave ritave Nov 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visual Studio Code is using new version of TS, and it's complaining that we're not accepting all the parameters of the call. This silences TypeScript in VSC, and will be useful when we're going to upgrade to newer version.

packages/snaps-utils/src/versions.test.ts Outdated Show resolved Hide resolved
@ritave
Copy link
Member Author

ritave commented Nov 14, 2022

I'm not sure if I agree with this in general. I'd prefer if we could implement this in a way with template literal types, i.e., ${string}.${string}.${string} for proper version types. This avoids the need for type casts as well.

The whole point is to have explicit type casts. Otherwise TypeScript will happily duck-type a version between range and we'll not find it during testing.

For example 1.2.3 is both a valid version and a range. Without opaque types it'll be accepted into wrong function, breaking the assumptions our code has.

Additionally ${string}.${string}.${string} is invalid, as it doesn't validate pre-releases properly. Having the type follow the full logic of semver package might not be reasonable.

I've worked with Opaque Types before in TypeScript, and believe me, it's the only way to properly disable duck typing.

@ritave ritave force-pushed the ritave/opaque branch 2 times, most recently from b2f5207 to 90a8f1f Compare November 15, 2022 19:58
* @see {@link assertIsSemVerRange}
* @see {@link isValidSemVerRange}
*/
export type SemVerRange = Opaque<string, typeof semVerRange>;
Copy link
Member Author

@ritave ritave Nov 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL - interfaces can be self recursive but types can't.

// works
interface A {
  b: Opaque<string, A>;
}

// errors
type B = Opaque<string, B>;

@@ -27,6 +28,16 @@ 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<Omit<PersistedSnap, 'version'>> & {
version?: string | SemVerVersion;
Copy link
Contributor

@hmalik88 hmalik88 Nov 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use omit here (and in the other instances that you do this) when you can just change the original type to be SemVerVersion? Also I don't understand why we would want it to be union type w/ string? I thought the whole point of the opaque types was to narrow types?

Copy link
Contributor

@hmalik88 hmalik88 Nov 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need to not change original types for some reason, here's a utility type to make things a tiny bit less redundant and clearer to understand:

type MakeSemVer<T> = { [K in keyof T]: K extends 'version' ? SemVerVersion | string : T[K]; }

// then you can do something like below
type GetPersistedSnapObjectOptions = Partial<MakeSemVer<PersistedSnap>>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's tens if not hundreds of tests that use those functions, and we would have to update all of them to have a type cast.

This is for backwards compatibility because of that.

The whole source code (not tests) is type cast though.

packages/snaps-utils/src/versions.ts Outdated Show resolved Hide resolved
@@ -2593,7 +2595,7 @@ describe('SnapController', () => {
.mockImplementationOnce(async () => {
const manifest: SnapManifest = {
...getSnapManifest(),
version: '1.1.0',
version: '1.1.0' as SemVerVersion,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be moved to a constant on top of the file.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not possible in this case. Tests that use specific versions instead of creation utils, do it because they're testing version/updates functionality.

@@ -27,6 +28,16 @@ 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<Omit<PersistedSnap, 'version'>> & {
version?: string | SemVerVersion;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we accept string here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@Mrtenz Mrtenz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some small comments.

@@ -62,7 +62,8 @@
"rfdc": "^1.3.0",
"semver": "^7.3.7",
"ses": "^0.17.0",
"superstruct": "^0.16.7"
"superstruct": "^0.16.7",
"ts-opaque": "^3.0.1"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove.

export function resolveVersion(version?: Json): Json {
export function resolveVersionRange(
version?: Json,
): [undefined, SemVerRange] | [Error, undefined] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
): [undefined, SemVerRange] | [Error, undefined] {
): [error: undefined, range: SemVerRange] | [error: Error, range: undefined] {

@codecov-commenter
Copy link

codecov-commenter commented Nov 18, 2022

Codecov Report

Merging #961 (07d0650) into main (fb6989c) will increase coverage by 0.08%.
The diff coverage is 100.00%.

@@            Coverage Diff             @@
##             main     #961      +/-   ##
==========================================
+ Coverage   92.58%   92.67%   +0.08%     
==========================================
  Files          93       93              
  Lines        9116     9226     +110     
  Branches      890      894       +4     
==========================================
+ Hits         8440     8550     +110     
  Misses        676      676              
Impacted Files Coverage Δ
packages/snaps-cli/src/cmds/init/initHandler.ts 100.00% <100.00%> (ø)
packages/snaps-utils/src/manifest/validation.ts 100.00% <100.00%> (ø)
packages/snaps-utils/src/snaps.ts 98.84% <100.00%> (+<0.01%) ⬆️
packages/snaps-utils/src/types.ts 100.00% <100.00%> (ø)
packages/snaps-utils/src/versions.ts 100.00% <100.00%> (ø)

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

@ritave ritave merged commit b6471f6 into main Nov 18, 2022
@ritave ritave deleted the ritave/opaque branch November 18, 2022 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants