diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts index 417c8657a7e2d1..aaf75903ff3150 100644 --- a/lib/modules/versioning/api.ts +++ b/lib/modules/versioning/api.ts @@ -29,6 +29,7 @@ import * as python from './python'; import * as redhat from './redhat'; import * as regex from './regex'; import * as rez from './rez'; +import * as rpm from './rpm'; import * as ruby from './ruby'; import * as semver from './semver'; import * as semverCoerced from './semver-coerced'; @@ -70,6 +71,7 @@ api.set(python.id, python.api); api.set(redhat.id, redhat.api); api.set(regex.id, regex.api); api.set(rez.id, rez.api); +api.set(rpm.id, rpm.api); api.set(ruby.id, ruby.api); api.set(semver.id, semver.api); api.set(semverCoerced.id, semverCoerced.api); diff --git a/lib/modules/versioning/rpm/index.spec.ts b/lib/modules/versioning/rpm/index.spec.ts new file mode 100644 index 00000000000000..117e89aa40fb63 --- /dev/null +++ b/lib/modules/versioning/rpm/index.spec.ts @@ -0,0 +1,189 @@ +import rpm from '.'; + +describe('modules/versioning/rpm/index', () => { + test.each` + version | expected + ${'1.1'} | ${true} + ${'1.3.RC2'} | ${true} + ${'0:1.1-1'} | ${true} + ${'a:1.1-1'} | ${false} + ${'1.1:1.3-1'} | ${false} + ${'1.1a:1.3-1'} | ${false} + ${'1a:1.3-1'} | ${false} + ${'-1:1.3-1'} | ${false} + ${'1:1:1:2-1'} | ${true} + ${'1:a:b:c:2-1'} | ${true} + ${'1:3_3.2-1'} | ${true} + ${'1:3!3.2-1'} | ${true} + ${'1:3/3.2-1'} | ${true} + ${'1.0-3_2'} | ${true} + ${'1.0-3!3'} | ${true} + ${'1.0-3/3'} | ${true} + ${'1.0+รค1-1'} | ${true} + ${'1,0-1'} | ${true} + ${'2:1.1-1'} | ${true} + ${'1.1.1-0rpmian1'} | ${true} + ${'1.1.1+really1.1.2-0rpmian1'} | ${true} + ${'2.31-13+rpm11u5'} | ${true} + ${'1:0.17.20140318svn632.el7'} | ${true} + ${'2.7.7+dfsg-12'} | ${true} + ${'8.20140605hgacf1c26e3029.el7'} | ${true} + ${'5:0.5.20120830CVS.el7'} | ${true} + ${'1:6.0.1r16-1.1build1'} | ${true} + ${'1.el6'} | ${true} + ${'1:2.20.1-1~bpo9+1'} | ${true} + ${'v1.4'} | ${true} + ${'3.5.0'} | ${true} + ${'4.2.21.Final'} | ${true} + ${'0.6.5.1'} | ${true} + ${'20100527'} | ${true} + ${'2.1.0-M3'} | ${true} + ${'4.3.20.RELEASE'} | ${true} + ${'1.1-groovy-2.4'} | ${true} + ${'0.8a'} | ${true} + ${'3.1.0.GA'} | ${true} + ${'3.0.0-beta.3'} | ${true} + ${'foo'} | ${true} + ${'1.2.3.4.5.6.7'} | ${true} + ${'0a1b2c3'} | ${true} + ${'0a1b2c3d'} | ${true} + ${'0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d'} | ${true} + ${'0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0'} | ${true} + ${'0a1b2C3'} | ${true} + ${'0z1b2c3'} | ${true} + ${'0A1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d'} | ${true} + ${'123098140293'} | ${true} + ${'3.12.0-1~a1^20231001'} | ${true} + ${'1.2.3^20231001'} | ${true} + `('isValid("$version") === $expected', ({ version, expected }) => { + expect(rpm.isValid(version)).toBe(expected); + }); + + test.each` + a | b | expected + ${''} | ${''} | ${true} + ${'~a'} | ${'~~'} | ${false} + ${'~'} | ${'~'} | ${true} + ${'~'} | ${'1'} | ${false} + ${'1~'} | ${'~'} | ${false} + ${'1'} | ${'a'} | ${false} + ${'2.4'} | ${'2.4'} | ${true} + ${'2.4.~'} | ${'2.4'} | ${false} + ${'2.4'} | ${'2.4.~'} | ${false} + ${'2.4.0'} | ${'2.4.0'} | ${true} + ${'2.4.0'} | ${'2.4'} | ${false} + ${'2.4.1'} | ${'2.4'} | ${false} + ${'2.4.2'} | ${'2.4.1'} | ${false} + ${'0.8a'} | ${'0.8a'} | ${true} + ${'90.5.20120830CVS.el6'} | ${'0.5.20120830CVS.el7'} | ${false} + ${'0.5.20120830CVS.el7'} | ${'0.5.20120830CVS.el6'} | ${false} + ${'0.5.20120830CVS.el7'} | ${'0.5.20120830CVS.el7'} | ${true} + ${'2.31-13+rpm11u5'} | ${'2.31-13+rpm11u5'} | ${true} + ${'2.31-13+rpm11u5'} | ${'2.31-13+rpm11u4'} | ${false} + ${'1.4-'} | ${'1.4'} | ${true} + ${'v1.4'} | ${'1.4'} | ${false} + ${'0:1.4'} | ${'1.4'} | ${true} + ${'1:1.4'} | ${'1.4'} | ${false} + ${'1.4-1'} | ${'1.4-2'} | ${false} + ${'0:1.4'} | ${'a:1.4'} | ${false} + ${'a:1.4'} | ${'0:1.4'} | ${false} + ${'3.12.0-1~^2023'} | ${'3.12.0-1^2023'} | ${false} + `('equals("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(rpm.equals(a, b)).toBe(expected); + }); + + test.each` + a | b | expected + ${'2.4.0'} | ${'2.4'} | ${true} + ${'2.4.2'} | ${'2.4.1'} | ${true} + ${'2.4.beta'} | ${'2.4.alpha'} | ${true} + ${'1.9'} | ${'2'} | ${false} + ${'1.9'} | ${'1.9.1'} | ${false} + ${'2.4'} | ${'2.4.beta'} | ${false} + ${'2.4.0'} | ${'2.4.beta'} | ${true} + ${'2.4.beta'} | ${'2.4'} | ${true} + ${'2.4.beta'} | ${'2.4.0'} | ${false} + ${'2.4~'} | ${'2.4~~'} | ${true} + ${'2.4'} | ${'2.4~'} | ${true} + ${'2.4a'} | ${'2.4'} | ${true} + ${'2.31-13+rpm11u5'} | ${'2.31-9'} | ${true} + ${'2.31-13+rpm11u5'} | ${'2.31-13+rpm10u5'} | ${true} + ${'2.31-13+rpm11u5'} | ${'2.31-13+rpm11u4'} | ${true} + ${'1.9'} | ${'1:1.7'} | ${false} + ${'1.9'} | ${'1.12'} | ${false} + ${'1.12'} | ${'1.9'} | ${true} + ${'1:1.9'} | ${'1:1.7'} | ${true} + ${'2.4.0.beta1'} | ${'2.4.0.Beta1'} | ${true} + ${'1:1.0'} | ${'1:1.0~'} | ${true} + ${'1:1.0Z0-0'} | ${'1:1.0'} | ${true} + ${'1:1.0Z0-0'} | ${'1:1.0A0-0'} | ${true} + ${'1:1.0a0-0'} | ${'1:1.0Z0-0'} | ${true} + ${'1:1.0z0-0'} | ${'1:1.0a0-0'} | ${true} + ${'1:1.0+0-0'} | ${'1:1.0z0-0'} | ${true} + ${'1:1.0-0-0'} | ${'1:1.0+0-0'} | ${false} + ${'1:1.0.0-0'} | ${'1:1.0-0-0'} | ${true} + ${'1:1.0:0-0'} | ${'1:1.0.0-0'} | ${false} + ${'a:1.4'} | ${'0:1.4'} | ${true} + ${'0:1.4'} | ${'a:1.4'} | ${true} + ${'a:1.4'} | ${'a:1.4'} | ${true} + ${'a1'} | ${'a~'} | ${true} + ${'a0'} | ${'a~'} | ${true} + ${'aa'} | ${'a1'} | ${true} + ${'ab'} | ${'a0'} | ${true} + ${'10'} | ${'1.'} | ${true} + ${'10'} | ${'1a'} | ${true} + ${'a'} | ${'A'} | ${true} + ${'A'} | ${'a'} | ${false} + ${'A1'} | ${'Aa'} | ${false} + ${'aaaaa1'} | ${'aaaaaaaaaaaa2'} | ${false} + ${'a-1~^20231001'} | ${'a-1^20231001'} | ${false} + ${'1'} | ${'2'} | ${false} + ${'a-1~pre2^20231001'} | ${'a-1~pre2^20231002'} | ${false} + ${'a-1'} | ${'a-1~pre1'} | ${true} + ${'4.20-4~beta4'} | ${'4.20-4'} | ${false} + ${'1.2.3~beta2'} | ${'1.2.3~alpha1'} | ${true} + ${'1.2.3-4~alpha1'} | ${'1.2.3-4~beta2'} | ${false} + ${'}}}'} | ${'{{{'} | ${false} + `('isGreaterThan("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(rpm.isGreaterThan(a, b)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${1} + ${'2-0-1'} | ${2} + ${'2.31-13+rpm11u5'} | ${2} + ${'1:2.3.1'} | ${2} + ${'foo'} | ${null} + ${'8'} | ${8} + ${'1.0'} | ${1} + `('getMajor("$version") === $expected', ({ version, expected }) => { + expect(rpm.getMajor(version)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${3} + ${'2-0-1'} | ${0} + ${'2.31-13+rpm11u5'} | ${31} + ${'1:2.3.1'} | ${3} + ${'foo'} | ${null} + ${'8'} | ${null} + ${'1.0'} | ${0} + `('getMinor("$version") === $expected', ({ version, expected }) => { + expect(rpm.getMinor(version)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${0} + ${'2-0-1'} | ${1} + ${'2.31-13+rpm11u5'} | ${13} + ${'1:2.3.1'} | ${1} + ${'foo'} | ${null} + ${'8'} | ${null} + ${'1.0'} | ${null} + `('getPatch("$version") === $expected', ({ version, expected }) => { + expect(rpm.getPatch(version)).toBe(expected); + }); +}); diff --git a/lib/modules/versioning/rpm/index.ts b/lib/modules/versioning/rpm/index.ts new file mode 100644 index 00000000000000..1c4fa202f3cce9 --- /dev/null +++ b/lib/modules/versioning/rpm/index.ts @@ -0,0 +1,282 @@ +import is from '@sindresorhus/is'; +import { regEx } from '../../../util/regex'; +import { GenericVersion, GenericVersioningApi } from '../generic'; +import type { VersioningApi } from '../types'; + +export const id = 'rpm'; +export const displayName = 'RPM version'; +export const urls = [ + 'https://docs.fedoraproject.org/en-US/packaging-guidelines/Versioning/', + 'https://fedoraproject.org/wiki/Package_Versioning_Examples', + 'https://fedoraproject.org/wiki/User:Tibbs/TildeCaretVersioning', +]; +export const supportsRanges = false; + +const alphaNumPattern = regEx(/([a-zA-Z]+)|(\d+)|(~)/g); +const epochPattern = regEx(/^\d+$/); + +export interface RpmVersion extends GenericVersion { + /** + * epoch, defaults to 0 if not present, are used to leave version mistakes and previous + * versioning schemes behind. + */ + epoch: number; + /** + * upstreamVersion is the main version part: it defines the version of origin software + * that was packaged. + */ + upstreamVersion: string; + /** + * rpmRelease is used to distinguish between different versions of packaging for the + * same upstream version. + */ + rpmRelease: string; + + /** + * rpmPreRelease is used to distinguish versions of prerelease of the same upstream and release version + * Example: Python 3.12.0-1 > Python 3.12.0-1~a1 + */ + rpmPreRelease: string; + + /** + * snapshot is an archive taken from upstream's source code control system which is not equivalent to any release version. + * This field must at minimum consist of the date in eight-digit "YYYYMMDD" format. The packager MAY + * include up to 17 characters of additional information after the date. The following formats are suggested: + * YYYYMMDD. + * YYYYMMDD + */ + snapshot: string; +} + +class RpmVersioningApi extends GenericVersioningApi { + /** + * https://github.com/rpm-software-management/rpm/blob/e3c11a790367016aed7ea48cfcc78751a71ce862/rpmio/rpmvercmp.c#L16 + */ + protected _parse(version: string): RpmVersion | null { + let remainingVersion = version; + + let epoch = 0; + const epochIndex = remainingVersion.indexOf(':'); + if (epochIndex !== -1) { + const epochStr = remainingVersion.slice(0, epochIndex); + if (epochPattern.test(epochStr)) { + epoch = parseInt(epochStr, 10); + } else { + return null; + } + + remainingVersion = remainingVersion.slice(epochIndex + 1); + } + + let upstreamVersion: string; + let rpmRelease = ''; + let rpmPreRelease = ''; + let snapshot = ''; + const releaseIndex = remainingVersion.indexOf('-'); + const prereleaseIndex = remainingVersion.indexOf('~'); + + // Note: There can be a snapshot if there is no prerelease. Snapshot always beat no snapshot, + // so if there is 3.12.0-1 vs 3.12.0-1^20231110, the snapshot wins. + // The logic below only creates snapshot IF there is a prerleease version. This logic is NOT + // correct, but the result is still correct due to the caret being ignored in release, and + // release continue comparing + // + // Note: If there IS a tilde preceding the caret, then snapshot DOES NOT win + // Example: 3.12.0-1~^20231001 LOSES to 3.12.0-1 and + // 3.12.0-1~^20231001 LOSES to 3.12.0-1^20231001 + const snapshotIndex = remainingVersion.indexOf('^'); + + if (releaseIndex >= 0) { + upstreamVersion = remainingVersion.slice(0, releaseIndex); + + // Do NOT splice out prerelease, we need to distinguish if the flag is set or not, regardless if there is a version. + // The tilde will get filtered out during regex + if (prereleaseIndex >= 0) { + rpmRelease = remainingVersion.slice(releaseIndex, prereleaseIndex); + if (snapshotIndex >= 0) { + rpmPreRelease = remainingVersion.slice( + prereleaseIndex, + snapshotIndex, + ); + snapshot = remainingVersion.slice(snapshotIndex + 1); + } else { + rpmPreRelease = remainingVersion.slice(prereleaseIndex); + } + } else { + rpmRelease = remainingVersion.slice(releaseIndex + 1); + } + } else { + upstreamVersion = remainingVersion; + } + + upstreamVersion; + + const release = [...remainingVersion.matchAll(regEx(/\d+/g))].map((m) => + parseInt(m[0], 10), + ); + + return { + epoch, + upstreamVersion, + rpmRelease, + release, + rpmPreRelease, + snapshot, + }; + } + + protected _compare_string(s1: string, s2: string): number { + if (s1 === s2) { + return 0; + } + + const minLength = Math.min(s1.length, s2.length); + + for (let i = 0; i < minLength; i++) { + const c1 = s1[i]; + const c2 = s2[i]; + + if (c1 === c2) { + continue; + } + + if (c1 > c2) { + return 1; + } else if (c1 < c2) { + return -1; + } + } + + // Okay, they've been the exact same up until now, so return the longer one + return s1.length > s2.length ? 1 : -1; + } + + /** + * Taken from https://github.com/rpm-software-management/rpm/blob/master/rpmio/rpmvercmp.c + */ + protected _compare_glob(v1: string, v2: string): number { + if (v1 === v2) { + return 0; + } + + const matchesv1 = v1.match(alphaNumPattern) ?? []; + const matchesv2 = v2.match(alphaNumPattern) ?? []; + const matches = Math.min(matchesv1.length, matchesv2.length); + + for (let i = 0; i < matches; i++) { + const matchv1 = matchesv1[i]; + const matchv2 = matchesv2[i]; + + // compare tildes + if (matchv1?.[0] === '~' || matchv2?.[0] === '~') { + if (matchv1?.[0] !== '~') { + return 1; + } + + if (matchv2?.[0] !== '~') { + return -1; + } + } + + if (is.numericString(matchv1?.[0])) { + // numbers are greater than letters + if (!is.numericString(matchv2?.[0])) { + return 1; + } + + //We clearly have a number here, so return which is greater + const result = matchv1.localeCompare(matchv2, undefined, { + numeric: true, + }); + + if (result === 0) { + continue; + } + + return Math.sign(result); + } else if (is.numericString(matchv2?.[0])) { + return -1; + } + + // We have two string globs, compare them + const compared_value = this._compare_string(matchv1, matchv2); + if (compared_value !== 0) { + return compared_value; + } + } + + // segments were all the same, but separators were different + if (matchesv1.length === matchesv2.length) { + return 0; + } + + // If there is a tilde in a segment past the minimum number of segments, find it + if (matchesv1.length > matches && matchesv1[matches][0] === '~') { + return -1; + } + + if (matchesv2.length > matches && matchesv2[matches][0] === '~') { + return 1; + } + + // whichever has the most segments wins + return matchesv1.length > matchesv2.length ? 1 : -1; + } + + protected override _compare(version: string, other: string): number { + const parsed1 = this._parse(version); + const parsed2 = this._parse(other); + + if (!(parsed1 && parsed2)) { + return 1; + } + + // Greater epoch wins + if (parsed1.epoch !== parsed2.epoch) { + return Math.sign(parsed1.epoch - parsed2.epoch); + } + + // Greater upstream version wins + const upstreamVersionDifference = this._compare_glob( + parsed1.upstreamVersion, + parsed2.upstreamVersion, + ); + + if (upstreamVersionDifference !== 0) { + return upstreamVersionDifference; + } + + // Greater release version wins + const releaseVersionDifference = this._compare_glob( + parsed1.rpmRelease, + parsed2.rpmRelease, + ); + + if (releaseVersionDifference !== 0) { + return releaseVersionDifference; + } + + // No Prerelease wins + if (parsed1.rpmPreRelease === '' && parsed2.rpmPreRelease !== '') { + return 1; + } else if (parsed1.rpmPreRelease !== '' && parsed2.rpmPreRelease === '') { + return -1; + } + + const preReleaseDifference = this._compare_glob( + parsed1.rpmPreRelease, + parsed2.rpmPreRelease, + ); + + if (preReleaseDifference !== 0) { + return releaseVersionDifference; + } + + // Greater Snapshot wins + return this._compare_glob(parsed1.snapshot, parsed2.snapshot); + } +} + +export const api: VersioningApi = new RpmVersioningApi(); + +export default api;