Skip to content

Commit

Permalink
fix(wallet): fix semver compatibility check (#442)
Browse files Browse the repository at this point in the history
Ensures the semver compatibility layer correctly validates versions
instead of simply doing a string comparison for latest api version.
  • Loading branch information
keplervital authored Nov 26, 2024
1 parent 6dce3f7 commit 6a58403
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 5 deletions.
7 changes: 5 additions & 2 deletions apps/wallet/src/core/compatibility.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { appInitConfig } from '~/configs/init.config';
import { icAgent } from '~/core/ic-agent.core';
import logger from '~/core/logger.core';
import { redirectToKey } from '~/plugins/router.plugin';
import { isSemanticVersion } from '~/utils/helper.utils';
import { isSemanticVersion, SemanticVersion } from '~/utils/helper.utils';
import { ApiCompatibilityInfo } from '~build/types/compat.types';

/**
Expand Down Expand Up @@ -178,11 +178,14 @@ export const createCompatibilityLayer = (agent: HttpAgent = icAgent.get()) => {

const compatibility = compat.api.compatibility;

const stationApiSemver = SemanticVersion.parse(stationApiVersion);
const latestCompatApiSemver = SemanticVersion.parse(compat.api.latest);

// If the station API version is newer than the latest supported version, then we treat it as incompatible
// and redirect to the unversioned path to avoid breaking the UI. It will also get redirected
// if the compatibility file does not have the station API version.
if (
stationApiVersion > compat.api.latest ||
stationApiSemver.isGreaterThan(latestCompatApiSemver) ||
!compatibility?.[stationApiVersion]?.ui ||
compatibility[stationApiVersion].ui.length === 0
) {
Expand Down
48 changes: 48 additions & 0 deletions apps/wallet/src/utils/helper.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseLocationQuery,
parseToBigIntOrUndefined,
removeBasePathFromPathname,
SemanticVersion,
throttle,
toArrayBuffer,
transformData,
Expand Down Expand Up @@ -123,6 +124,53 @@ describe('Semver utils', () => {
expect(isSemanticVersion('v1.0', 'v')).toBe(false);
});
});

describe('SemanticVersion', () => {
it.each([
['1.0.0', '2.0.0'],
['1.0.0', '1.1.0'],
['1.0.0', '1.0.1'],
['1.1.1', '1.1.11'],
['1.0.0-alpha.9', '1.0.0-alpha.10'],
['1.0.0-alpha.10', '1.0.0-alpha.11'],
['1.0.0-alpha.9', '1.0.0-beta'],
['1.0.0-beta', '1.0.0'],
['1.0.0-beta', '1.0.0+build'],
])('version `v%s` is less than version `v%s`', (version, newVersion) => {
const olderVersion = SemanticVersion.parse(version);
const newerVersion = SemanticVersion.parse(newVersion);

expect(olderVersion.isLessThan(newerVersion)).toBe(true);
});

it.each([
['1.0.0', '1.0.0'],
['1.0.0', '1.0.0+build'],
['1.0.0+build', '1.0.0+build'],
['1.0.0+build', '1.0.0+build.1'],
])('version `v%s` is equal to version `v%s`', (version, newVersion) => {
const olderVersion = SemanticVersion.parse(version);
const newerVersion = SemanticVersion.parse(newVersion);

expect(olderVersion.isEqualTo(newerVersion)).toBe(true);
});

it.each([
['1.0.0', '2.0.0'],
['1.0.0', '1.1.0'],
['1.0.0', '1.0.1'],
['1.1.1', '1.1.11'],
['1.0.0-alpha.9', '1.0.0-alpha.10'],
['1.0.0-alpha.9', '1.0.0-beta'],
['1.0.0-beta', '1.0.0'],
['1.0.0-beta', '1.0.0+build'],
])('version `v%s` is older than version `v%s`', (oldVersion, newVersion) => {
const olderVersion = SemanticVersion.parse(oldVersion);
const newerVersion = SemanticVersion.parse(newVersion);

expect(newerVersion.isGreaterThan(olderVersion)).toBe(true);
});
});
});

describe('Url utils', () => {
Expand Down
86 changes: 83 additions & 3 deletions apps/wallet/src/utils/helper.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ export const transformIdlWithOnlyVerifiedCalls = (
};
};

const semanticVersionRegex =
/^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<preRelease>[\w.]+))?(?:\+(?<build>[\w.]+))?$/;

// Checks if a string is with the correct format for a semantic version.
//
// More information on semantic versioning can be found at: https://semver.org/
Expand All @@ -224,11 +227,88 @@ export const isSemanticVersion = (version: string, prefix = ''): boolean => {
versionWithoutPrefix = version.slice(prefix.length);
}

return /^((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/.test(
versionWithoutPrefix,
);
return semanticVersionRegex.test(versionWithoutPrefix);
};

export class SemanticVersion {
constructor(
public major: number,
public minor: number,
public patch: number,
public preReleaseType?: string,
public preReleaseNumber?: number,
public build?: string,
) {
if (major < 0 || minor < 0 || patch < 0) {
throw new Error('Version numbers must be positive');
}
}

static parse(version: string): SemanticVersion {
const match = version.match(semanticVersionRegex);
if (!match) {
throw new Error(`Invalid semantic version: ${version}`);
}

const [, major, minor, patch, preRelease, build] = match;
const [preReleaseType, preReleaseNumber] = preRelease?.split('.') ?? [undefined, undefined];

return new SemanticVersion(
Number(major),
Number(minor),
Number(patch),
preReleaseType,
preReleaseNumber !== undefined ? Number(preReleaseNumber) : 0,
build,
);
}

public toString(): string {
const preRelease = this.preReleaseType
? `-${this.preReleaseType}${this.preReleaseNumber ?? 0}`
: '';
const build = this.build ? `+${this.build}` : '';
return `${this.major}.${this.minor}.${this.patch}${preRelease}${build}`;
}

public compare(other: SemanticVersion): number {
// Compare major, minor, and patch versions
if (this.major !== other.major) return this.major - other.major;
if (this.minor !== other.minor) return this.minor - other.minor;
if (this.patch !== other.patch) return this.patch - other.patch;

// Compare pre-release types and numbers
if (this.preReleaseType || other.preReleaseType) {
if (!this.preReleaseType) return 1; // No pre-release means higher precedence
if (!other.preReleaseType) return -1;
const typeComparison = this.preReleaseType.localeCompare(other.preReleaseType);
if (typeComparison !== 0) return typeComparison;

// Compare pre-release numbers
const thisNumber = this.preReleaseNumber ?? 0;
const otherNumber = other.preReleaseNumber ?? 0;
if (thisNumber !== otherNumber) return thisNumber - otherNumber;
}

// As per semver spec, Build metadata MUST be ignored when determining version precedence.
// https://semver.org/#spec-item-10

return 0;
}

public isGreaterThan(other: SemanticVersion): boolean {
return this.compare(other) > 0;
}

public isLessThan(other: SemanticVersion): boolean {
return this.compare(other) < 0;
}

public isEqualTo(other: SemanticVersion): boolean {
return this.compare(other) === 0;
}
}

export const removeBasePathFromPathname = (pathname: string, basePath: string): string => {
const updatedPath = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;

Expand Down

0 comments on commit 6a58403

Please sign in to comment.